Compare commits
416 Commits
fix/questi
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
67f5f830a3 | ||
|
|
81102cc6bf | ||
|
|
afa7243eab | ||
|
|
37b7c1e53c | ||
|
|
ba61ab79e2 | ||
|
|
37d075fbb3 | ||
|
|
2961d41be3 | ||
|
|
1bb5aedfdb | ||
|
|
0a793fb1c6 | ||
|
|
a401eeec11 | ||
|
|
d9bcc66930 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
240
.github/workflows/build-and-upload.yml
vendored
240
.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
|
||||
@@ -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,6 +101,112 @@ 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: |
|
||||
@@ -76,6 +218,15 @@ jobs:
|
||||
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
|
||||
@@ -155,6 +319,15 @@ jobs:
|
||||
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
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -164,6 +337,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -206,7 +381,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 +392,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 +421,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -279,7 +465,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 +476,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 +505,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -355,7 +552,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 +565,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 +594,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -443,7 +651,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 +677,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 +707,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -587,6 +806,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 +844,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
|
||||
|
||||
40
.github/workflows/manual-npm-publish.yml
vendored
40
.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
|
||||
@@ -34,6 +50,8 @@ jobs:
|
||||
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
|
||||
10
.github/workflows/release-ui.yml
vendored
10
.github/workflows/release-ui.yml
vendored
@@ -1,7 +1,13 @@
|
||||
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:
|
||||
@@ -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
|
||||
35
.github/workflows/reusable-release.yml
vendored
35
.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,6 +18,21 @@ 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
|
||||
@@ -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.
|
||||
170
README.md
170
README.md
@@ -1,117 +1,127 @@
|
||||
# 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**
|
||||
- **⌨️ 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
|
||||
|
||||
- **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.
|
||||
---
|
||||
|
||||
## 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 |
4859
package-lock.json
generated
4859
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.2",
|
||||
"version": "0.13.3",
|
||||
"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",
|
||||
|
||||
@@ -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.13.3",
|
||||
"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")
|
||||
@@ -92,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
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 +141,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 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
@@ -135,13 +168,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)) {
|
||||
@@ -206,25 +280,30 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -321,13 +400,68 @@ 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,
|
||||
},
|
||||
})
|
||||
|
||||
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,19 @@ if (isMac) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Required for Windows notifications / taskbar grouping.
|
||||
// Keep in sync with desktop app identifier.
|
||||
try {
|
||||
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
@@ -490,6 +638,17 @@ app.whenReady().then(() => {
|
||||
}
|
||||
|
||||
createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
event.preventDefault()
|
||||
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
callback(false)
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
@@ -505,7 +664,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"
|
||||
@@ -37,8 +42,41 @@ interface CliEntryResolution {
|
||||
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,67 @@ 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)
|
||||
|
||||
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)) {
|
||||
const runtimePath = this.resolveShellNodeCommand()
|
||||
const entryPath = this.resolveBundledProdEntry()
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? 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 node at ${runtimePath} (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: shellEnv,
|
||||
stdio: "pipe",
|
||||
serviceName: "CodeNomad CLI Supervisor",
|
||||
})
|
||||
this.childLaunchMode = "utility"
|
||||
} else {
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const 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 +216,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 +284,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 +383,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 +439,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 +510,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,10 +539,24 @@ 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
|
||||
@@ -308,6 +572,10 @@ 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 === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
@@ -378,4 +646,58 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||
}
|
||||
|
||||
private resolveCliSupervisorPath(): string {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||
}
|
||||
|
||||
private resolveShellNodeCommand(): string {
|
||||
const configured = process.env.NODE_BINARY?.trim()
|
||||
return configured && configured.length > 0 ? configured : "node"
|
||||
}
|
||||
|
||||
private resolveBundledProdEntry(): string {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||
}
|
||||
|
||||
private describeUtilityProcessError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const typed = error as { type?: unknown; location?: unknown }
|
||||
if (typeof typed.type === "string") {
|
||||
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||
}
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
@@ -12,6 +12,18 @@ 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)
|
||||
|
||||
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.2",
|
||||
"version": "0.13.3",
|
||||
"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",
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
|
||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs"
|
||||
import path, { join } from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
const serverRoot = join(appDir, "..", "server")
|
||||
const resourcesRoot = join(appDir, "electron", "resources")
|
||||
const serverDest = join(resourcesRoot, "server")
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||
|
||||
function log(message) {
|
||||
console.log(`[prepare-resources] ${message}`)
|
||||
}
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = join(serverRoot, "dist")
|
||||
const publicPath = join(serverRoot, "public")
|
||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(serverDepsMarker)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("installing production server dependencies")
|
||||
const npmArgs = [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--workspaces=false",
|
||||
"--package-lock=false",
|
||||
"--install-strategy=shallow",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
]
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
npm_config_workspaces: "false",
|
||||
}
|
||||
|
||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||
const result = npmCli
|
||||
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
|
||||
for (const name of serverSources) {
|
||||
const from = join(serverRoot, name)
|
||||
const to = join(serverDest, name)
|
||||
if (!fs.existsSync(from)) {
|
||||
throw new Error(`Missing required server artifact: ${from}`)
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||
log(`copied ${name} to Electron resources`)
|
||||
}
|
||||
}
|
||||
|
||||
function stripNodeModuleBins() {
|
||||
const root = join(serverDest, "node_modules")
|
||||
if (!fs.existsSync(root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
fs.rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
log(`removed ${removed} node_modules/.bin directories`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureServerBuild()
|
||||
ensureServerDependencies()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[prepare-resources] failed:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -14,5 +14,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"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.3.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,20 +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.2",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.2",
|
||||
"version": "0.13.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.2",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
@@ -20,7 +21,7 @@
|
||||
"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": {
|
||||
@@ -30,12 +31,16 @@
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
@@ -50,6 +49,38 @@ 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 LogLevel = "debug" | "info" | "warn" | "error"
|
||||
|
||||
export interface WorkspaceLogEntry {
|
||||
@@ -139,6 +170,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 +200,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 +225,79 @@ 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 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 +308,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 +320,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 +347,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 +370,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 +390,8 @@ export interface BackgroundProcess {
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
terminalReason?: BackgroundProcessTerminalReason
|
||||
notifyEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
|
||||
@@ -15,15 +15,27 @@ export interface AuthManagerInit {
|
||||
username: string
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore
|
||||
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) {
|
||||
this.authStore = null
|
||||
this.tokenManager = null
|
||||
return
|
||||
}
|
||||
|
||||
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||
|
||||
@@ -37,6 +49,10 @@ export class AuthManager {
|
||||
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||
}
|
||||
|
||||
isAuthEnabled(): boolean {
|
||||
return this.authEnabled
|
||||
}
|
||||
|
||||
getCookieName(): string {
|
||||
return this.cookieName
|
||||
}
|
||||
@@ -56,19 +72,31 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
validateLogin(username: string, password: string): boolean {
|
||||
return this.authStore.validateCredentials(username, password)
|
||||
if (!this.authEnabled) {
|
||||
return true
|
||||
}
|
||||
return this.requireAuthStore().validateCredentials(username, password)
|
||||
}
|
||||
|
||||
createSession(username: string) {
|
||||
if (!this.authEnabled) {
|
||||
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
|
||||
}
|
||||
return this.sessionManager.createSession(username)
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.authStore.getStatus()
|
||||
if (!this.authEnabled) {
|
||||
return { username: this.init.username, passwordUserProvided: false }
|
||||
}
|
||||
return this.requireAuthStore().getStatus()
|
||||
}
|
||||
|
||||
setPassword(password: string) {
|
||||
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||
if (!this.authEnabled) {
|
||||
throw new Error("Internal authentication is disabled")
|
||||
}
|
||||
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
|
||||
}
|
||||
|
||||
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||
@@ -76,7 +104,18 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
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 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
|
||||
@@ -87,9 +126,34 @@ 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")
|
||||
}
|
||||
return this.authStore
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -104,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,7 +19,12 @@ 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 { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
import { SideCarManager } from "./sidecars/manager"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -28,8 +34,15 @@ const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
|
||||
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,12 +56,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()
|
||||
@@ -56,9 +72,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))
|
||||
@@ -79,16 +102,36 @@ 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")
|
||||
.default(false),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--dangerously-skip-auth",
|
||||
"Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).",
|
||||
)
|
||||
.env("CODENOMAD_SKIP_AUTH")
|
||||
.default(false),
|
||||
)
|
||||
|
||||
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
|
||||
@@ -103,9 +146,16 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
|
||||
const parseBooleanEnv = (value: string | undefined): boolean => {
|
||||
const normalized = (value ?? "").trim().toLowerCase()
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on"
|
||||
}
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
@@ -113,9 +163,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),
|
||||
@@ -129,7 +193,9 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,16 +240,31 @@ async function main() {
|
||||
|
||||
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||
|
||||
if (options.dangerouslySkipAuth) {
|
||||
logger.warn(
|
||||
"DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).",
|
||||
)
|
||||
}
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
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: [],
|
||||
@@ -191,33 +272,55 @@ 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,
|
||||
},
|
||||
logger.child({ component: "auth" }),
|
||||
)
|
||||
|
||||
if (options.generateToken) {
|
||||
if (options.generateToken && !options.dangerouslySkipAuth) {
|
||||
const token = authManager.issueBootstrapToken()
|
||||
if (token) {
|
||||
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -254,28 +357,158 @@ async function main() {
|
||||
minServerVersion: uiResolution.minServerVersion,
|
||||
}
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
workspaceManager,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
})
|
||||
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 startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
if (uiResolution.uiDevServerUrl && options.https) {
|
||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||
}
|
||||
|
||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
|
||||
const 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,
|
||||
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,
|
||||
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
|
||||
@@ -295,6 +528,12 @@ 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 {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
@@ -305,8 +544,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")
|
||||
}
|
||||
@@ -316,6 +555,8 @@ async function main() {
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
devReleaseMonitor?.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
96
packages/server/src/plugins/voice-mode.ts
Normal file
96
packages/server/src/plugins/voice-mode.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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): void {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,56 @@ import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import { connect as connectTcp, type Socket } from "net"
|
||||
import path from "path"
|
||||
import { connect as connectTls, type TLSSocket } from "tls"
|
||||
import { fetch } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||
import { registerConfigRoutes } from "./routes/config"
|
||||
import { registerSettingsRoutes } from "./routes/settings"
|
||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
import type { SpeechService } from "../speech/service"
|
||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
import type { SideCarManager } from "../sidecars/manager"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
port: number
|
||||
bindHost: string
|
||||
bindPort: number
|
||||
/** When bindPort is 0, try this first. */
|
||||
defaultPort: number
|
||||
protocol: "http" | "https"
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
workspaceManager: WorkspaceManager
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
settings: SettingsService
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
@@ -49,10 +65,15 @@ interface HttpServerStartResult {
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
const DEFAULT_HTTP_PORT = 9898
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
||||
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
||||
const app = Fastify(
|
||||
({
|
||||
logger: false,
|
||||
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
|
||||
} as unknown) as any,
|
||||
) as unknown as FastifyInstance
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
const apiLogger = deps.logger.child({ component: "http" })
|
||||
const sseLogger = deps.logger.child({ component: "sse" })
|
||||
@@ -95,6 +116,27 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const getSelfOrigins = (): Set<string> => {
|
||||
const origins = new Set<string>()
|
||||
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
for (const addr of deps.serverMeta.addresses ?? []) {
|
||||
try {
|
||||
origins.add(new URL(addr.remoteUrl).origin)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) {
|
||||
@@ -102,14 +144,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
let selfOrigin: string | null = null
|
||||
try {
|
||||
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||
} catch {
|
||||
selfOrigin = null
|
||||
}
|
||||
|
||||
if (selfOrigin && origin === selfOrigin) {
|
||||
const selfOrigins = getSelfOrigins()
|
||||
if (selfOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +156,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
|
||||
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
@@ -146,6 +182,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -166,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||
if (requiresAuthForApi && !session) {
|
||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||
@@ -218,16 +261,37 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerEventRoutes(app, {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||
setupSideCarWebSocketProxy(app, {
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: proxyLogger,
|
||||
})
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
@@ -242,12 +306,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const attemptListen = async (requestedPort: number) => {
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
|
||||
return { addressInfo, requestedPort }
|
||||
}
|
||||
|
||||
const autoPortRequested = deps.port === 0
|
||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||
const autoPortRequested = deps.bindPort === 0
|
||||
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
||||
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
@@ -283,20 +347,16 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
|
||||
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
|
||||
|
||||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
@@ -307,52 +367,197 @@ interface InstanceProxyDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarProxyDeps {
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||
authManager: AuthManager
|
||||
}
|
||||
|
||||
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: "",
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
app.all("/sidecars/:id", proxyBaseHandler)
|
||||
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||
}
|
||||
|
||||
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||
app.server.on("upgrade", (request, socket, head) => {
|
||||
const rawUrl = request.url ?? "/"
|
||||
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
void proxySideCarWebSocketUpgrade({
|
||||
request,
|
||||
socket: socket as Socket,
|
||||
head,
|
||||
sidecarId: parsed.sidecarId,
|
||||
incomingPath: parsed.pathname,
|
||||
search: parsed.search,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: deps.logger,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxyWorkspaceRequest({
|
||||
request,
|
||||
reply,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
worktreeSlug: request.params.slug,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
logger: deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||
})
|
||||
}
|
||||
|
||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||
|
||||
// Special-case OpenCode directory override.
|
||||
//
|
||||
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||
// before proxying to the instance.
|
||||
//
|
||||
// Example proxied request path:
|
||||
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||
//
|
||||
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||
|
||||
async function proxyWorkspaceRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
worktreeSlug: string
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const { request, reply, workspaceManager, logger } = args
|
||||
const { request, reply, workspaceManager, logger, worktreeSlug } = args
|
||||
const workspaceId = (request.params as { id: string }).id
|
||||
const workspace = workspaceManager.get(workspaceId)
|
||||
|
||||
const bodyToJson = (body: unknown): unknown => {
|
||||
if (body == null) return null
|
||||
|
||||
const anyBody = body as any
|
||||
if (anyBody && typeof anyBody.pipe === "function") {
|
||||
// Don't consume streams (would break proxying).
|
||||
// Best-effort: if the stream already has buffered chunks, parse those.
|
||||
try {
|
||||
const buffered = anyBody?._readableState?.buffer
|
||||
if (Array.isArray(buffered) && buffered.length > 0) {
|
||||
const chunks: Buffer[] = []
|
||||
for (const entry of buffered) {
|
||||
if (!entry) continue
|
||||
if (Buffer.isBuffer(entry)) {
|
||||
chunks.push(entry)
|
||||
continue
|
||||
}
|
||||
const data = (entry as any).data
|
||||
if (Buffer.isBuffer(data)) {
|
||||
chunks.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const text = Buffer.concat(chunks).toString("utf-8")
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { __raw: text }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return { __stream: true }
|
||||
}
|
||||
|
||||
const maybeParse = (input: string): unknown => {
|
||||
try {
|
||||
return JSON.parse(input)
|
||||
} catch {
|
||||
return { __raw: input }
|
||||
}
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return maybeParse(body.toString("utf-8"))
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
return maybeParse(body)
|
||||
}
|
||||
|
||||
if (typeof body === "object") {
|
||||
return body
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
@@ -364,7 +569,48 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
if (!isValidWorktreeSlug(worktreeSlug)) {
|
||||
reply.code(400).send({ error: "Invalid worktree slug" })
|
||||
return
|
||||
}
|
||||
|
||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||
try {
|
||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
let directory: string | null = null
|
||||
let forwardedSuffix = extracted.forwardedSuffix
|
||||
|
||||
if (extracted.overrideDirectory) {
|
||||
try {
|
||||
directory = validateAndNormalizeOverrideDirectory({
|
||||
overrideDirectory: extracted.overrideDirectory,
|
||||
workspaceRoot: workspace.path,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
@@ -380,6 +626,43 @@ async function proxyWorkspaceRequest(args: {
|
||||
if (instanceAuthHeader) {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
|
||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
||||
|
||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
const outgoing: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
outgoing[key] = value
|
||||
}
|
||||
|
||||
// Redact sensitive headers.
|
||||
for (const key of Object.keys(outgoing)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
||||
outgoing[key] = "<redacted>"
|
||||
}
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{
|
||||
workspaceId,
|
||||
method: request.method,
|
||||
targetUrl,
|
||||
worktreeSlug,
|
||||
directory,
|
||||
contentType: request.headers["content-type"],
|
||||
body: bodyToJson(request.body),
|
||||
headers: outgoing,
|
||||
},
|
||||
"Proxy -> OpenCode request",
|
||||
)
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
onError: (proxyReply, { error }) => {
|
||||
@@ -391,6 +674,89 @@ async function proxyWorkspaceRequest(args: {
|
||||
})
|
||||
}
|
||||
|
||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||
overrideDirectory: string | null
|
||||
forwardedSuffix: string | undefined
|
||||
} {
|
||||
if (!pathSuffix) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
// Fastify wildcard param does not include a leading slash.
|
||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||
}
|
||||
|
||||
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||
const slashIndex = rest.indexOf("/")
|
||||
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||
|
||||
if (!encoded) {
|
||||
throw new Error("Missing directory override")
|
||||
}
|
||||
|
||||
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||
throw new Error("Directory override too large")
|
||||
}
|
||||
|
||||
let overrideDirectory = ""
|
||||
try {
|
||||
overrideDirectory = decodeBase64Url(encoded)
|
||||
} catch {
|
||||
throw new Error("Invalid directory override")
|
||||
}
|
||||
const forwardedSuffix = remaining
|
||||
return { overrideDirectory, forwardedSuffix }
|
||||
}
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
// base64url -> base64
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||
const base64 = `${normalized}${padding}`
|
||||
return Buffer.from(base64, "base64").toString("utf-8")
|
||||
}
|
||||
|
||||
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||
const raw = params.overrideDirectory.trim()
|
||||
if (!raw) {
|
||||
throw new Error("Override directory is empty")
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(raw)) {
|
||||
throw new Error("Override directory must be an absolute path")
|
||||
}
|
||||
|
||||
if (!fs.existsSync(raw)) {
|
||||
throw new Error(`Override directory does not exist: ${raw}`)
|
||||
}
|
||||
|
||||
const stats = fs.statSync(raw)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Override path is not a directory: ${raw}`)
|
||||
}
|
||||
|
||||
const normalizedOverride = fs.realpathSync(raw)
|
||||
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||
|
||||
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||
throw new Error("Override directory must be within the workspace root")
|
||||
}
|
||||
|
||||
return normalizedOverride
|
||||
}
|
||||
|
||||
function isSubpath(candidate: string, root: string): boolean {
|
||||
const rel = path.relative(root, candidate)
|
||||
if (rel === "") return true
|
||||
if (rel === "..") return false
|
||||
if (rel.startsWith(`..${path.sep}`)) return false
|
||||
if (path.isAbsolute(rel)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
@@ -399,6 +765,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||
}
|
||||
|
||||
type WorktreeCacheEntry = {
|
||||
expiresAt: number
|
||||
repoRoot: string
|
||||
worktrees: Array<{ slug: string; directory: string }>
|
||||
}
|
||||
|
||||
const WORKTREE_CACHE_TTL_MS = 2000
|
||||
const worktreeCache = new Map<string, WorktreeCacheEntry>()
|
||||
|
||||
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
|
||||
const cached = worktreeCache.get(params.workspaceId)
|
||||
const now = Date.now()
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
|
||||
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
|
||||
const entry: WorktreeCacheEntry = {
|
||||
expiresAt: now + WORKTREE_CACHE_TTL_MS,
|
||||
repoRoot,
|
||||
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
|
||||
}
|
||||
worktreeCache.set(params.workspaceId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function resolveWorktreeDirectory(params: {
|
||||
workspaceId: string
|
||||
workspacePath: string
|
||||
worktreeSlug: string
|
||||
logger: Logger
|
||||
}): Promise<string | null> {
|
||||
const { worktreeSlug } = params
|
||||
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
|
||||
if (match) {
|
||||
return match.directory
|
||||
}
|
||||
|
||||
// If the slug is new (e.g., created moments ago), refresh once.
|
||||
worktreeCache.delete(params.workspaceId)
|
||||
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
|
||||
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
|
||||
}
|
||||
|
||||
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||
if (!uiDir) {
|
||||
app.log.warn("UI static directory not provided; API endpoints only")
|
||||
@@ -501,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function proxySideCarRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
args.reply.code(404).send({ error: "SideCar not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||
const pathSuffix = args.pathSuffix ?? ""
|
||||
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetUrl = `${targetOrigin}${targetPath}`
|
||||
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||
|
||||
await args.reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||
onError: (reply, { error }) => {
|
||||
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl, "http://localhost")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||
pathname: parsed.pathname,
|
||||
search: parsed.search,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySideCarWebSocketUpgrade(args: {
|
||||
request: import("http").IncomingMessage
|
||||
socket: Socket
|
||||
head: Buffer
|
||||
sidecarId: string
|
||||
incomingPath: string
|
||||
search: string
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
logger: Logger
|
||||
}) {
|
||||
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||
|
||||
if (!isWebSocketUpgradeRequest(request)) {
|
||||
rejectUpgrade(socket, 400, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromHeaders(request.headers)
|
||||
if (!session) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
const sidecar = await sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
rejectUpgrade(socket, 404, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||
|
||||
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
upstream.once("error", (error) => {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("error", (error) => {
|
||||
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once(readyEvent, () => {
|
||||
try {
|
||||
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||
if (head.length > 0) {
|
||||
upstream.write(head)
|
||||
}
|
||||
upstream.pipe(socket)
|
||||
socket.pipe(upstream)
|
||||
} catch (error) {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||
closeBoth()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once("close", () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.end()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("close", () => {
|
||||
if (!upstream.destroyed) {
|
||||
upstream.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||
if (targetUrl.protocol === "https:") {
|
||||
return {
|
||||
socket: connectTls({
|
||||
host: targetUrl.hostname,
|
||||
port,
|
||||
servername: targetUrl.hostname,
|
||||
}),
|
||||
readyEvent: "secureConnect",
|
||||
}
|
||||
}
|
||||
return {
|
||||
socket: connectTcp(port, targetUrl.hostname),
|
||||
readyEvent: "connect",
|
||||
}
|
||||
}
|
||||
|
||||
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||
const headerLines: string[] = []
|
||||
const rawHeaders = request.rawHeaders ?? []
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
|
||||
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||
const key = rawHeaders[index]
|
||||
const value = rawHeaders[index + 1]
|
||||
if (!key || value === undefined) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (blockedHeaders.has(lower)) continue
|
||||
if (lower === "origin") {
|
||||
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||
continue
|
||||
}
|
||||
headerLines.push(`${key}: ${value}\r\n`)
|
||||
}
|
||||
|
||||
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||
headerLines.push("\r\n")
|
||||
|
||||
return requestLine + headerLines.join("")
|
||||
}
|
||||
|
||||
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||
const upgrade = request.headers.upgrade
|
||||
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||
return false
|
||||
}
|
||||
const connection = request.headers.connection
|
||||
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||
if (socket.destroyed) {
|
||||
return
|
||||
}
|
||||
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
function rewriteSideCarResponseHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
sidecarId: string,
|
||||
targetOrigin: string,
|
||||
prefixMode: "strip" | "preserve",
|
||||
) {
|
||||
if (prefixMode === "preserve") {
|
||||
return headers
|
||||
}
|
||||
|
||||
const next = { ...headers }
|
||||
const locationHeader = next.location
|
||||
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||
if (!location) {
|
||||
return next
|
||||
}
|
||||
|
||||
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||
|
||||
if (location.startsWith("/")) {
|
||||
next.location = `${publicBase}${location}`
|
||||
return next
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(location)
|
||||
if (parsed.origin === targetOrigin) {
|
||||
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
} catch {
|
||||
// Relative redirects should continue to resolve against the public sidecar path.
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function sanitizeSideCarProxyRequestHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
targetOrigin: string,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
const next: Record<string, string | string[] | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!value) continue
|
||||
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||
next[key] = value
|
||||
}
|
||||
|
||||
next.origin = targetOrigin
|
||||
return next
|
||||
}
|
||||
|
||||
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||
return new Set([
|
||||
"host",
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
])
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -51,7 +51,19 @@ function getTokenHtml(): string {
|
||||
}
|
||||
|
||||
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 +79,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 +105,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 +129,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 +156,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,22 @@ 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 ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
const handleWildcard = async (request: any, reply: any) => {
|
||||
const workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
|
||||
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/runtime"
|
||||
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") }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentBodySchema = z.object({
|
||||
contents: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -100,6 +104,20 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
241
packages/server/src/workspaces/git-worktrees.ts
Normal file
241
packages/server/src/workspaces/git-worktrees.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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 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 (!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 }
|
||||
}
|
||||
|
||||
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 rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
||||
|
||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||
if (!result.ok) {
|
||||
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||
return [rootDescriptor]
|
||||
}
|
||||
|
||||
const records = parseWorktreePorcelain(result.stdout)
|
||||
|
||||
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"
|
||||
@@ -23,11 +23,13 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
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 +83,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 +115,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 +137,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
|
||||
@@ -274,42 +286,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 +305,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 +318,8 @@ export class WorkspaceManager {
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
@@ -343,7 +327,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 +341,7 @@ export class WorkspaceManager {
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
return probeResult.version
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
@@ -368,8 +352,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 +367,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")
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Logger } from "../logger"
|
||||
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.-]+)/
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
if (process.platform !== "win32") {
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
const spec = buildSpawnSpec(binaryPath, ["--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 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) }
|
||||
}
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
@@ -59,6 +116,7 @@ interface LaunchOptions {
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
logLevel?: string
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
@@ -82,7 +140,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
|
||||
@@ -116,12 +175,26 @@ 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,
|
||||
|
||||
129
packages/server/src/workspaces/worktree-map.ts
Normal file
129
packages/server/src/workspaces/worktree-map.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import fs from "fs"
|
||||
import { promises as fsp } from "fs"
|
||||
import path from "path"
|
||||
import type { WorktreeMap } from "../api-types"
|
||||
import { resolveRepoRoot } from "./git-worktrees"
|
||||
import type { LogLike } from "./git-worktrees"
|
||||
|
||||
const DEFAULT_MAP: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: "root",
|
||||
parentSessionWorktreeSlug: {},
|
||||
}
|
||||
|
||||
function getMapPath(repoRoot: string): string {
|
||||
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
|
||||
}
|
||||
|
||||
function getGitExcludePath(repoRoot: string): string {
|
||||
return path.join(repoRoot, ".git", "info", "exclude")
|
||||
}
|
||||
|
||||
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
|
||||
const excludePath = getGitExcludePath(repoRoot)
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = [
|
||||
".codenomad/worktrees/",
|
||||
".codenomad/worktreeMap.json",
|
||||
]
|
||||
|
||||
let existing = ""
|
||||
try {
|
||||
existing = await fsp.readFile(excludePath, "utf-8")
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "ENOENT") {
|
||||
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
|
||||
return
|
||||
}
|
||||
existing = ""
|
||||
}
|
||||
|
||||
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
|
||||
const missing = entries.filter((e) => !lines.has(e))
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
|
||||
const suffix = missing.map((e) => `${e}\n`).join("")
|
||||
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
|
||||
}
|
||||
|
||||
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
if (!isGitRepo) {
|
||||
return
|
||||
}
|
||||
await ensureGitExclude(repoRoot, logger)
|
||||
}
|
||||
|
||||
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
const filePath = getMapPath(repoRoot)
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
const version = (parsed as any).version
|
||||
if (version !== 1) {
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
|
||||
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
|
||||
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
|
||||
return {
|
||||
version: 1,
|
||||
defaultWorktreeSlug,
|
||||
parentSessionWorktreeSlug: { ...mapping },
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === "ENOENT") {
|
||||
if (isGitRepo) {
|
||||
// Best-effort ignore setup on first use.
|
||||
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||
}
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
|
||||
return DEFAULT_MAP
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
|
||||
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
|
||||
const filePath = getMapPath(repoRoot)
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true })
|
||||
|
||||
// Ensure ignore rules are present (local-only).
|
||||
if (isGitRepo) {
|
||||
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
|
||||
}
|
||||
|
||||
const payload: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
|
||||
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
|
||||
}
|
||||
|
||||
// Write atomically.
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
|
||||
await fsp.rename(tmpPath, filePath)
|
||||
}
|
||||
|
||||
export function worktreeMapExists(repoRoot: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(getMapPath(repoRoot))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
2000
packages/tauri-app/Cargo.lock
generated
2000
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "tauri build"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
const { pathToFileURL } = require("url")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
async function ensureMonacoAssets() {
|
||||
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||
const helperUrl = pathToFileURL(helperPath).href
|
||||
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||
warn: (msg) => console.warn(`[dev-prep] ${msg}`),
|
||||
sourceRoots: [
|
||||
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
@@ -42,5 +57,11 @@ function copyUiLoadingAssets() {
|
||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
;(async () => {
|
||||
await ensureMonacoAssets()
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
})().catch((err) => {
|
||||
console.error("[dev-prep] failed:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
const { pathToFileURL } = require("url")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
@@ -19,6 +20,7 @@ const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
@@ -37,14 +39,24 @@ const braceExpansionPath = path.join(
|
||||
|
||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||
|
||||
async function ensureMonacoAssets() {
|
||||
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||
const helperUrl = pathToFileURL(helperPath).href
|
||||
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||
copyMonacoPublicAssets({
|
||||
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||
warn: (msg) => console.warn(`[prebuild] ${msg}`),
|
||||
sourceRoots: [
|
||||
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
@@ -76,6 +88,15 @@ function ensureUiBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncServerUiBundle() {
|
||||
console.log("[prebuild] syncing server public UI bundle...")
|
||||
execSync(serverPrepareUiCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
@@ -223,12 +244,19 @@ function copyUiLoadingAssets() {
|
||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
;(async () => {
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
await ensureMonacoAssets()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
})().catch((err) => {
|
||||
console.error("[prebuild] failed:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const packageJsonPath = path.join(root, "package.json")
|
||||
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
|
||||
const cargoLockPath = path.join(root, "Cargo.lock")
|
||||
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
|
||||
|
||||
function readPackageVersion() {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error("Missing version in packages/tauri-app/package.json")
|
||||
}
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
function syncCargoToml(version) {
|
||||
const current = fs.readFileSync(cargoTomlPath, "utf8")
|
||||
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoTomlPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncCargoLock(version) {
|
||||
if (!fs.existsSync(cargoLockPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = fs.readFileSync(cargoLockPath, "utf8")
|
||||
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoLockPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncTauriConfig(version) {
|
||||
const current = fs.readFileSync(tauriConfigPath, "utf8")
|
||||
const config = JSON.parse(current)
|
||||
if (config.version === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
config.version = version
|
||||
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
return true
|
||||
}
|
||||
|
||||
function main() {
|
||||
const version = readPackageVersion()
|
||||
const changed = []
|
||||
|
||||
if (syncCargoToml(version)) {
|
||||
changed.push(path.relative(root, cargoTomlPath))
|
||||
}
|
||||
|
||||
if (syncCargoLock(version)) {
|
||||
changed.push(path.relative(root, cargoLockPath))
|
||||
}
|
||||
|
||||
if (syncTauriConfig(version)) {
|
||||
changed.push(path.relative(root, tauriConfigPath))
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
console.log(`[sync-tauri-version] already aligned to ${version}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[sync-tauri-version] failed: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.13.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
@@ -10,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] }
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
@@ -17,7 +19,13 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
url = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
|
||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,7 +3,7 @@
|
||||
"identifier": "main-window-native-dialogs",
|
||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||
"remote": {
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||
"urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
@@ -11,6 +11,11 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"opener:allow-open-url",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-show",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2378,6 +2378,270 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||
"type": "string",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user