Compare commits
439 Commits
v0.9.3
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a10d12e0 | ||
|
|
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 | ||
|
|
ff71302969 | ||
|
|
4f6c8523c0 | ||
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
5067db3dd0 | ||
|
|
c7195469bd | ||
|
|
1ef01da019 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 | ||
|
|
ffe991bbe4 | ||
|
|
3047a1e602 | ||
|
|
e6c568988a | ||
|
|
45fab91e7f | ||
|
|
d3484ec3af | ||
|
|
cb0d601b09 | ||
|
|
9ea4f6b5ef | ||
|
|
bf9ee76de5 | ||
|
|
6ed1e09180 | ||
|
|
54d4cf6604 | ||
|
|
359e89971f | ||
|
|
7f833747b0 | ||
|
|
ab3f228d85 | ||
|
|
67a530a83b | ||
|
|
612ec6af1b | ||
|
|
dbde403b3e | ||
|
|
3382736f05 | ||
|
|
fd5941fb36 | ||
|
|
9b76521a90 | ||
|
|
ea92c0609d | ||
|
|
612e50808a | ||
|
|
2c24402742 | ||
|
|
d7c4bf1e45 | ||
|
|
5bfb09c73b | ||
|
|
fd499d95e6 | ||
|
|
204b2e020b | ||
|
|
d34e0163e3 | ||
|
|
a93252621a | ||
|
|
8ce7a9b4ee | ||
|
|
63ffb86ea7 | ||
|
|
bd9a8d9788 | ||
|
|
d291c2f074 | ||
|
|
16c2eeca3e | ||
|
|
d9d281af8c | ||
|
|
56a6364f99 | ||
|
|
ba20dd6f2f | ||
|
|
0d96a9f9ff | ||
|
|
ee9da95044 | ||
|
|
0511d92cbf | ||
|
|
e666ac333c | ||
|
|
8495dcd021 | ||
|
|
01ab2f2794 | ||
|
|
b59e85abda | ||
|
|
4eded9e204 | ||
|
|
90164aa507 | ||
|
|
f87c83cadd | ||
|
|
01300a81de | ||
|
|
d143faf8eb | ||
|
|
8c29741830 | ||
|
|
d360089b80 | ||
|
|
4279b25ff4 | ||
|
|
230c981cc2 | ||
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 | ||
|
|
34978c87fb | ||
|
|
dcbe3475ed | ||
|
|
338a88fb5a | ||
|
|
7eb1551e4b | ||
|
|
0414f924e6 | ||
|
|
9456871271 | ||
|
|
5b4edef785 | ||
|
|
6b81d0d703 | ||
|
|
4097637169 | ||
|
|
9bd66e7297 | ||
|
|
883b0724e0 | ||
|
|
7b6ed88be4 | ||
|
|
e0bb867948 | ||
|
|
ca28f503b7 | ||
|
|
c83028abc2 | ||
|
|
60406ca8fb | ||
|
|
e878c3c83b | ||
|
|
bdd3fe8899 | ||
|
|
3cfaf689e7 | ||
|
|
b41da03e8a | ||
|
|
ef14b9acb6 | ||
|
|
99474955af | ||
|
|
6f73adaef6 | ||
|
|
e2ff758003 | ||
|
|
748a99c9c4 | ||
|
|
db2d764cce | ||
|
|
157fe9d6b4 | ||
|
|
6c42b64466 | ||
|
|
88605a4617 | ||
|
|
e8f8e7bd65 | ||
|
|
750a87ef45 | ||
|
|
8fda9aed71 | ||
|
|
7e1dab8384 | ||
|
|
5b24f0cd40 | ||
|
|
a6b1f4ba19 | ||
|
|
df02b7cdca | ||
|
|
06b0d03c31 | ||
|
|
fd22a5ed9d | ||
|
|
86db407c0b | ||
|
|
f1520be777 | ||
|
|
3e6d0a402c | ||
|
|
8a91e04ff9 | ||
|
|
76b1134c95 | ||
|
|
d98d519fd3 | ||
|
|
02407e0f7a | ||
|
|
0261154a5e | ||
|
|
d2b68159be | ||
|
|
aab0692403 | ||
|
|
17a3e43ac7 | ||
|
|
a2127a11ac | ||
|
|
ea4c687125 | ||
|
|
de20b3adf3 | ||
|
|
929e79befd | ||
|
|
3522d3dff5 | ||
|
|
1af01680ee | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
257
.github/workflows/build-and-upload.yml
vendored
257
.github/workflows/build-and-upload.yml
vendored
@@ -3,6 +3,11 @@ name: Build and Upload Binaries
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
description: "Version to apply to workspace packages (release builds)"
|
||||
required: false
|
||||
@@ -23,6 +28,21 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
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:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
@@ -33,7 +53,7 @@ on:
|
||||
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
@@ -45,6 +65,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -54,7 +76,21 @@ jobs:
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
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
|
||||
run: npm ci --workspaces --include=optional
|
||||
@@ -65,17 +101,132 @@ jobs:
|
||||
- name: Build macOS binaries (Electron)
|
||||
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
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
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:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
@@ -85,6 +236,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -115,6 +268,15 @@ jobs:
|
||||
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:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -124,6 +286,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -149,12 +313,23 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
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:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -164,6 +339,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -195,7 +372,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -206,7 +383,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -237,6 +423,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -268,7 +456,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -279,7 +467,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -310,6 +507,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -343,7 +542,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-win32-x64-msvc@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -355,7 +554,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
@@ -368,6 +567,15 @@ jobs:
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
@@ -388,6 +596,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -404,6 +614,7 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
xdg-utils \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
@@ -432,6 +643,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
# Tauri CLI 2.10.1 regresses Linux AppImage bundling in CI; keep Linux on the last known-good CLI.
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
@@ -443,7 +655,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
@@ -469,6 +681,15 @@ jobs:
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -490,6 +711,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -520,6 +743,7 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
xdg-utils \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
@@ -587,6 +811,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -623,3 +849,12 @@ jobs:
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||
path: packages/electron-app/release/*.rpm
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
122
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
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}`);
|
||||
80
.github/workflows/dev-release.yml
vendored
80
.github/workflows/dev-release.yml
vendored
@@ -1,18 +1,80 @@
|
||||
name: Dev CI
|
||||
name: Develop Pre-Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
schedule:
|
||||
# Nightly build of dev (only if dev has new commits)
|
||||
- cron: "0 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dev-prerelease
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dev-ci:
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
||||
steps:
|
||||
- name: Decide whether to run
|
||||
id: gate
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
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)
|
||||
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:
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
upload: false
|
||||
set_versions: false
|
||||
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
prerelease: true
|
||||
release_ui: false
|
||||
secrets: inherit
|
||||
|
||||
42
.github/workflows/manual-npm-publish.yml
vendored
42
.github/workflows/manual-npm-publish.yml
vendored
@@ -12,8 +12,17 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
package_name:
|
||||
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||
required: false
|
||||
default: "@neuralnomads/codenomad"
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
@@ -21,6 +30,13 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
package_name:
|
||||
required: false
|
||||
type: string
|
||||
default: "@neuralnomads/codenomad"
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,10 +46,12 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -51,7 +69,7 @@ jobs:
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
run: npm run build --workspace packages/server
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
@@ -62,13 +80,31 @@ jobs:
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Set server package name for publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
# Optional: when present, npm will use token auth.
|
||||
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||
shell: bash
|
||||
run: |
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
set -euo pipefail
|
||||
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
|
||||
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
|
||||
unset NODE_AUTH_TOKEN
|
||||
else
|
||||
echo "Using NPM_TOKEN authentication"
|
||||
fi
|
||||
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||
|
||||
58
.github/workflows/pr-build.yml
vendored
Normal file
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
|
||||
12
.github/workflows/release-ui.yml
vendored
12
.github/workflows/release-ui.yml
vendored
@@ -1,14 +1,20 @@
|
||||
name: Release UI
|
||||
|
||||
on:
|
||||
workflow_call: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
@@ -18,6 +24,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
dist_tag: latest
|
||||
npm_package_name: "@neuralnomads/codenomad"
|
||||
secrets: inherit
|
||||
|
||||
55
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
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
|
||||
37
.github/workflows/reusable-release.yml
vendored
37
.github/workflows/reusable-release.yml
vendored
@@ -3,6 +3,11 @@ name: Reusable Release
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version_suffix:
|
||||
description: "Suffix appended to package.json version"
|
||||
required: false
|
||||
@@ -13,13 +18,28 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
npm_package_name:
|
||||
description: "npm package name to publish (defaults to server package name)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Create GitHub prerelease"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_ui:
|
||||
description: "Publish remote UI + manifest"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
@@ -31,6 +51,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -53,17 +75,23 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
if [ "${IS_PRERELEASE}" = "true" ]; then
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
@@ -71,9 +99,12 @@ jobs:
|
||||
|
||||
release-ui:
|
||||
needs: prepare-release
|
||||
if: ${{ inputs.release_ui }}
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
@@ -82,6 +113,8 @@ jobs:
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
package_name: ${{ inputs.npm_package_name }}
|
||||
secrets: inherit
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -15,6 +15,35 @@
|
||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||
|
||||
## Multi-Language Support (i18n)
|
||||
|
||||
The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings.
|
||||
|
||||
- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code.
|
||||
- Implementation: `packages/ui/src/lib/i18n/index.tsx`
|
||||
- **Where messages live:** `packages/ui/src/lib/i18n/messages/<locale>/` as TypeScript objects (`"flat.dot.keys": "string"`).
|
||||
- Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time.
|
||||
- Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts`
|
||||
- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file.
|
||||
- Missing translations fall back to English (and finally to the key), so gaps can be easy to miss.
|
||||
- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`.
|
||||
- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code.
|
||||
- **Adding a new language:** add a new `messages/<locale>/` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`.
|
||||
- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`).
|
||||
- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply.
|
||||
|
||||
## File Length Guidelines (Highlight Only)
|
||||
|
||||
We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits.
|
||||
|
||||
- Source files: warn after ~500 lines; target limit ~800 lines
|
||||
- Test files: highlight after ~1000 lines
|
||||
|
||||
Behavior for agents:
|
||||
- Do not refactor solely to satisfy these thresholds.
|
||||
- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count.
|
||||
- When creating new files, aim to stay under the thresholds unless there's a clear reason.
|
||||
|
||||
## Tooling Preferences
|
||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||
- Use the `write` tool only when creating new files from scratch.
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Neural Nomads
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
221
README.md
221
README.md
@@ -1,117 +1,182 @@
|
||||
# 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>
|
||||
---
|
||||
|
||||

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

|
||||
_Rich media previews for images and assets._
|
||||
- **🚀 Multi-Instance Workspace**
|
||||
- **🌐 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
|
||||
|
||||
Choose the way that fits your workflow:
|
||||
### 🖥️ Desktop App
|
||||
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
Available as both Electron and Tauri builds — choose based on your preference.
|
||||
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
| Platform | Formats |
|
||||
|----------|---------|
|
||||
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||
|
||||
### 💻 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
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
For dev version
|
||||
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||
|
||||
### 🧪 Dev Releases
|
||||
|
||||
Bleeding-edge builds from the `dev` branch:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad@dev --launch
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
This command starts the server and opens the web client in your default browser.
|
||||
---
|
||||
|
||||
## Highlights
|
||||
## SideCars
|
||||
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
SideCars let you open local web tools inside CodeNomad as tabs.
|
||||
|
||||
<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
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
- **Node.js 18+** — for server mode or building from source
|
||||
|
||||
## Troubleshooting
|
||||
---
|
||||
|
||||
### macOS says the app is damaged
|
||||
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`:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
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:
|
||||
CodeNomad is a monorepo built with:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, 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
|
||||
To build the Desktop App from source:
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
```bash
|
||||
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||
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)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 835 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
6252
package-lock.json
generated
6252
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/server",
|
||||
@@ -21,7 +22,7 @@
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
"bumpVersion": "node ./scripts/bump-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -29,5 +30,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"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,6 +1,7 @@
|
||||
{
|
||||
"name": "@codenomad/ui-host-worker",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.9.2",
|
||||
"minServerVersion": "0.14.0",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
electron/resources/server/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
|
||||
|
||||
const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
@@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
function prepareMonacoPublicAssets() {
|
||||
return {
|
||||
name: "prepare-monaco-public-assets",
|
||||
configureServer(server: any) {
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: uiRendererRoot,
|
||||
warn: (msg: string) => server.config.logger.warn(msg),
|
||||
sourceRoots: [
|
||||
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||
],
|
||||
})
|
||||
},
|
||||
buildStart(this: any) {
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: uiRendererRoot,
|
||||
warn: (msg: string) => this.warn(msg),
|
||||
sourceRoots: [
|
||||
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||
],
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
@@ -40,7 +67,7 @@ export default defineConfig({
|
||||
},
|
||||
renderer: {
|
||||
root: uiRendererRoot,
|
||||
plugins: [solid()],
|
||||
plugins: [solid(), prepareMonacoPublicAssets()],
|
||||
css: {
|
||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BrowserWindow, dialog, ipcMain, 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"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
@@ -62,4 +66,95 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
|
||||
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 }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
|
||||
return { enabled: true }
|
||||
}
|
||||
try {
|
||||
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
|
||||
} catch {
|
||||
wakeLockId = null
|
||||
return { enabled: false }
|
||||
}
|
||||
return { enabled: true }
|
||||
}
|
||||
|
||||
if (wakeLockId !== null) {
|
||||
try {
|
||||
if (powerSaveBlocker.isStarted(wakeLockId)) {
|
||||
powerSaveBlocker.stop(wakeLockId)
|
||||
}
|
||||
} finally {
|
||||
wakeLockId = null
|
||||
}
|
||||
}
|
||||
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(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
if (!Notification.isSupported()) {
|
||||
return { ok: false, reason: "unsupported" }
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
|
||||
const body = typeof payload?.body === "string" ? payload.body : ""
|
||||
try {
|
||||
const notification = new Notification({ title, body })
|
||||
notification.show()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { configureMediaPermissionHandlers } from "./permissions"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
@@ -13,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
||||
|
||||
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()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
@@ -20,6 +46,8 @@ let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
@@ -90,10 +118,17 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
|
||||
return loader
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): 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]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
@@ -108,13 +143,13 @@ function getAllowedRendererOrigins(): string[] {
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
@@ -127,7 +162,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
@@ -135,13 +170,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
event.preventDefault()
|
||||
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
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
@@ -203,39 +279,41 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=local"],
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
const window = mainWindow
|
||||
|
||||
setupNavigationGuards(window)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
clearWindowAllowedOrigin(window)
|
||||
const loadingReady = loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
createApplicationMenu(window)
|
||||
setupCliIPC(window, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
window.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
return loadingReady
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
@@ -321,13 +399,69 @@ function finalizeCliSwap(url: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const window = mainWindow
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
setWindowAllowedOrigin(window, url)
|
||||
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
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
@@ -350,6 +484,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const sessionCookieName = cliManager.getAuthCookieName()
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
@@ -380,14 +515,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
name: sessionCookieName,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
@@ -399,7 +534,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
// In desktop dev workflows we always want the CLI to run in dev mode so it:
|
||||
// - uses plain HTTP
|
||||
// - proxies UI requests to the renderer dev server
|
||||
// Monaco's AMD assets are served from that dev server.
|
||||
const devMode = !app.isPackaged
|
||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||
await cliManager.start({ dev: devMode })
|
||||
} catch (error) {
|
||||
@@ -473,10 +612,20 @@ if (isMac) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
startCli()
|
||||
// Required for Windows notifications / taskbar grouping.
|
||||
// Keep in sync with desktop app identifier.
|
||||
try {
|
||||
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const loadingReady = createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
@@ -489,7 +638,21 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
void loadingReady.finally(() => {
|
||||
setTimeout(() => {
|
||||
void startCli()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
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", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
@@ -505,7 +668,6 @@ app.on("before-quit", async (event) => {
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
// CodeNomad supports a single window; closing it should quit the app on all platforms.
|
||||
app.quit()
|
||||
})
|
||||
|
||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
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,15 +1,20 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { parse as parseYaml } from "yaml"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
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 SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -33,12 +38,45 @@ interface StartOptions {
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runner: "node" | "tsx" | "standalone"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
type ManagedChild = ChildProcess | UtilityProcess
|
||||
type ChildLaunchMode = "spawn" | "utility"
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
function isJsonPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".json")
|
||||
}
|
||||
|
||||
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
|
||||
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
|
||||
const resolved = resolveConfigPath(target)
|
||||
|
||||
if (isYamlPath(resolved)) {
|
||||
const baseDir = path.dirname(resolved)
|
||||
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
|
||||
}
|
||||
|
||||
if (isJsonPath(resolved)) {
|
||||
const baseDir = path.dirname(resolved)
|
||||
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
|
||||
}
|
||||
|
||||
// Treat as directory.
|
||||
return {
|
||||
configYamlPath: path.join(resolved, "config.yaml"),
|
||||
legacyJsonPath: path.join(resolved, "config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(configPath?: string): string {
|
||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||
if (target.startsWith("~/")) {
|
||||
@@ -53,11 +91,20 @@ function resolveHostForMode(mode: ListeningMode): string {
|
||||
|
||||
function readListeningModeFromConfig(): ListeningMode {
|
||||
try {
|
||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
||||
if (!existsSync(configPath)) return "local"
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
||||
|
||||
let parsed: any = null
|
||||
if (existsSync(configYamlPath)) {
|
||||
const content = readFileSync(configYamlPath, "utf-8")
|
||||
parsed = parseYaml(content)
|
||||
} else if (existsSync(legacyJsonPath)) {
|
||||
const content = readFileSync(legacyJsonPath, "utf-8")
|
||||
parsed = JSON.parse(content)
|
||||
} else {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
@@ -77,11 +124,14 @@ export declare interface CliProcessManager {
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private child?: ManagedChild
|
||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
@@ -91,33 +141,72 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
let child: ManagedChild
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||
stdio: "pipe",
|
||||
serviceName: "CodeNomad CLI Supervisor",
|
||||
})
|
||||
this.childLaunchMode = "utility"
|
||||
} else {
|
||||
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(" ")}`)
|
||||
if (!child.pid) {
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
if (cliEntry.runner !== "standalone") {
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
}
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(
|
||||
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
|
||||
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : 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")
|
||||
}
|
||||
|
||||
@@ -132,23 +221,48 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
if (this.childLaunchMode === "utility") {
|
||||
const utilityChild = child as UtilityProcess
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
utilityChild.on("error", (error) => {
|
||||
const message = this.describeUtilityProcessError(error)
|
||||
console.error("[cli] utility supervisor failed:", error)
|
||||
this.updateStatus({ state: "error", error: message })
|
||||
this.emit("error", new Error(message))
|
||||
})
|
||||
|
||||
utilityChild.on("exit", (code) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
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) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -175,15 +289,98 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.childLaunchMode === "utility") {
|
||||
return this.stopUtilityChild(child as UtilityProcess)
|
||||
}
|
||||
|
||||
const spawnedChild = child as ChildProcess
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = spawnedChild.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
spawnedChild.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
@@ -191,7 +388,55 @@ export class CliProcessManager extends EventEmitter {
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
if (isAlreadyExited()) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
sendStopSignal("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,13 +444,34 @@ export class CliProcessManager extends EventEmitter {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getAuthCookieName(): string {
|
||||
return this.authCookieName
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
const pid = this.child.pid
|
||||
if (this.childLaunchMode === "utility") {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
} else if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
@@ -249,38 +515,27 @@ export class CliProcessManager extends EventEmitter {
|
||||
console.info(`[cli][${stream}] ${trimmed}`)
|
||||
this.emit("log", { stream, message: trimmed })
|
||||
|
||||
const port = this.extractPort(trimmed)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
this.updateStatus({ state: "ready", port, url })
|
||||
const localUrl = this.extractLocalUrl(trimmed)
|
||||
if (localUrl && this.status.state === "starting") {
|
||||
let port: number | undefined
|
||||
try {
|
||||
port = Number(new URL(localUrl).port) || undefined
|
||||
} catch {
|
||||
port = undefined
|
||||
}
|
||||
console.info(`[cli] ready on ${localUrl}`)
|
||||
this.updateStatus({ state: "ready", port, url: localUrl })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractPort(line: string): number | null {
|
||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||
if (readyMatch) {
|
||||
return parseInt(readyMatch[1], 10)
|
||||
private extractLocalUrl(line: string): string | null {
|
||||
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (line.toLowerCase().includes("http server listening")) {
|
||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||
if (httpMatch) {
|
||||
return parseInt(httpMatch[1], 10)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (typeof parsed.port === "number") {
|
||||
return parsed.port
|
||||
}
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return match[1] ?? null
|
||||
}
|
||||
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
@@ -289,16 +544,34 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
args.push("--https", "false", "--http", "true")
|
||||
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||
// by forcing an ephemeral port in dev.
|
||||
args.push("--http-port", "0")
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https", "true", "--http", "true")
|
||||
}
|
||||
|
||||
if (options.dev) {
|
||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||
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
|
||||
}
|
||||
|
||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||
if (cliEntry.runner === "standalone") {
|
||||
return this.buildExecutableCommand(cliEntry.entry, args)
|
||||
}
|
||||
|
||||
const parts = [JSON.stringify(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
@@ -308,7 +581,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
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[]) {
|
||||
if (cliEntry.runner === "standalone") {
|
||||
return { command: cliEntry.entry, args }
|
||||
}
|
||||
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
}
|
||||
@@ -325,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
|
||||
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
@@ -367,15 +647,54 @@ export class CliProcessManager extends EventEmitter {
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
private resolveStandaloneProdEntry(): string {
|
||||
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "server", "dist", executableName),
|
||||
path.join(mainDirname, "../resources/server/dist", executableName),
|
||||
path.resolve(process.cwd(), "..", "server", "dist", executableName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
|
||||
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||
}
|
||||
|
||||
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 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) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
@@ -12,6 +25,29 @@ const electronAPI = {
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
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)),
|
||||
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
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,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
@@ -14,8 +15,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||
"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",
|
||||
"prepare:resources": "node scripts/prepare-resources.js",
|
||||
"prebuild": "npm run prepare:resources",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
@@ -29,17 +35,22 @@
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"prepackage:mac": "npm run prepare:resources",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"prepackage:win": "npm run prepare:resources",
|
||||
"package:win": "electron-builder --win",
|
||||
"prepackage:linux": "npm run prepare:resources",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui"
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
@@ -51,7 +62,7 @@
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"appId": "ai.neuralnomads.codenomad.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
@@ -76,6 +87,12 @@
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"target": [
|
||||
{
|
||||
@@ -130,6 +147,13 @@
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import path, { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
function getPlatformEsbuildPackage() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
return platformPackages[platformKey] ?? null
|
||||
}
|
||||
|
||||
async function ensureEsbuildPlatformBinary() {
|
||||
const pkgName = getPlatformEsbuildPackage()
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = join(workspaceNodeModulesPath, ...pkgName.split("/"))
|
||||
if (existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = JSON.parse(readFileSync(join(workspaceNodeModulesPath, "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
console.log("📦 Step 0/3: Restoring esbuild platform binary...\n")
|
||||
await run(npmCmd, ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
}
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
@@ -105,12 +145,20 @@ async function build(platform) {
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
await ensureEsbuildPlatformBinary()
|
||||
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
|
||||
208
packages/electron-app/scripts/prepare-resources.js
Normal file
208
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/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")
|
||||
const standaloneMarker = join(serverRoot, "dist", process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server")
|
||||
|
||||
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 ensureStandaloneServerBuild() {
|
||||
log("building standalone server executable")
|
||||
const result = spawnSync(
|
||||
"npm",
|
||||
["run", "build:standalone", "--workspace", "@neuralnomads/codenomad"],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`standalone server build exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(standaloneMarker)) {
|
||||
throw new Error(`Standalone server executable missing after build: ${standaloneMarker}`)
|
||||
}
|
||||
}
|
||||
|
||||
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 ensureEsbuildPlatformBinary() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
const pkgName = platformPackages[platformKey]
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
||||
if (fs.existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = JSON.parse(fs.readFileSync(join(workspaceRoot, "node_modules", "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
log("installing esbuild platform binary (optional dep workaround)")
|
||||
|
||||
const result = spawnSync("npm", ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`esbuild platform 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()
|
||||
ensureStandaloneServerBuild()
|
||||
ensureServerDependencies()
|
||||
ensureEsbuildPlatformBinary()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[prepare-resources] failed:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -14,5 +14,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "@codenomad/opencode-config",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.36"
|
||||
"@opencode-ai/plugin": "1.14.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
let voiceModeEnabled = false
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).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: {
|
||||
...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 }) {
|
||||
const opencodeEvent = input?.event
|
||||
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
|
||||
}
|
||||
|
||||
type BackgroundProcessNotificationRequest = {
|
||||
sessionID: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
|
||||
args: {
|
||||
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"),
|
||||
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)
|
||||
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
|
||||
? {
|
||||
sessionID: context.sessionID,
|
||||
directory: context.directory,
|
||||
}
|
||||
: undefined
|
||||
const process = await request<BackgroundProcess>("", {
|
||||
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}`
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import http from "http"
|
||||
import https from "https"
|
||||
import { Readable } from "stream"
|
||||
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||
// Use a single request implementation that tolerates custom/self-signed certs
|
||||
// without disabling TLS verification for the whole Node process.
|
||||
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeFetch(
|
||||
url: string,
|
||||
init: RequestInit & { headers?: Record<string, string> },
|
||||
tls: { rejectUnauthorized: boolean },
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url)
|
||||
const isHttps = parsed.protocol === "https:"
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
|
||||
const method = (init.method ?? "GET").toUpperCase()
|
||||
const headers = init.headers ?? {}
|
||||
const body = init.body
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
const req = requestFn(
|
||||
{
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||
},
|
||||
(res) => {
|
||||
const responseHeaders = new Headers()
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
responseHeaders.set(key, value.join(", "))
|
||||
} else {
|
||||
responseHeaders.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Node stream -> Web ReadableStream for Response.
|
||||
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||
},
|
||||
)
|
||||
|
||||
const signal = init.signal
|
||||
const abort = () => {
|
||||
const err = new Error("Request aborted")
|
||||
;(err as any).name = "AbortError"
|
||||
req.destroy(err)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||
}
|
||||
|
||||
req.once("error", reject)
|
||||
|
||||
if (body === undefined || body === null) {
|
||||
req.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
req.end(body)
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array) {
|
||||
req.end(Buffer.from(body))
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
req.end(Buffer.from(new Uint8Array(body)))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for less common BodyInit types.
|
||||
req.end(String(body))
|
||||
})
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
|
||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
||||
public/
|
||||
|
||||
# Local developer config (may contain secrets)
|
||||
config-*.json
|
||||
|
||||
@@ -5,18 +5,21 @@
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
@@ -25,13 +28,26 @@
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
To list all CLI options:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
@@ -39,29 +55,119 @@ npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Install Locally (per-project)
|
||||
|
||||
If you prefer to install CodeNomad into a project and run the local binary:
|
||||
|
||||
```sh
|
||||
npm install @neuralnomads/codenomad
|
||||
npx codenomad --launch
|
||||
```
|
||||
|
||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||
|
||||
### Common Flags
|
||||
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
|
||||
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
|
||||
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
|
||||
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
|
||||
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
|
||||
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
|
||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--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 |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||
| `--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-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||
| `--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 |
|
||||
|
||||
### Dev Releases (Advanced)
|
||||
|
||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
These environment variables control how CodeNomad checks for dev updates:
|
||||
|
||||
| Env Variable | Description |
|
||||
|-------------|-------------|
|
||||
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
||||
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
||||
|
||||
### HTTP vs HTTPS
|
||||
|
||||
- Default: `--https=true --http=false` (HTTPS only).
|
||||
- To run plain HTTP only (useful for development):
|
||||
|
||||
```sh
|
||||
codenomad --https=false --http=true
|
||||
```
|
||||
|
||||
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
|
||||
|
||||
```sh
|
||||
codenomad --https=true --http=true
|
||||
```
|
||||
|
||||
### Remote Access Binding Rules
|
||||
|
||||
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
|
||||
- HTTP listens on `127.0.0.1` only.
|
||||
- HTTPS listens on `--host` (LAN/all interfaces).
|
||||
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
|
||||
- Both HTTP and HTTPS listen on `127.0.0.1`.
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
|
||||
|
||||
- `~/.config/codenomad/tls/ca-cert.pem`
|
||||
- `~/.config/codenomad/tls/server-cert.pem`
|
||||
|
||||
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
|
||||
|
||||
```sh
|
||||
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
|
||||
3. The app will open in a standalone window and appear in your OS app list.
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> 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
|
||||
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
@@ -17,26 +18,32 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||
"build:standalone": "node ./scripts/build-standalone.mjs",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/reply-from": "^12.6.2",
|
||||
"@fastify/static": "^9.1.1",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify": "^5.8.5",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"undici": "^8.1.0",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"bun": "^1.3.13",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
|
||||
99
packages/server/scripts/build-standalone.mjs
Normal file
99
packages/server/scripts/build-standalone.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const distDir = path.join(cliRoot, "dist")
|
||||
const publicDir = path.join(cliRoot, "public")
|
||||
const authPagesSourceDir = path.join(distDir, "server", "routes", "auth-pages")
|
||||
const authPagesTargetDir = path.join(distDir, "auth-pages")
|
||||
const explicitTarget = process.env.CODENOMAD_STANDALONE_TARGET?.trim()
|
||||
const outputName = (explicitTarget?.includes("windows") || process.platform === "win32") ? "codenomad-server.exe" : "codenomad-server"
|
||||
const outputPath = path.join(distDir, outputName)
|
||||
const packageJsonPath = path.join(cliRoot, "package.json")
|
||||
|
||||
function resolveBunCommand() {
|
||||
const executableName = process.platform === "win32" ? "bun.exe" : "bun"
|
||||
const localBinName = process.platform === "win32" ? "bun.cmd" : "bun"
|
||||
const candidates = [
|
||||
path.join(cliRoot, "node_modules", ".bin", localBinName),
|
||||
path.join(cliRoot, "..", "..", "node_modules", ".bin", localBinName),
|
||||
path.join(cliRoot, "node_modules", "bun", "bin", executableName),
|
||||
path.join(cliRoot, "..", "..", "node_modules", "bun", "bin", executableName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return "bun"
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[build-standalone] ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function ensureArtifacts() {
|
||||
const requiredPaths = [distDir, publicDir, authPagesSourceDir, packageJsonPath]
|
||||
const missing = requiredPaths.filter((filePath) => !fs.existsSync(filePath))
|
||||
if (missing.length > 0) {
|
||||
fail(`Missing required build artifacts: ${missing.join(", ")}. Run npm run build first.`)
|
||||
}
|
||||
|
||||
const bunResult = spawnSync(resolveBunCommand(), ["-v"], { cwd: cliRoot, encoding: "utf-8", shell: process.platform === "win32" })
|
||||
if (bunResult.status !== 0) {
|
||||
fail("Bun is required to build the standalone server executable. Install dependencies so the local Bun binary is available.")
|
||||
}
|
||||
}
|
||||
|
||||
function syncStandaloneAuthPages() {
|
||||
fs.rmSync(authPagesTargetDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(path.dirname(authPagesTargetDir), { recursive: true })
|
||||
fs.cpSync(authPagesSourceDir, authPagesTargetDir, { recursive: true })
|
||||
}
|
||||
|
||||
function buildStandaloneExecutable() {
|
||||
fs.rmSync(outputPath, { force: true })
|
||||
const bunCommand = resolveBunCommand()
|
||||
|
||||
const args = ["build", "--compile"]
|
||||
if (explicitTarget) {
|
||||
args.push(`--target=${explicitTarget}`)
|
||||
}
|
||||
args.push(path.join(cliRoot, "src", "index.ts"), "--outfile", outputPath)
|
||||
|
||||
const result = spawnSync(bunCommand, args, {
|
||||
cwd: cliRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`bun build --compile exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureArtifacts()
|
||||
syncStandaloneAuthPages()
|
||||
|
||||
buildStandaloneExecutable()
|
||||
console.log(`[build-standalone] built ${outputPath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error("[build-standalone] failed:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "child_process"
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
function stripNodeModuleBins(rootDir) {
|
||||
const root = path.join(rootDir, "node_modules")
|
||||
if (!existsSync(root)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
function stripOptionalNativeAddons(rootDir) {
|
||||
const nodeModulesRoot = path.join(rootDir, "node_modules")
|
||||
if (!existsSync(nodeModulesRoot)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const removablePaths = [
|
||||
path.join(nodeModulesRoot, "@msgpackr-extract"),
|
||||
path.join(nodeModulesRoot, "msgpackr-extract"),
|
||||
]
|
||||
|
||||
let removed = 0
|
||||
for (const targetPath of removablePaths) {
|
||||
if (!existsSync(targetPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
rmSync(targetPath, { recursive: true, force: true })
|
||||
removed += 1
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
const removedBins = stripNodeModuleBins(targetDir)
|
||||
if (removedBins > 0) {
|
||||
console.log(`[copy-opencode-config] Removed ${removedBins} node_modules/.bin directories`)
|
||||
}
|
||||
|
||||
const removedNativeAddons = stripOptionalNativeAddons(targetDir)
|
||||
if (removedNativeAddons > 0) {
|
||||
console.log(`[copy-opencode-config] Removed ${removedNativeAddons} optional native addon package paths`)
|
||||
}
|
||||
|
||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
@@ -50,6 +49,87 @@ export interface WorkspaceDeleteResponse {
|
||||
status: WorkspaceStatus
|
||||
}
|
||||
|
||||
export type WorktreeKind = "root" | "worktree"
|
||||
|
||||
export interface WorktreeDescriptor {
|
||||
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
|
||||
slug: string
|
||||
/** Absolute directory path on the server host. */
|
||||
directory: string
|
||||
kind: WorktreeKind
|
||||
/** Optional VCS branch name when available. */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeListResponse {
|
||||
worktrees: WorktreeDescriptor[]
|
||||
/** True when the workspace folder resolves to a Git repository. */
|
||||
isGitRepo?: boolean
|
||||
}
|
||||
|
||||
export interface WorktreeCreateRequest {
|
||||
slug: string
|
||||
/** Optional branch name (defaults to slug). */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreeMap {
|
||||
version: 1
|
||||
/** Default worktree to use for new sessions and as fallback. */
|
||||
defaultWorktreeSlug: string
|
||||
/** Mapping of *parent* session IDs to a worktree slug. */
|
||||
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 interface WorkspaceLogEntry {
|
||||
@@ -139,6 +219,24 @@ export interface InstanceStreamEvent {
|
||||
[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 {
|
||||
id: string
|
||||
path: string
|
||||
@@ -151,9 +249,9 @@ export interface BinaryRecord {
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type AppConfig = ConfigFile
|
||||
export type AppConfigResponse = AppConfig
|
||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||
export type SettingsOwner = string
|
||||
export type SettingsBucket = Record<string, unknown>
|
||||
export type SettingsDoc = Record<string, unknown>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
@@ -176,14 +274,89 @@ export interface BinaryValidationResult {
|
||||
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 =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "sidecar.updated"
|
||||
| "sidecar.removed"
|
||||
| "storage.configChanged"
|
||||
| "storage.stateChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
@@ -194,8 +367,10 @@ export type WorkspaceEventPayload =
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||
| { 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.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
@@ -204,7 +379,8 @@ export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
url: string
|
||||
/** Remote URL using the server's remote protocol/port for this IP. */
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
@@ -230,16 +406,20 @@ export interface SupportMeta {
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
|
||||
localUrl: string
|
||||
/** URL remote clients should use (prefers HTTPS when enabled). */
|
||||
remoteUrl?: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual port in use after binding. */
|
||||
port: number
|
||||
/** Actual local port in use after binding. */
|
||||
localPort: number
|
||||
/** Actual remote port in use after binding (when remoteUrl is set). */
|
||||
remotePort?: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
@@ -249,10 +429,14 @@ export interface ServerMeta {
|
||||
serverVersion?: string
|
||||
ui?: UiMeta
|
||||
support?: SupportMeta
|
||||
/** Optional update info (dev channel only). */
|
||||
update?: LatestReleaseInfo | null
|
||||
}
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
|
||||
|
||||
export interface BackgroundProcess {
|
||||
id: string
|
||||
workspaceId: string
|
||||
@@ -265,6 +449,8 @@ export interface BackgroundProcess {
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
terminalReason?: BackgroundProcessTerminalReason
|
||||
notifyEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
|
||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly cookieName: string
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
@@ -102,13 +104,18 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
// When auth is disabled, treat all requests as authenticated.
|
||||
// We still return a stable username so callers can display it.
|
||||
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 session = this.sessionManager.getSession(sessionId)
|
||||
if (!session) return null
|
||||
@@ -119,10 +126,18 @@ export class AuthManager {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||
}
|
||||
|
||||
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||
}
|
||||
|
||||
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
|
||||
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
|
||||
}
|
||||
|
||||
private requireAuthStore(): AuthStore {
|
||||
if (!this.authStore) {
|
||||
throw new Error("Auth store is unavailable")
|
||||
@@ -131,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) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
@@ -143,8 +168,11 @@ function resolvePath(filePath: string) {
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
|
||||
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||
if (options?.secure) {
|
||||
parts.push("Secure")
|
||||
}
|
||||
if (options?.maxAgeSeconds !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
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 INDEX_FILE = "index.json"
|
||||
@@ -27,6 +27,31 @@ interface RunningProcess {
|
||||
outputPath: string
|
||||
exitPromise: Promise<void>
|
||||
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 {
|
||||
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const enriched = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
...record,
|
||||
...this.toPublicProcess(record),
|
||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||
})),
|
||||
)
|
||||
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)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
|
||||
this.killProcessTree(child, "SIGTERM")
|
||||
})
|
||||
|
||||
const record: BackgroundProcess = {
|
||||
|
||||
const record: PersistedBackgroundProcess = {
|
||||
id,
|
||||
workspaceId,
|
||||
title,
|
||||
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
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) => {
|
||||
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
|
||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||
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.stoppedAt = new Date().toISOString()
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
await this.finalizeRecord(workspaceId, record, completion)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||
runningState.exitPromise = exitPromise
|
||||
|
||||
this.running.set(id, runningState)
|
||||
|
||||
let lastPublishAt = 0
|
||||
const maybePublishSize = () => {
|
||||
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
return record
|
||||
return this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.completion = { reason: "user_stopped", endContext: "normal" }
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
const updated = await this.findProcess(workspaceId, processId)
|
||||
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
if (record.status === "running") {
|
||||
record.status = "stopped"
|
||||
record.terminalReason = "user_stopped"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
|
||||
}
|
||||
|
||||
return record
|
||||
return this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
return
|
||||
}
|
||||
|
||||
await this.removeFromIndex(workspaceId, processId)
|
||||
await this.removeProcessDir(workspaceId, processId)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId } },
|
||||
record.status = "stopped"
|
||||
record.terminalReason = "user_terminated"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.finalizeRecord(workspaceId, record, {
|
||||
reason: "user_terminated",
|
||||
endContext: "normal",
|
||||
removeAfterFinalize: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
|
||||
private async cleanupWorkspace(workspaceId: string) {
|
||||
for (const [, running] of this.running.entries()) {
|
||||
if (running.workspaceId !== workspaceId) continue
|
||||
running.completion = {
|
||||
reason: "user_terminated",
|
||||
endContext: "workspace_cleanup",
|
||||
removeAfterFinalize: true,
|
||||
}
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
|
||||
return args
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
return "error"
|
||||
private completionFromExit(code: number | null): ProcessCompletion {
|
||||
if (code === 0) {
|
||||
return { reason: "finished", endContext: "normal" }
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
|
||||
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)
|
||||
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)
|
||||
if (!existsSync(indexPath)) return []
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
||||
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
||||
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const index = records.findIndex((entry) => entry.id === record.id)
|
||||
if (index >= 0) {
|
||||
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
|
||||
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)
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||
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({
|
||||
type: "instance.event",
|
||||
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 {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||
const random = randomBytes(3).toString("hex")
|
||||
|
||||
128
packages/server/src/clients/connection-manager.ts
Normal file
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
|
||||
}
|
||||
}
|
||||
78
packages/server/src/config/location.ts
Normal file
78
packages/server/src/config/location.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
export interface ConfigLocation {
|
||||
/** Resolved absolute base directory containing all persisted server data. */
|
||||
baseDir: string
|
||||
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
|
||||
configYamlPath: string
|
||||
/** Canonical YAML state file path (always in baseDir). */
|
||||
stateYamlPath: string
|
||||
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
|
||||
legacyJsonPath: string
|
||||
/** Directory for per-instance persisted data (chat history etc.). */
|
||||
instancesDir: string
|
||||
}
|
||||
|
||||
function resolvePath(inputPath: string): string {
|
||||
if (inputPath.startsWith("~/")) {
|
||||
return path.join(os.homedir(), inputPath.slice(2))
|
||||
}
|
||||
return path.resolve(inputPath)
|
||||
}
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase()
|
||||
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||
}
|
||||
|
||||
function isJsonPath(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith(".json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
|
||||
*
|
||||
* Supported inputs:
|
||||
* - Directory: "~/.config/codenomad"
|
||||
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
|
||||
* - Legacy JSON file: "~/.config/codenomad/config.json"
|
||||
*/
|
||||
export function resolveConfigLocation(raw: string): ConfigLocation {
|
||||
const trimmed = (raw ?? "").trim()
|
||||
const fallback = "~/.config/codenomad/config.json"
|
||||
const input = trimmed.length > 0 ? trimmed : fallback
|
||||
|
||||
const resolvedInput = resolvePath(input)
|
||||
|
||||
if (isYamlPath(resolvedInput)) {
|
||||
const baseDir = path.dirname(resolvedInput)
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: resolvedInput,
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
|
||||
if (isJsonPath(resolvedInput)) {
|
||||
const baseDir = path.dirname(resolvedInput)
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: resolvedInput,
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
|
||||
const baseDir = resolvedInput
|
||||
return {
|
||||
baseDir,
|
||||
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||
instancesDir: path.join(baseDir, "instances"),
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ const ModelPreferenceSchema = z.object({
|
||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
const PreferencesSchema = z
|
||||
.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
promptSubmitOnEnter: z.boolean().default(false),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
@@ -24,7 +26,16 @@ const PreferencesSchema = z.object({
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
})
|
||||
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||
notifyOnNeedsInput: z.boolean().default(true),
|
||||
notifyOnIdle: z.boolean().default(true),
|
||||
})
|
||||
// Preserve unknown preference keys so newer configs survive older binaries.
|
||||
.passthrough()
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
@@ -38,14 +49,35 @@ const OpenCodeBinarySchema = z.object({
|
||||
label: z.string().optional(),
|
||||
})
|
||||
|
||||
const ConfigFileSchema = z.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
const ConfigFileSchema = z
|
||||
.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
// Preserve unknown top-level keys so optional future features survive downgrades.
|
||||
.passthrough()
|
||||
|
||||
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
|
||||
const ConfigYamlSchema = z
|
||||
.object({
|
||||
preferences: PreferencesSchema.default({}),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
|
||||
const StateFileSchema = z
|
||||
.object({
|
||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
|
||||
const DEFAULT_STATE = StateFileSchema.parse({})
|
||||
|
||||
export {
|
||||
ModelPreferenceSchema,
|
||||
@@ -55,7 +87,11 @@ export {
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
ConfigYamlSchema,
|
||||
StateFileSchema,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CONFIG_YAML,
|
||||
DEFAULT_STATE,
|
||||
}
|
||||
|
||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||
@@ -65,3 +101,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
|
||||
export type StateFile = z.infer<typeof StateFileSchema>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
private loaded = false
|
||||
|
||||
constructor(
|
||||
private readonly configPath: string,
|
||||
private readonly eventBus: EventBus | undefined,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
load(): ConfigFile {
|
||||
if (this.loaded) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = this.resolvePath(this.configPath)
|
||||
if (fs.existsSync(resolved)) {
|
||||
const content = fs.readFileSync(resolved, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
this.cache = ConfigFileSchema.parse(parsed)
|
||||
this.logger.debug({ resolved }, "Loaded existing config file")
|
||||
} else {
|
||||
this.cache = DEFAULT_CONFIG
|
||||
this.logger.debug({ resolved }, "No config file found, using defaults")
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
||||
this.cache = DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
this.loaded = true
|
||||
return this.cache
|
||||
}
|
||||
|
||||
get(): ConfigFile {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
replace(config: ConfigFile) {
|
||||
const validated = ConfigFileSchema.parse(config)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
private commit(next: ConfigFile) {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
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 resolved = this.resolvePath(this.configPath)
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
||||
this.logger.debug({ resolved }, "Persisted config file")
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist config")
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string) {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||
}
|
||||
return path.resolve(filePath)
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ export class EventBus extends EventEmitter {
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("sidecar.updated", handler)
|
||||
this.on("sidecar.removed", handler)
|
||||
this.on("storage.configChanged", handler)
|
||||
this.on("storage.stateChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
@@ -35,8 +37,10 @@ export class EventBus extends EventEmitter {
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("sidecar.updated", handler)
|
||||
this.off("sidecar.removed", handler)
|
||||
this.off("storage.configChanged", handler)
|
||||
this.off("storage.stateChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
|
||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
||||
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 {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
@@ -222,20 +230,18 @@ export class FileSystemBrowser {
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
|
||||
// are treated as directories in directory-only listings.
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
const isDirectory = stats.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
import { BinaryRegistry } from "./config/binaries"
|
||||
import { resolveConfigLocation } from "./config/location"
|
||||
import { SettingsService } from "./settings/service"
|
||||
import { BinaryResolver } from "./settings/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
@@ -18,18 +19,35 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
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 { RemoteProxySessionManager } from "./server/remote-proxy"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
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"
|
||||
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const packageJson = { version: readServerPackageVersion(import.meta.url) }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
|
||||
|
||||
interface CliOptions {
|
||||
port: number
|
||||
host: string
|
||||
https: boolean
|
||||
http: boolean
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
@@ -43,13 +61,15 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
const DEFAULT_HOST = "127.0.0.1"
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
const DEFAULT_HTTPS_PORT = 9898
|
||||
const DEFAULT_HTTP_PORT = 9899
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
@@ -57,9 +77,16 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
|
||||
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
|
||||
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
|
||||
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
|
||||
.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("--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("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
@@ -80,6 +107,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.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(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -97,7 +129,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
port: number
|
||||
https?: string
|
||||
http?: string
|
||||
httpsPort: number
|
||||
httpPort: number
|
||||
tlsKey?: string
|
||||
tlsCert?: string
|
||||
tlsCa?: string
|
||||
tlsSANs?: string
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
@@ -112,6 +151,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -128,9 +168,23 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||
|
||||
const httpsEnabled = parseBooleanEnv(parsed.https)
|
||||
const httpEnabled = parseBooleanEnv(parsed.http)
|
||||
|
||||
if (!httpsEnabled && !httpEnabled) {
|
||||
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
|
||||
}
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: normalizedHost,
|
||||
https: httpsEnabled,
|
||||
http: httpEnabled,
|
||||
httpsPort: parsed.httpsPort,
|
||||
httpPort: parsed.httpPort,
|
||||
tlsKeyPath: parsed.tlsKey,
|
||||
tlsCertPath: parsed.tlsCert,
|
||||
tlsCaPath: parsed.tlsCa,
|
||||
tlsSANs: parsed.tlsSANs,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
@@ -144,6 +198,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -200,12 +255,21 @@ async function main() {
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const configLocation = resolveConfigLocation(options.configPath)
|
||||
const configDir = configLocation.baseDir
|
||||
|
||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||
}
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
localUrl: "http://localhost:0",
|
||||
remoteUrl: undefined,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||
port: options.port,
|
||||
localPort: 0,
|
||||
remotePort: undefined,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
@@ -213,9 +277,10 @@ async function main() {
|
||||
|
||||
const authManager = new AuthManager(
|
||||
{
|
||||
configPath: options.configPath,
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -229,18 +294,38 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const tlsResolution = resolveHttpsOptions({
|
||||
enabled: options.https,
|
||||
configDir,
|
||||
host: options.host,
|
||||
tlsKeyPath: options.tlsKeyPath,
|
||||
tlsCertPath: options.tlsCertPath,
|
||||
tlsCaPath: options.tlsCaPath,
|
||||
tlsSANs: options.tlsSANs,
|
||||
logger: logger.child({ component: "tls" }),
|
||||
})
|
||||
|
||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||
|
||||
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||
const binaryResolver = new BinaryResolver(settings)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
settings,
|
||||
binaryResolver,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
nodeExtraCaCertsPath,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
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({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -277,28 +362,175 @@ async function main() {
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
|
||||
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
|
||||
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
|
||||
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
|
||||
const devReleaseMonitor = enableDevUpdateChecks
|
||||
? startDevReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
repo: githubRepo,
|
||||
logger: logger.child({ component: "updates" }),
|
||||
onUpdate: (release) => {
|
||||
serverMeta.update = release
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
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,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
logger: logger.child({ component: "remote-proxy" }),
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
})
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
const startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
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 httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
|
||||
const httpBindPort = httpPortExplicit ? options.httpPort : 0
|
||||
|
||||
// Listener binding rules:
|
||||
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
|
||||
// - Remote access disabled: both listen on loopback.
|
||||
// - HTTP-only mode: respect --host (used for dev/testing).
|
||||
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
|
||||
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
|
||||
|
||||
const servers: Array<ReturnType<typeof createHttpServer>> = []
|
||||
|
||||
const httpServer = options.http
|
||||
? createHttpServer({
|
||||
bindHost: httpBindHost,
|
||||
bindPort: httpBindPort,
|
||||
defaultPort: options.httpPort,
|
||||
protocol: "http",
|
||||
workspaceManager,
|
||||
settings,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
const httpsServer = options.https
|
||||
? createHttpServer({
|
||||
bindHost: httpsBindHost,
|
||||
bindPort: httpsBindPort,
|
||||
defaultPort: options.httpsPort,
|
||||
protocol: "https",
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
workspaceManager,
|
||||
settings,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
})
|
||||
: null
|
||||
|
||||
if (httpServer) servers.push(httpServer)
|
||||
if (httpsServer) servers.push(httpsServer)
|
||||
|
||||
const [httpStart, httpsStart] = await Promise.all([
|
||||
httpServer ? httpServer.start() : Promise.resolve(null),
|
||||
httpsServer ? httpsServer.start() : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const localStart = httpStart ?? httpsStart
|
||||
if (!localStart) {
|
||||
throw new Error("No listeners started")
|
||||
}
|
||||
|
||||
const remoteStart = httpsStart ?? httpStart
|
||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||
|
||||
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
serverMeta.localPort = localStart.port
|
||||
serverMeta.remoteUrl = remoteUrl
|
||||
serverMeta.remotePort = remoteStart?.port
|
||||
serverMeta.host = options.host
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (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) {
|
||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
@@ -318,6 +550,18 @@ async function main() {
|
||||
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 {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
@@ -328,8 +572,8 @@ async function main() {
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
await Promise.allSettled(servers.map((srv) => srv.stop()))
|
||||
logger.info("HTTP server(s) stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
@@ -339,6 +583,8 @@ async function main() {
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
devReleaseMonitor?.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createLogger } from "./logger"
|
||||
import { resolveOpencodeTemplateDir } from "./runtime-paths"
|
||||
|
||||
const log = createLogger({ component: "opencode-config" })
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const prodTemplateDirs = [
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
||||
path.resolve(__dirname, "opencode-config"),
|
||||
].filter((dir): dir is string => Boolean(dir))
|
||||
const templateDir = resolveOpencodeTemplateDir(import.meta.url)
|
||||
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
||||
const templateDir = isDevBuild
|
||||
? devTemplateDir
|
||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
|
||||
100
packages/server/src/plugins/voice-mode.ts
Normal file
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}`
|
||||
}
|
||||
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { fetch } from "undici"
|
||||
import type { LatestReleaseInfo } from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
|
||||
|
||||
interface DevReleaseMonitorOptions {
|
||||
/** Current running server version (from package.json). */
|
||||
currentVersion: string
|
||||
/** GitHub repo in the form "owner/name". */
|
||||
repo: string
|
||||
logger: Logger
|
||||
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
interface GithubReleaseListItem {
|
||||
tag_name?: string
|
||||
name?: string
|
||||
html_url?: string
|
||||
body?: string
|
||||
published_at?: string
|
||||
created_at?: string
|
||||
prerelease?: boolean
|
||||
draft?: boolean
|
||||
}
|
||||
|
||||
export interface DevReleaseMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
|
||||
|
||||
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const pollIntervalMs =
|
||||
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
|
||||
? (options.pollIntervalMs as number)
|
||||
: DEFAULT_POLL_INTERVAL_MS
|
||||
|
||||
const refresh = async () => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const release = await fetchLatestPrerelease({
|
||||
repo: options.repo,
|
||||
currentVersion: options.currentVersion,
|
||||
})
|
||||
options.onUpdate(release)
|
||||
} catch (error) {
|
||||
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
timer = setInterval(() => void refresh(), pollIntervalMs)
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestPrerelease(args: {
|
||||
repo: string
|
||||
currentVersion: string
|
||||
}): Promise<LatestReleaseInfo | null> {
|
||||
const normalizedRepo = args.repo.trim()
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
|
||||
throw new Error(`Invalid GitHub repo: ${args.repo}`)
|
||||
}
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub releases API responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const list = (await response.json()) as GithubReleaseListItem[]
|
||||
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
|
||||
if (!latest) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tag = latest.tag_name || latest.name
|
||||
if (!tag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedVersion = stripTagPrefix(tag)
|
||||
if (!normalizedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
tag,
|
||||
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
|
||||
channel: "dev",
|
||||
publishedAt: latest.published_at ?? latest.created_at,
|
||||
notes: latest.body,
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
|
||||
}
|
||||
}
|
||||
|
||||
export function compareVersionStrings(a: string, b: string): number {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
return compareVersions(left, right)
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||
const response = await fetch(RELEASES_API_URL, {
|
||||
headers: {
|
||||
@@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
|
||||
}
|
||||
}
|
||||
|
||||
function stripTagPrefix(tag: string | undefined): string | null {
|
||||
export function stripTagPrefix(tag: string | undefined): string | null {
|
||||
if (!tag) return null
|
||||
const trimmed = tag.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
|
||||
|
||||
function parseVersion(value: string): NormalizedVersion {
|
||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||
const [core, prerelease = null] = normalized.split("-", 2)
|
||||
const dashIndex = normalized.indexOf("-")
|
||||
const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized
|
||||
const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null
|
||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
|
||||
79
packages/server/src/runtime-paths.ts
Normal file
79
packages/server/src/runtime-paths.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
function safeModuleDir(importMetaUrl: string): string | null {
|
||||
try {
|
||||
return path.dirname(fileURLToPath(importMetaUrl))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function firstExistingPath(candidates: Array<string | null | undefined>, predicate: (value: string) => boolean): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
if (predicate(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getPackagedDistDir(): string {
|
||||
return path.dirname(process.execPath)
|
||||
}
|
||||
|
||||
export function resolveServerPackageRoot(importMetaUrl: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const configuredRoot = process.env.CODENOMAD_SERVER_ROOT?.trim()
|
||||
const candidates = [
|
||||
configuredRoot ? path.resolve(configuredRoot) : null,
|
||||
moduleDir ? path.resolve(moduleDir, "..") : null,
|
||||
path.resolve(getPackagedDistDir(), ".."),
|
||||
]
|
||||
|
||||
return (
|
||||
firstExistingPath(candidates, (value) => fs.existsSync(path.join(value, "package.json"))) ??
|
||||
candidates.find((value): value is string => Boolean(value)) ??
|
||||
process.cwd()
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveServerPublicDir(importMetaUrl: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const candidates = [moduleDir ? path.resolve(moduleDir, "../public") : null, path.join(resolveServerPackageRoot(importMetaUrl), "public")]
|
||||
|
||||
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
|
||||
}
|
||||
|
||||
export function resolveAuthTemplatePath(importMetaUrl: string, fileName: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const distDir = getPackagedDistDir()
|
||||
const candidates = [
|
||||
moduleDir ? path.join(moduleDir, "auth-pages", fileName) : null,
|
||||
path.join(distDir, "auth-pages", fileName),
|
||||
path.join(distDir, "server", "routes", "auth-pages", fileName),
|
||||
]
|
||||
|
||||
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[0]!
|
||||
}
|
||||
|
||||
export function resolveOpencodeTemplateDir(importMetaUrl: string): string {
|
||||
const moduleDir = safeModuleDir(importMetaUrl)
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const candidates = [
|
||||
moduleDir ? path.resolve(moduleDir, "../../opencode-config") : null,
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : null,
|
||||
moduleDir ? path.resolve(moduleDir, "opencode-config") : null,
|
||||
path.join(getPackagedDistDir(), "opencode-config"),
|
||||
]
|
||||
|
||||
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
|
||||
}
|
||||
|
||||
export function readServerPackageVersion(importMetaUrl: string): string {
|
||||
const packageJsonPath = path.join(resolveServerPackageRoot(importMetaUrl), "package.json")
|
||||
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }
|
||||
return typeof parsed.version === "string" && parsed.version.trim().length > 0 ? parsed.version : "0.0.0"
|
||||
}
|
||||
@@ -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
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
128
packages/server/src/server/network-addresses.ts
Normal file
128
packages/server/src/server/network-addresses.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): NetworkAddress[] {
|
||||
const { host, protocol, port } = args
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
|
||||
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 {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
566
packages/server/src/server/remote-proxy.ts
Normal file
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})`)
|
||||
return
|
||||
}
|
||||
window.location.href = "/"
|
||||
// Replace history entry so Back doesn't return to /login.
|
||||
window.location.replace("/")
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs"
|
||||
import { z } from "zod"
|
||||
import type { AuthManager } from "../../auth/manager"
|
||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||
import { resolveAuthTemplatePath } from "../../runtime-paths"
|
||||
|
||||
interface RouteDeps {
|
||||
authManager: AuthManager
|
||||
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
|
||||
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
|
||||
|
||||
let cachedLoginTemplate: string | null = null
|
||||
let cachedTokenTemplate: string | null = null
|
||||
|
||||
function readTemplate(url: URL, cache: string | null): string {
|
||||
function readTemplate(filePath: string, cache: string | null): string {
|
||||
if (cache) return cache
|
||||
const content = fs.readFileSync(url, "utf-8")
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function getLoginHtml(defaultUsername: string): string {
|
||||
if (!cachedLoginTemplate) {
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
|
||||
}
|
||||
|
||||
const escapedUsername = escapeHtml(defaultUsername)
|
||||
@@ -44,14 +45,26 @@ function getLoginHtml(defaultUsername: string): string {
|
||||
|
||||
function getTokenHtml(): string {
|
||||
if (!cachedTokenTemplate) {
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
|
||||
}
|
||||
|
||||
return cachedTokenTemplate
|
||||
}
|
||||
|
||||
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()
|
||||
reply.type("text/html").send(getLoginHtml(status.username))
|
||||
})
|
||||
@@ -67,6 +80,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
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())
|
||||
})
|
||||
|
||||
@@ -88,7 +106,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const session = deps.authManager.createSession(body.username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -112,12 +130,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const username = deps.authManager.getStatus().username
|
||||
const session = deps.authManager.createSession(username)
|
||||
deps.authManager.setSessionCookie(reply, session.id)
|
||||
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/api/auth/logout", async (_request, reply) => {
|
||||
deps.authManager.clearSessionCookie(reply)
|
||||
app.post("/api/auth/logout", async (request, reply) => {
|
||||
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -139,6 +157,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
function isSecureRequest(request: any) {
|
||||
if (request.protocol === "https") {
|
||||
return true
|
||||
}
|
||||
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"]/g, (char) => {
|
||||
switch (char) {
|
||||
|
||||
@@ -9,6 +9,21 @@ interface RouteDeps {
|
||||
const StartSchema = z.object({
|
||||
title: 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({
|
||||
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
|
||||
|
||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||
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)
|
||||
return process
|
||||
})
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
import { ConfigFileSchema } from "../../config/schema"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
}
|
||||
|
||||
const BinaryCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryUpdateSchema = z.object({
|
||||
label: z.string().optional(),
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const BinaryValidateSchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/config/app", async () => deps.configStore.get())
|
||||
|
||||
app.put("/api/config/app", async (request) => {
|
||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||
deps.configStore.replace(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
app.get("/api/config/binaries", async () => {
|
||||
return { binaries: deps.binaryRegistry.list() }
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries", async (request, reply) => {
|
||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.create(body)
|
||||
reply.code(201)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||
return { binary }
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||
deps.binaryRegistry.remove(request.params.id)
|
||||
reply.code(204)
|
||||
})
|
||||
|
||||
app.post("/api/config/binaries/validate", async (request) => {
|
||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||
return deps.binaryRegistry.validatePath(body.path)
|
||||
})
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
connectionManager: ClientConnectionManager
|
||||
}
|
||||
|
||||
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) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
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)
|
||||
|
||||
let closed = false
|
||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
const unregisterConnection = deps.connectionManager.register({
|
||||
...connection,
|
||||
close,
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
unregisterConnection()
|
||||
}
|
||||
|
||||
request.raw.on("close", 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 os from "os"
|
||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -11,23 +11,23 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const port = resolvePort(meta)
|
||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||
return meta.port
|
||||
function resolveLocalPort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
|
||||
return meta.localPort
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.httpBaseUrl)
|
||||
const parsed = new URL(meta.localUrl)
|
||||
const port = Number(parsed.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : 0
|
||||
} catch {
|
||||
@@ -35,74 +35,22 @@ function resolvePort(meta: ServerMeta): number {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
|
||||
if (!meta.remoteUrl) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.remoteUrl)
|
||||
const protocol = parsed.protocol === "https:" ? "https" : "http"
|
||||
const port = Number(parsed.port)
|
||||
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
// NetworkAddress shape is resolved in ../network-addresses
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { VoiceModeStateResponse } from "../../api-types"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { EventBus } from "../../events/bus"
|
||||
import type { Logger } from "../../logger"
|
||||
import { PluginChannelManager } from "../../plugins/channel"
|
||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
channel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const VoiceModeStateSchema = z.object({
|
||||
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) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.raw.flushHeaders?.()
|
||||
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(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
deps.channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
@@ -49,6 +58,28 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
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 workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
|
||||
54
packages/server/src/server/routes/remote-proxy.ts
Normal file
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
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
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
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
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 { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
|
||||
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
|
||||
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
|
||||
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
@@ -19,6 +23,24 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
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({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -100,10 +122,152 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
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) {
|
||||
if (isGitMutationError(error)) {
|
||||
reply.code(error.statusCode)
|
||||
return { error: error.message }
|
||||
}
|
||||
if (error instanceof Error && error.message === "Workspace not found") {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
|
||||
195
packages/server/src/server/routes/worktrees.ts
Normal file
195
packages/server/src/server/routes/worktrees.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
import {
|
||||
resolveRepoRoot,
|
||||
listWorktrees,
|
||||
isValidWorktreeSlug,
|
||||
createManagedWorktree,
|
||||
removeWorktree,
|
||||
} from "../../workspaces/git-worktrees"
|
||||
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
|
||||
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const WorktreeMapSchema = z.object({
|
||||
version: z.literal(1),
|
||||
defaultWorktreeSlug: z.string().min(1).default("root"),
|
||||
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
|
||||
})
|
||||
|
||||
const WorktreeCreateSchema = z.object({
|
||||
slug: z.string().trim().min(1),
|
||||
branch: z.string().trim().min(1).optional(),
|
||||
})
|
||||
|
||||
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||
const response: WorktreeListResponse = { worktrees, isGitRepo }
|
||||
return response
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = WorktreeCreateSchema.parse(request.body ?? {})
|
||||
const slug = body.slug
|
||||
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug" }
|
||||
}
|
||||
if (body.branch) {
|
||||
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree branch" }
|
||||
}
|
||||
if (body.branch !== slug) {
|
||||
reply.code(400)
|
||||
return { error: "Branch must match slug" }
|
||||
}
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
return { error: "Workspace is not a Git repository" }
|
||||
}
|
||||
|
||||
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
|
||||
|
||||
const created = await createManagedWorktree({
|
||||
repoRoot,
|
||||
workspaceFolder: workspace.path,
|
||||
slug,
|
||||
logger: request.log,
|
||||
})
|
||||
|
||||
reply.code(201)
|
||||
return created
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
|
||||
"/api/workspaces/:id/worktrees/:slug",
|
||||
async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
const slug = (request.params.slug ?? "").trim()
|
||||
if (!isValidWorktreeSlug(slug) || slug === "root") {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug" }
|
||||
}
|
||||
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
|
||||
if (!isGitRepo) {
|
||||
reply.code(400)
|
||||
return { error: "Workspace is not a Git repository" }
|
||||
}
|
||||
|
||||
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
|
||||
|
||||
try {
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
|
||||
const match = worktrees.find((wt) => wt.slug === slug)
|
||||
if (!match || match.kind === "root") {
|
||||
reply.code(404)
|
||||
return { error: "Worktree not found" }
|
||||
}
|
||||
|
||||
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
|
||||
|
||||
// Best-effort: prune any mappings that point at the deleted worktree.
|
||||
const current = await readWorktreeMap(workspace.path, request.log)
|
||||
let changed = false
|
||||
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
|
||||
if (mapped === slug) {
|
||||
delete nextMapping[sessionId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
|
||||
if (nextDefault !== current.defaultWorktreeSlug) {
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await writeWorktreeMap(
|
||||
workspace.path,
|
||||
{
|
||||
version: 1,
|
||||
defaultWorktreeSlug: nextDefault,
|
||||
parentSessionWorktreeSlug: nextMapping,
|
||||
},
|
||||
request.log,
|
||||
)
|
||||
}
|
||||
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
return await readWorktreeMap(workspace.path, request.log)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404)
|
||||
return { error: "Workspace not found" }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
|
||||
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
|
||||
reply.code(400)
|
||||
return { error: "Invalid defaultWorktreeSlug" }
|
||||
}
|
||||
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
|
||||
if (!isValidWorktreeSlug(slug)) {
|
||||
reply.code(400)
|
||||
return { error: "Invalid worktree slug in mapping" }
|
||||
}
|
||||
}
|
||||
await writeWorktreeMap(workspace.path, parsed, request.log)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleError(error: unknown, reply: FastifyReply) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
|
||||
}
|
||||
283
packages/server/src/server/tls.ts
Normal file
283
packages/server/src/server/tls.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import crypto from "crypto"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { createRequire } from "module"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
type Forge = typeof import("node-forge")
|
||||
|
||||
function loadForge(): Forge {
|
||||
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
|
||||
return require("node-forge") as Forge
|
||||
}
|
||||
|
||||
export interface ResolvedHttpsOptions {
|
||||
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
|
||||
caCertPath?: string
|
||||
mode: "provided" | "generated"
|
||||
}
|
||||
|
||||
export interface ResolveHttpsOptionsArgs {
|
||||
enabled: boolean
|
||||
configDir: string
|
||||
host: string
|
||||
tlsKeyPath?: string
|
||||
tlsCertPath?: string
|
||||
tlsCaPath?: string
|
||||
tlsSANs?: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const LEAF_VALIDITY_DAYS = 30
|
||||
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
|
||||
|
||||
const CA_VALIDITY_DAYS = 365
|
||||
|
||||
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
|
||||
if (!args.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
|
||||
if (hasProvided) {
|
||||
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
|
||||
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
|
||||
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
|
||||
return {
|
||||
httpsOptions: { key, cert, ca },
|
||||
caCertPath: args.tlsCaPath,
|
||||
mode: "provided",
|
||||
}
|
||||
}
|
||||
|
||||
return ensureGeneratedTls(args)
|
||||
}
|
||||
|
||||
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
|
||||
const tlsDir = path.join(args.configDir, "tls")
|
||||
const caKeyPath = path.join(tlsDir, "ca-key.pem")
|
||||
const caCertPath = path.join(tlsDir, "ca-cert.pem")
|
||||
const keyPath = path.join(tlsDir, "server-key.pem")
|
||||
const certPath = path.join(tlsDir, "server-cert.pem")
|
||||
|
||||
fs.mkdirSync(tlsDir, { recursive: true })
|
||||
|
||||
const shouldRotateLeaf = () => {
|
||||
try {
|
||||
if (!fs.existsSync(certPath)) return true
|
||||
const pem = fs.readFileSync(certPath, "utf-8")
|
||||
const x509 = new crypto.X509Certificate(pem)
|
||||
const validToMs = Date.parse(x509.validTo)
|
||||
if (!Number.isFinite(validToMs)) return true
|
||||
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
|
||||
return Date.now() >= rotateAt
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRotateCa = () => {
|
||||
try {
|
||||
if (!fs.existsSync(caCertPath)) return true
|
||||
const pem = fs.readFileSync(caCertPath, "utf-8")
|
||||
const x509 = new crypto.X509Certificate(pem)
|
||||
const validToMs = Date.parse(x509.validTo)
|
||||
if (!Number.isFinite(validToMs)) return true
|
||||
// CA rotates only when expired.
|
||||
return Date.now() >= validToMs
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
|
||||
const { caKeyPem, caCertPem } = generateCaCertificate()
|
||||
writePemFile(caKeyPath, caKeyPem, 0o600)
|
||||
writePemFile(caCertPath, caCertPem, 0o644)
|
||||
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
|
||||
}
|
||||
|
||||
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
|
||||
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
|
||||
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
|
||||
|
||||
const { keyPem, certPem } = generateServerCertificate({
|
||||
host: args.host,
|
||||
tlsSANs: args.tlsSANs,
|
||||
caKeyPem,
|
||||
caCertPem,
|
||||
})
|
||||
|
||||
writePemFile(keyPath, keyPem, 0o600)
|
||||
writePemFile(certPath, certPem, 0o644)
|
||||
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
|
||||
}
|
||||
|
||||
const key = fs.readFileSync(keyPath, "utf-8")
|
||||
const cert = fs.readFileSync(certPath, "utf-8")
|
||||
const ca = fs.readFileSync(caCertPath, "utf-8")
|
||||
|
||||
// Present the CA as part of the chain.
|
||||
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
|
||||
|
||||
return {
|
||||
httpsOptions: {
|
||||
key,
|
||||
cert: chainedCert,
|
||||
},
|
||||
caCertPath,
|
||||
mode: "generated",
|
||||
}
|
||||
}
|
||||
|
||||
function writePemFile(filePath: string, content: string, mode: number) {
|
||||
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
|
||||
try {
|
||||
fs.chmodSync(filePath, mode)
|
||||
} catch {
|
||||
// best effort on platforms that ignore chmod
|
||||
}
|
||||
}
|
||||
|
||||
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
|
||||
const forge = loadForge()
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||
const cert = forge.pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = new Date(now.getTime() - 60_000)
|
||||
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
cert.validity.notBefore = notBefore
|
||||
cert.validity.notAfter = notAfter
|
||||
|
||||
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
|
||||
cert.setSubject(attrs)
|
||||
cert.setIssuer(attrs)
|
||||
|
||||
cert.setExtensions([
|
||||
{ name: "basicConstraints", cA: true },
|
||||
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
|
||||
{ name: "subjectKeyIdentifier" },
|
||||
])
|
||||
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create())
|
||||
|
||||
return {
|
||||
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
caCertPem: forge.pki.certificateToPem(cert),
|
||||
}
|
||||
}
|
||||
|
||||
function generateServerCertificate(args: {
|
||||
host: string
|
||||
tlsSANs?: string
|
||||
caKeyPem: string
|
||||
caCertPem: string
|
||||
}): { keyPem: string; certPem: string } {
|
||||
const forge = loadForge()
|
||||
|
||||
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
|
||||
const caCert = forge.pki.certificateFromPem(args.caCertPem)
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048)
|
||||
const cert = forge.pki.createCertificate()
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex")
|
||||
|
||||
const now = new Date()
|
||||
const notBefore = new Date(now.getTime() - 60_000)
|
||||
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
|
||||
cert.validity.notBefore = notBefore
|
||||
cert.validity.notAfter = notAfter
|
||||
|
||||
const commonName = pickCommonName(args.host)
|
||||
cert.setSubject([{ name: "commonName", value: commonName }])
|
||||
cert.setIssuer(caCert.subject.attributes)
|
||||
|
||||
const san = buildSubjectAltNames(args.host, args.tlsSANs)
|
||||
|
||||
cert.setExtensions([
|
||||
{ name: "basicConstraints", cA: false },
|
||||
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
||||
{ name: "extKeyUsage", serverAuth: true },
|
||||
{ name: "subjectAltName", altNames: san },
|
||||
{ name: "subjectKeyIdentifier" },
|
||||
])
|
||||
|
||||
cert.sign(caKey, forge.md.sha256.create())
|
||||
|
||||
return {
|
||||
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
certPem: forge.pki.certificateToPem(cert),
|
||||
}
|
||||
}
|
||||
|
||||
function pickCommonName(host: string): string {
|
||||
if (!host || host === "0.0.0.0") {
|
||||
return "localhost"
|
||||
}
|
||||
if (host === "127.0.0.1") {
|
||||
return "localhost"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
|
||||
const dns = new Set<string>()
|
||||
const ips = new Set<string>()
|
||||
|
||||
dns.add("localhost")
|
||||
ips.add("127.0.0.1")
|
||||
|
||||
if (host && host !== "0.0.0.0") {
|
||||
if (isIPv4(host)) {
|
||||
ips.add(host)
|
||||
} else {
|
||||
dns.add(host)
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of splitList(tlsSANs)) {
|
||||
if (isIPv4(token)) {
|
||||
ips.add(token)
|
||||
} else if (token) {
|
||||
dns.add(token)
|
||||
}
|
||||
}
|
||||
|
||||
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
|
||||
|
||||
// 2 = DNS, 7 = IP
|
||||
for (const name of Array.from(dns)) {
|
||||
altNames.push({ type: 2, value: name })
|
||||
}
|
||||
for (const ip of Array.from(ips)) {
|
||||
altNames.push({ type: 7, ip })
|
||||
}
|
||||
|
||||
return altNames
|
||||
}
|
||||
|
||||
function splitList(input: string | undefined): string[] {
|
||||
if (!input) return []
|
||||
return input
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
55
packages/server/src/settings/binaries.ts
Normal file
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
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
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
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
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
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
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
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
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.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,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
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
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
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,
|
||||
}
|
||||
}
|
||||
260
packages/server/src/workspaces/git-worktrees.ts
Normal file
260
packages/server/src/workspaces/git-worktrees.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import type { WorktreeDescriptor } from "../api-types"
|
||||
import { promises as fsp } from "fs"
|
||||
|
||||
export interface LogLike {
|
||||
debug?: (obj: any, msg?: string) => void
|
||||
warn?: (obj: any, msg?: string) => void
|
||||
}
|
||||
|
||||
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> {
|
||||
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 async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||
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) {
|
||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
}
|
||||
const repoRoot = result.stdout.trim()
|
||||
if (!repoRoot) {
|
||||
return { repoRoot: folder, isGitRepo: false }
|
||||
}
|
||||
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 }> {
|
||||
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||
const lines = output.split(/\r?\n/)
|
||||
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
|
||||
|
||||
const flush = () => {
|
||||
if (current.worktree) {
|
||||
records.push({ worktree: current.worktree, branch: current.branch })
|
||||
}
|
||||
current = {}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const [key, ...rest] = trimmed.split(" ")
|
||||
const value = rest.join(" ").trim()
|
||||
if (key === "worktree") {
|
||||
current.worktree = value
|
||||
} else if (key === "branch") {
|
||||
// branch is like refs/heads/foo
|
||||
current.branch = value.replace(/^refs\/heads\//, "")
|
||||
} else if (key === "HEAD") {
|
||||
current.head = value
|
||||
} else if (key === "detached") {
|
||||
current.detached = true
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return records
|
||||
}
|
||||
|
||||
export async function listWorktrees(params: {
|
||||
repoRoot: string
|
||||
workspaceFolder: string
|
||||
logger?: LogLike
|
||||
}): Promise<WorktreeDescriptor[]> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
|
||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||
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")
|
||||
return [rootDescriptor]
|
||||
}
|
||||
|
||||
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 seen = new Set<string>(["root"])
|
||||
|
||||
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
||||
const branch = (record.branch ?? "").trim()
|
||||
if (branch) {
|
||||
return branch
|
||||
}
|
||||
const head = (record.head ?? "").trim()
|
||||
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
|
||||
return `detached-${head.slice(0, 7)}`
|
||||
}
|
||||
// Fallback: stable-ish identifier derived from directory basename.
|
||||
const base = path.basename(record.worktree || "")
|
||||
return base ? `worktree-${base}` : "worktree"
|
||||
}
|
||||
|
||||
|
||||
for (const record of records) {
|
||||
const abs = record.worktree
|
||||
if (!abs || typeof abs !== "string") continue
|
||||
|
||||
// Skip the root record (we always expose it as slug="root").
|
||||
if (path.resolve(abs) === path.resolve(repoRoot)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = normalizeSlug(record)
|
||||
if (!slug || slug === "root") {
|
||||
continue
|
||||
}
|
||||
if (seen.has(slug)) {
|
||||
continue
|
||||
}
|
||||
seen.add(slug)
|
||||
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
||||
}
|
||||
|
||||
return worktrees
|
||||
}
|
||||
|
||||
export function isValidWorktreeSlug(slug: string): boolean {
|
||||
if (!slug) return false
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > 200) return false
|
||||
// Disallow control characters; allow branch-like slugs including '/'.
|
||||
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createManagedWorktree(params: {
|
||||
repoRoot: string
|
||||
workspaceFolder: string
|
||||
slug: string
|
||||
logger?: LogLike
|
||||
}): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||
const { repoRoot, workspaceFolder, logger } = params
|
||||
const branch = params.slug.trim()
|
||||
|
||||
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
|
||||
throw new Error("Invalid worktree slug")
|
||||
}
|
||||
|
||||
const sanitizeDirName = (input: string): string => {
|
||||
const normalized = input
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return normalized || "worktree"
|
||||
}
|
||||
|
||||
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
|
||||
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
|
||||
await fsp.mkdir(worktreesDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const stat = await fsp.stat(targetDir)
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error("Worktree directory already exists")
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
|
||||
|
||||
// Prefer creating a new branch from HEAD.
|
||||
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
|
||||
if (first.ok) {
|
||||
return { slug: branch, directory: targetDir, branch }
|
||||
}
|
||||
|
||||
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
|
||||
if (message.includes("already exists")) {
|
||||
// If the branch already exists, add worktree for that branch.
|
||||
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
|
||||
if (second.ok) {
|
||||
return { slug: branch, directory: targetDir, branch }
|
||||
}
|
||||
throw second.error
|
||||
}
|
||||
|
||||
throw first.error
|
||||
}
|
||||
|
||||
export async function removeWorktree(params: {
|
||||
workspaceFolder: string
|
||||
directory: string
|
||||
force?: boolean
|
||||
logger?: LogLike
|
||||
}): Promise<void> {
|
||||
const { workspaceFolder, logger } = params
|
||||
const directory = (params.directory ?? "").trim()
|
||||
if (!directory) {
|
||||
throw new Error("Invalid worktree directory")
|
||||
}
|
||||
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
|
||||
|
||||
const args = ["worktree", "remove"]
|
||||
if (params.force) {
|
||||
args.push("--force")
|
||||
}
|
||||
args.push(directory)
|
||||
|
||||
const result = await runGit(args, workspaceFolder)
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Best-effort cleanup of stale metadata.
|
||||
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
const url = `http://${INSTANCE_HOST}:${port}/global/event`
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
@@ -165,8 +165,32 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||
const parsed = JSON.parse(payload) as any
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode SSE payload shapes vary across versions.
|
||||
// Common variants:
|
||||
// - { type, properties, ... }
|
||||
// - { payload: { type, properties, ... }, directory: "/abs/path" }
|
||||
// - { payload: { type, properties, ... } }
|
||||
const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed
|
||||
|
||||
const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null
|
||||
|
||||
// Attach directory when available (don't overwrite if already present).
|
||||
if (event && !(event as any).directory && typeof (parsed as any).directory === "string") {
|
||||
;(event as any).directory = (parsed as any).directory
|
||||
}
|
||||
|
||||
if (!event || typeof (event as any).type !== "string") {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
|
||||
return
|
||||
}
|
||||
|
||||
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
|
||||
if (this.options.logger.isLevelEnabled("trace")) {
|
||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { BinaryResolver } from "../settings/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
@@ -21,13 +21,79 @@ import {
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
function defaultShellPath(): string {
|
||||
const configured = process.env.SHELL?.trim()
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
|
||||
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"
|
||||
}
|
||||
|
||||
function shellEscape(input: string): string {
|
||||
if (!input) return "''"
|
||||
return `'${input.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath).toLowerCase()
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string, command: string): string[] {
|
||||
const shellName = path.basename(shellPath).toLowerCase()
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c", command]
|
||||
}
|
||||
return ["-l", "-c", command]
|
||||
}
|
||||
|
||||
function resolveBinaryPathFromUserShell(identifier: string): string | null {
|
||||
if (process.platform === "win32") {
|
||||
return null
|
||||
}
|
||||
|
||||
const shellPath = defaultShellPath()
|
||||
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath)
|
||||
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_prefix: undefined,
|
||||
NPM_CONFIG_PREFIX: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
|
||||
return resolved ?? null
|
||||
}
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
settings: SettingsService
|
||||
binaryResolver: BinaryResolver
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
||||
nodeExtraCaCertsPath?: string
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
@@ -81,17 +147,23 @@ 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> {
|
||||
|
||||
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 workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
@@ -107,17 +179,14 @@ export class WorkspaceManager {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||
const envVars = (serverConfig as any)?.environmentVariables
|
||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||
|
||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||
const opencodePassword = generateOpencodeServerPassword()
|
||||
@@ -132,20 +201,27 @@ export class WorkspaceManager {
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
logLevel,
|
||||
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.port = port
|
||||
@@ -254,6 +330,12 @@ export class WorkspaceManager {
|
||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||
}
|
||||
|
||||
const shellResolved = resolveBinaryPathFromUserShell(identifier)
|
||||
if (shellResolved) {
|
||||
this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell")
|
||||
return shellResolved
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
@@ -274,42 +356,12 @@ export class WorkspaceManager {
|
||||
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: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
}): Promise<string | undefined> {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
@@ -323,7 +375,7 @@ export class WorkspaceManager {
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
const version = await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
@@ -336,6 +388,8 @@ export class WorkspaceManager {
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
@@ -343,7 +397,7 @@ export class WorkspaceManager {
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
}): Promise<string | undefined> {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
@@ -357,7 +411,7 @@ export class WorkspaceManager {
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
return probeResult.version
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
@@ -368,8 +422,11 @@ export class WorkspaceManager {
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
private async probeInstance(
|
||||
workspaceId: string,
|
||||
port: number,
|
||||
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/global/health`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -380,11 +437,22 @@ export class WorkspaceManager {
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
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")
|
||||
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) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
|
||||
@@ -4,43 +4,10 @@ import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
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 }
|
||||
}
|
||||
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||
|
||||
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> {
|
||||
const redacted: Record<string, string | undefined> = {}
|
||||
@@ -59,6 +26,7 @@ interface LaunchOptions {
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
logLevel?: string
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
@@ -72,6 +40,10 @@ export interface ProcessExitInfo {
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
wsl?: {
|
||||
distro: string
|
||||
linuxPid: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
@@ -82,7 +54,8 @@ export class WorkspaceRuntime {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
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 ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
@@ -108,7 +81,13 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
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(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -116,22 +95,40 @@ export class WorkspaceRuntime {
|
||||
folder: options.folder,
|
||||
binary: options.binaryPath,
|
||||
spawnCommand: spec.command,
|
||||
spawnArgs: spec.args,
|
||||
commandLine,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
spawnArgs: spec.args,
|
||||
},
|
||||
"OpenCode spawn args",
|
||||
)
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"OpenCode spawn environment",
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...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)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
@@ -211,6 +208,15 @@ export class WorkspaceRuntime {
|
||||
const trimmed = line.trim()
|
||||
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)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
@@ -325,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) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||
if (!trySignalWslProcess(signal)) {
|
||||
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
307
packages/server/src/workspaces/spawn.ts
Normal file
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
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user