Compare commits
420 Commits
v0.5.1
...
ready/ui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f59e66065 | ||
|
|
51ac7f152d | ||
|
|
df74c06ba2 | ||
|
|
5f144ca24d | ||
|
|
de66b1349a | ||
|
|
3d888fee64 | ||
|
|
1abcc8ee3c | ||
|
|
d0d5c309e6 | ||
|
|
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 | ||
|
|
7c3f808d69 | ||
|
|
a59e929b12 | ||
|
|
8ff4019839 | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 | ||
|
|
6a4e548d2c | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 | ||
|
|
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 | ||
|
|
8a91e04ff9 | ||
|
|
76b1134c95 | ||
|
|
d98d519fd3 | ||
|
|
02407e0f7a | ||
|
|
0261154a5e | ||
|
|
d2b68159be | ||
|
|
aab0692403 | ||
|
|
17a3e43ac7 | ||
|
|
a2127a11ac | ||
|
|
ea4c687125 | ||
|
|
de20b3adf3 | ||
|
|
929e79befd | ||
|
|
3522d3dff5 | ||
|
|
1af01680ee | ||
|
|
67f5f830a3 | ||
|
|
81102cc6bf | ||
|
|
afa7243eab | ||
|
|
37b7c1e53c | ||
|
|
ba61ab79e2 | ||
|
|
37d075fbb3 | ||
|
|
2961d41be3 | ||
|
|
1bb5aedfdb | ||
|
|
0a793fb1c6 | ||
|
|
a401eeec11 | ||
|
|
d9bcc66930 | ||
|
|
01921e3454 | ||
|
|
158f6e25cf | ||
|
|
562c4b2637 | ||
|
|
51fd5d87f7 | ||
|
|
28fb56bfa1 | ||
|
|
c1052b36dc | ||
|
|
c62c9b1c78 | ||
|
|
feccbd13bd | ||
|
|
5b1e21345f | ||
|
|
33939f4096 | ||
|
|
96f5a0ab44 | ||
|
|
d9f7735c94 | ||
|
|
4aae8ab720 | ||
|
|
b83c69f002 | ||
|
|
c74e0b89f7 | ||
|
|
9ee7ff9509 | ||
|
|
74a21d6418 | ||
|
|
15f390ade7 | ||
|
|
bb4e3815d1 | ||
|
|
8fa0175b98 | ||
|
|
ee59622b98 | ||
|
|
a1452ad353 | ||
|
|
0c9284e57e | ||
|
|
0766185ff6 | ||
|
|
effb30d98e | ||
|
|
4da69b5a20 | ||
|
|
3d3337c7b8 | ||
|
|
f0b43dbc68 | ||
|
|
b0eb9aec64 | ||
|
|
8c48455ae5 | ||
|
|
292f695395 | ||
|
|
4ea710c735 | ||
|
|
f5d4cb6917 | ||
|
|
1e53e06424 | ||
|
|
2530cd4fc8 | ||
|
|
b25fb0073e | ||
|
|
c01846f7fd | ||
|
|
dfd397803f | ||
|
|
267f1592c4 | ||
|
|
668ac7fa88 | ||
|
|
43a476e967 | ||
|
|
adbfab5c25 | ||
|
|
02f1284f7f | ||
|
|
a014ce555a | ||
|
|
db3c13c463 | ||
|
|
7c0bf382ba | ||
|
|
6e9c5a88b4 | ||
|
|
0bf22a323f | ||
|
|
cc997576cf | ||
|
|
05f193df7b | ||
|
|
c9b5bb1b7a | ||
|
|
ba1013cd35 | ||
|
|
ec6428702b | ||
|
|
e08ebb2057 | ||
|
|
9683f90f7e | ||
|
|
06cb986aa6 | ||
|
|
a85c2f1700 | ||
|
|
bd2a0d1bec | ||
|
|
df9722cd16 | ||
|
|
dffa4907ec | ||
|
|
e567d35438 | ||
|
|
62f52fc534 | ||
|
|
69f221942c | ||
|
|
7749225f71 | ||
|
|
ae322c53cc | ||
|
|
37da426ab4 | ||
|
|
591f55bef9 | ||
|
|
aabaadbe1d | ||
|
|
3ab14e8de6 | ||
|
|
40634138bc | ||
|
|
b17087b610 | ||
|
|
71f58e7c5f | ||
|
|
927e4e1281 | ||
|
|
2e56a5e9f4 | ||
|
|
296d07a0d6 | ||
|
|
0d8a844af8 | ||
|
|
bf9cef4cd5 | ||
|
|
9dde33aba7 | ||
|
|
0fefff3b0a | ||
|
|
1122c19648 | ||
|
|
f06359a1fc | ||
|
|
72f420b6f6 | ||
|
|
147c9e3e4b | ||
|
|
ab38cdccac | ||
|
|
8168d52295 | ||
|
|
1081bfb276 | ||
|
|
38064b229c | ||
|
|
1a7aefcbae | ||
|
|
e50d9f461a | ||
|
|
d76cf8a3f7 | ||
|
|
c7370fe7bc | ||
|
|
3dfbe2a5b2 | ||
|
|
e30c8b0253 | ||
|
|
df9fc529f9 | ||
|
|
2e9f5b916c | ||
|
|
fd464f349a | ||
|
|
ff6d6f4f76 | ||
|
|
cb2966fb08 | ||
|
|
888e365d72 | ||
|
|
e9241a1b93 | ||
|
|
f01a06d85b | ||
|
|
a68285da68 | ||
|
|
31940f972f | ||
|
|
b4663fb250 |
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:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
description: "Version to apply to workspace packages (release builds)"
|
description: "Version to apply to workspace packages (release builds)"
|
||||||
required: false
|
required: false
|
||||||
@@ -23,6 +28,21 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
|
upload_actions_artifacts:
|
||||||
|
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
actions_artifacts_retention_days:
|
||||||
|
description: "Retention (days) for GitHub Actions artifacts"
|
||||||
|
required: false
|
||||||
|
default: 7
|
||||||
|
type: number
|
||||||
|
actions_artifacts_name_prefix:
|
||||||
|
description: "Optional prefix for Actions artifact names"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
set_versions:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -45,6 +65,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -54,7 +76,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Set workspace versions
|
- name: Set workspace versions
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
shell: bash
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: 5
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
@@ -65,6 +101,112 @@ jobs:
|
|||||||
- name: Build macOS binaries (Electron)
|
- name: Build macOS binaries (Electron)
|
||||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
release_root="packages/electron-app/release"
|
||||||
|
apps=()
|
||||||
|
while IFS= read -r -d '' app; do
|
||||||
|
apps+=("$app")
|
||||||
|
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||||
|
|
||||||
|
if [ "${#apps[@]}" -eq 0 ]; then
|
||||||
|
echo "No CodeNomad.app found under $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub macOS runners typically have no signing identity. Without any signature,
|
||||||
|
# the shipped .app can fail Gatekeeper with:
|
||||||
|
# code has no resources but signature indicates they must be present
|
||||||
|
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
||||||
|
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
||||||
|
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
||||||
|
for app in "${apps[@]}"; do
|
||||||
|
echo "codesign (adhoc): $app"
|
||||||
|
codesign --force --deep --sign - "$app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Repackage Electron macOS zips (ditto)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prefer the workflow-provided version; fall back to package.json.
|
||||||
|
VERSION_TO_USE="${VERSION:-}"
|
||||||
|
if [ -z "$VERSION_TO_USE" ]; then
|
||||||
|
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_root="packages/electron-app/release"
|
||||||
|
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
||||||
|
# Use find to locate built app bundles instead of ** globs.
|
||||||
|
apps=()
|
||||||
|
while IFS= read -r -d '' app; do
|
||||||
|
apps+=("$app")
|
||||||
|
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||||
|
if [ "${#apps[@]}" -eq 0 ]; then
|
||||||
|
echo "No CodeNomad.app found under $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for app in "${apps[@]}"; do
|
||||||
|
bundle_dir=$(basename "$(dirname "$app")")
|
||||||
|
arch="x64"
|
||||||
|
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
||||||
|
arch="arm64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
||||||
|
rm -f "$out_zip"
|
||||||
|
echo "ditto -ck: $app -> $out_zip"
|
||||||
|
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Validate Electron macOS codesign (unzipped)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
||||||
|
if [ "${#zips[@]}" -eq 0 ]; then
|
||||||
|
echo "No Electron macOS zip artifacts found to validate" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for zip in "${zips[@]}"; do
|
||||||
|
echo "Validating codesign for: $zip"
|
||||||
|
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
||||||
|
mkdir -p "$extract_dir"
|
||||||
|
|
||||||
|
# Use ditto for extraction as well to preserve bundle metadata.
|
||||||
|
ditto -x -k "$zip" "$extract_dir"
|
||||||
|
|
||||||
|
app_path=""
|
||||||
|
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
||||||
|
if [ -d "$candidate" ]; then
|
||||||
|
app_path="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$app_path" ]; then
|
||||||
|
echo "No .app found after extracting $zip" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -76,6 +218,15 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron macOS)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -85,6 +236,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -115,6 +268,15 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Windows)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -124,6 +286,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -155,6 +319,15 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Linux)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-tauri-macos:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -164,6 +337,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -206,7 +381,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -217,6 +392,15 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri macOS)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -237,6 +421,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -279,7 +465,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -290,6 +476,15 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -310,6 +505,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -355,7 +552,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -368,6 +565,15 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri Windows)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -388,6 +594,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -443,7 +651,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -469,6 +677,15 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri Linux)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||||
|
path: packages/tauri-app/release-tauri/*
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -490,6 +707,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -587,6 +806,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -623,3 +844,12 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||||
|
path: packages/electron-app/release/*.rpm
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
121
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
121
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
name: Comment PR Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- 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 }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
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" == *",${ACTOR},"* ]]; 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:
|
on:
|
||||||
push:
|
schedule:
|
||||||
branches:
|
# Nightly build of dev (only if dev has new commits)
|
||||||
- dev
|
- cron: "0 1 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
actions: read
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-prerelease
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dev-ci:
|
gate:
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
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:
|
with:
|
||||||
upload: false
|
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||||
set_versions: false
|
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||||
|
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||||
|
dist_tag: latest
|
||||||
|
prerelease: true
|
||||||
|
release_ui: false
|
||||||
secrets: inherit
|
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
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
type: string
|
||||||
|
package_name:
|
||||||
|
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||||
|
required: false
|
||||||
|
default: "@neuralnomads/codenomad"
|
||||||
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -21,6 +30,13 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: dev
|
default: dev
|
||||||
|
package_name:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "@neuralnomads/codenomad"
|
||||||
|
secrets:
|
||||||
|
NPM_TOKEN:
|
||||||
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -34,6 +50,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -51,7 +69,7 @@ jobs:
|
|||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
- name: Build server package (includes UI bundling)
|
- 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
|
- name: Set publish metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -62,13 +80,31 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$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
|
- name: Bump package version for publish
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
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
|
- name: Publish server package with provenance
|
||||||
env:
|
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_PROVENANCE: true
|
||||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||||
|
shell: bash
|
||||||
run: |
|
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
|
||||||
|
|||||||
57
.github/workflows/pr-build.yml
vendored
Normal file
57
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: PR Build Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- 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 }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
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" == *",${ACTOR},"* ]]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping builds for unauthorized PR 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
|
||||||
55
.github/workflows/release-ui.yml
vendored
Normal file
55
.github/workflows/release-ui.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Release UI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-ui:
|
||||||
|
# Automated via reusable call (main releases); manual runs allowed on dev/main.
|
||||||
|
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Install Cloudflare worker deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: packages/cloudflare
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: npm run build --workspace @codenomad/ui
|
||||||
|
|
||||||
|
- name: Publish UI zip + update manifest
|
||||||
|
working-directory: packages/cloudflare
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
|
||||||
|
run: npm run release:ui
|
||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
|||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
|
npm_package_name: "@neuralnomads/codenomad"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Restrict Non-Dev PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- 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 }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
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" == *",${ACTOR},"* ]]; 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 "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||||
|
exit 1
|
||||||
42
.github/workflows/reusable-release.yml
vendored
42
.github/workflows/reusable-release.yml
vendored
@@ -3,6 +3,11 @@ name: Reusable Release
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Suffix appended to package.json version"
|
description: "Suffix appended to package.json version"
|
||||||
required: false
|
required: false
|
||||||
@@ -13,6 +18,21 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
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:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -31,6 +51,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -53,28 +75,46 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ steps.versions.outputs.tag }}
|
TAG: ${{ steps.versions.outputs.tag }}
|
||||||
|
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||||
run: |
|
run: |
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG already exists"
|
echo "Release $TAG already exists"
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
build-and-upload:
|
build-and-upload:
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
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:
|
publish-server:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- build-and-upload
|
- build-and-upload
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
dist_tag: ${{ inputs.dist_tag }}
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
|
package_name: ${{ inputs.npm_package_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,4 +7,9 @@ release/
|
|||||||
.electron-vite/
|
.electron-vite/
|
||||||
out/
|
out/
|
||||||
.dir-locals.el
|
.dir-locals.el
|
||||||
.opencode/bashOutputs/
|
.opencode/bashOutputs/
|
||||||
|
|
||||||
|
# Local runtime artifacts
|
||||||
|
.codenomad/
|
||||||
|
.tmp/
|
||||||
|
packages/cloudflare/.wrangler/
|
||||||
7
.opencode/commands/release-notes.md
Normal file
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: Creates release notes
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
|
||||||
|
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||||
|
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||||
29
AGENTS.md
29
AGENTS.md
@@ -15,6 +15,35 @@
|
|||||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
- 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.
|
- 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
|
## Tooling Preferences
|
||||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
- 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.
|
- 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.
|
||||||
40
README.md
40
README.md
@@ -44,13 +44,21 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
For dev version
|
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||||
|
- [packages/server/README.md](packages/server/README.md)
|
||||||
|
|
||||||
|
To see all available options:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad@dev --launch
|
npx @neuralnomads/codenomad --help
|
||||||
```
|
```
|
||||||
|
|
||||||
This command starts the server and opens the web client in your default browser.
|
### 🧪 Dev Releases
|
||||||
|
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
|
```
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -76,6 +84,29 @@ 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.
|
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
|
## 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 split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||||
@@ -92,3 +123,6 @@ To build the Desktop App from source:
|
|||||||
1. Clone the repo.
|
1. Clone the repo.
|
||||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||||
|
|
||||||
|
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||||
|
|
||||||
|
|||||||
4790
package-lock.json
generated
4790
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
|
"license": "MIT",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/server",
|
"packages/server",
|
||||||
@@ -30,4 +31,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
packages/cloudflare/.gitignore
vendored
Normal file
1
packages/cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
1515
packages/cloudflare/package-lock.json
generated
Normal file
1515
packages/cloudflare/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
packages/cloudflare/package.json
Normal file
15
packages/cloudflare/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@codenomad/ui-host-worker",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||||
|
"release:ui": "node ./scripts/release-ui.mjs",
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/cloudflare/release-config.json
Normal file
4
packages/cloudflare/release-config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"minServerVersion": "0.12.3",
|
||||||
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
|
}
|
||||||
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { createHash } from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const repoRoot = path.resolve(root, "..", "..")
|
||||||
|
|
||||||
|
const releaseConfigPath = path.join(root, "release-config.json")
|
||||||
|
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||||
|
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
|
||||||
|
|
||||||
|
const distDir = path.join(root, "dist")
|
||||||
|
const manifestPath = path.join(distDir, "version.json")
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2))
|
||||||
|
|
||||||
|
function getArgValue(flag) {
|
||||||
|
const idx = process.argv.indexOf(flag)
|
||||||
|
if (idx === -1) return null
|
||||||
|
return process.argv[idx + 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipPath = getArgValue("--zip")
|
||||||
|
|
||||||
|
if (!zipPath) {
|
||||||
|
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
|
||||||
|
if (!fs.existsSync(resolvedZipPath)) {
|
||||||
|
console.error(`Zip not found: ${resolvedZipPath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||||
|
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
|
||||||
|
|
||||||
|
const bucket = process.env.CODENOMAD_R2_BUCKET
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiVersion = uiPackageJson.version
|
||||||
|
const serverVersion = serverPackageJson.version
|
||||||
|
|
||||||
|
if (!uiVersion || !serverVersion) {
|
||||||
|
console.error("Missing version fields in package.json")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
|
||||||
|
|
||||||
|
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
minServerVersion: releaseConfig.minServerVersion,
|
||||||
|
latestUIVersion: uiVersion,
|
||||||
|
uiPackageURL,
|
||||||
|
sha256,
|
||||||
|
latestServerVersion: serverVersion,
|
||||||
|
latestServerUrl: releaseConfig.latestServerUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true })
|
||||||
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
|
const headersPath = path.join(distDir, "_headers")
|
||||||
|
fs.writeFileSync(
|
||||||
|
headersPath,
|
||||||
|
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Wrote ${manifestPath}`)
|
||||||
|
console.log(`Wrote ${headersPath}`)
|
||||||
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { execFileSync } from "child_process"
|
||||||
|
import fs from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const repoRoot = path.resolve(root, "..", "..")
|
||||||
|
|
||||||
|
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
|
||||||
|
|
||||||
|
if (!r2Bucket) {
|
||||||
|
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||||
|
const uiVersion = uiPackageJson.version
|
||||||
|
|
||||||
|
if (!uiVersion) {
|
||||||
|
console.error("Missing packages/ui/package.json version")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
|
||||||
|
if (!fs.existsSync(uiBuildDir)) {
|
||||||
|
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
|
||||||
|
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
|
||||||
|
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
|
||||||
|
|
||||||
|
// Upload to R2.
|
||||||
|
const objectKey = `ui/ui-${uiVersion}.zip`
|
||||||
|
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
"npx",
|
||||||
|
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
|
||||||
|
{ cwd: root, stdio: "inherit" },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate version.json into packages/cloudflare/dist
|
||||||
|
console.log("[release-ui] Generating version.json")
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
|
||||||
|
{
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CODENOMAD_R2_BUCKET: r2Bucket,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("[release-ui] Deploying worker")
|
||||||
|
execFileSync("npx", ["wrangler", "deploy"], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("[release-ui] Done")
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
9
packages/cloudflare/src/index.ts
Normal file
9
packages/cloudflare/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Env {
|
||||||
|
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
return env.ASSETS.fetch(request)
|
||||||
|
},
|
||||||
|
}
|
||||||
14
packages/cloudflare/wrangler.toml
Normal file
14
packages/cloudflare/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "codenomad-ui-host"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-01-22"
|
||||||
|
|
||||||
|
# Custom domain for the manifest host.
|
||||||
|
# Note: Custom domains apply to all paths on the hostname.
|
||||||
|
[[routes]]
|
||||||
|
pattern = "ui.codenomad.neuralnomads.ai"
|
||||||
|
custom_domain = true
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./dist"
|
||||||
|
binding = "ASSETS"
|
||||||
|
not_found_handling = "404-page"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
|
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
|
||||||
|
|
||||||
const uiRoot = resolve(__dirname, "../ui")
|
const uiRoot = resolve(__dirname, "../ui")
|
||||||
const uiSrc = resolve(uiRoot, "src")
|
const uiSrc = resolve(uiRoot, "src")
|
||||||
@@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
|||||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.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({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
@@ -40,7 +67,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
root: uiRendererRoot,
|
root: uiRendererRoot,
|
||||||
plugins: [solid()],
|
plugins: [solid(), prepareMonacoPublicAssets()],
|
||||||
css: {
|
css: {
|
||||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
|
import fs from "fs"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
|
let wakeLockId: number | null = null
|
||||||
|
|
||||||
interface DialogOpenRequest {
|
interface DialogOpenRequest {
|
||||||
mode: "directory" | "file"
|
mode: "directory" | "file"
|
||||||
title?: string
|
title?: string
|
||||||
@@ -62,4 +65,68 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
|
|
||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||||
|
if (!Array.isArray(paths)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = paths.filter((value): value is string => {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.statSync(value).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return directories
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
|
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(
|
||||||
|
"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,4 +1,6 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
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 } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
|
|||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
let pendingCliUrl: string | null = null
|
let pendingCliUrl: string | null = null
|
||||||
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
|
||||||
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
|
|||||||
loadLoadingScreen(mainWindow)
|
loadLoadingScreen(mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBootstrapTokenUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startCliPreload(url: string) {
|
function startCliPreload(url: string) {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
pendingCliUrl = url
|
pendingCliUrl = url
|
||||||
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
|
|||||||
showLoadingScreen(true)
|
showLoadingScreen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
|
||||||
|
// consuming the token in the hidden view and then failing in the main window.
|
||||||
|
if (isBootstrapTokenUrl(url)) {
|
||||||
|
finalizeCliSwap(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const view = new BrowserView({
|
const view = new BrowserView({
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -308,10 +327,83 @@ function finalizeCliSwap(url: string) {
|
|||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||||
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
|
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const first = raw.split(";")[0] ?? ""
|
||||||
|
const index = first.indexOf("=")
|
||||||
|
if (index < 0) return null
|
||||||
|
|
||||||
|
const key = first.slice(0, index).trim()
|
||||||
|
const value = first.slice(index + 1).trim()
|
||||||
|
if (key !== name || !value) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
|
const transport = target.protocol === "https:" ? https : http
|
||||||
|
|
||||||
|
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
|
||||||
|
const req = transport.request(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume()
|
||||||
|
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
req.on("error", reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.statusCode !== 200) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||||
|
if (!sessionId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.defaultSession.cookies.set({
|
||||||
|
url: baseUrl,
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
value: sessionId,
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function startCli() {
|
async function startCli() {
|
||||||
try {
|
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, ")")
|
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||||
await cliManager.start({ dev: devMode })
|
await cliManager.start({ dev: devMode })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -323,11 +415,53 @@ async function startCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function maybeExchangeAndNavigate(baseUrl: string) {
|
||||||
|
if (bootstrapExchangeInFlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = pendingBootstrapToken
|
||||||
|
if (!token) {
|
||||||
|
startCliPreload(baseUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapExchangeInFlight = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await exchangeBootstrapToken(baseUrl, token)
|
||||||
|
pendingBootstrapToken = null
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
startCliPreload(`${baseUrl}/login`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startCliPreload(baseUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[cli] bootstrap token exchange failed:", error)
|
||||||
|
pendingBootstrapToken = null
|
||||||
|
startCliPreload(`${baseUrl}/login`)
|
||||||
|
} finally {
|
||||||
|
bootstrapExchangeInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cliManager.on("bootstrapToken", (token) => {
|
||||||
|
pendingBootstrapToken = token
|
||||||
|
|
||||||
|
const status = cliManager.getStatus()
|
||||||
|
if (status.url) {
|
||||||
|
void maybeExchangeAndNavigate(status.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
cliManager.on("ready", (status) => {
|
cliManager.on("ready", (status) => {
|
||||||
if (!status.url) {
|
if (!status.url) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startCliPreload(status.url)
|
|
||||||
|
void maybeExchangeAndNavigate(status.url)
|
||||||
})
|
})
|
||||||
|
|
||||||
cliManager.on("status", (status) => {
|
cliManager.on("status", (status) => {
|
||||||
@@ -343,6 +477,14 @@ if (isMac) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
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()
|
startCli()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
@@ -375,7 +517,6 @@ app.on("before-quit", async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
// CodeNomad supports a single window; closing it should quit the app on all platforms.
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { spawn, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -38,6 +40,36 @@ interface CliEntryResolution {
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
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 {
|
function resolveConfigPath(configPath?: string): string {
|
||||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||||
if (target.startsWith("~/")) {
|
if (target.startsWith("~/")) {
|
||||||
@@ -52,11 +84,20 @@ function resolveHostForMode(mode: ListeningMode): string {
|
|||||||
|
|
||||||
function readListeningModeFromConfig(): ListeningMode {
|
function readListeningModeFromConfig(): ListeningMode {
|
||||||
try {
|
try {
|
||||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
||||||
if (!existsSync(configPath)) return "local"
|
|
||||||
const content = readFileSync(configPath, "utf-8")
|
let parsed: any = null
|
||||||
const parsed = JSON.parse(content)
|
if (existsSync(configYamlPath)) {
|
||||||
const mode = parsed?.preferences?.listeningMode
|
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") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
@@ -69,6 +110,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
export declare interface CliProcessManager {
|
export declare interface CliProcessManager {
|
||||||
on(event: "status", listener: (status: CliStatus) => void): this
|
on(event: "status", listener: (status: CliStatus) => void): this
|
||||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||||
|
on(event: "bootstrapToken", listener: (token: string) => void): this
|
||||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||||
on(event: "error", listener: (error: Error) => void): this
|
on(event: "error", listener: (error: Error) => void): this
|
||||||
@@ -79,6 +121,8 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
|
private bootstrapToken: string | null = null
|
||||||
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
@@ -87,6 +131,8 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
|
this.bootstrapToken = null
|
||||||
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
@@ -105,11 +151,13 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env,
|
env,
|
||||||
shell: false,
|
shell: false,
|
||||||
|
detached,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
@@ -171,10 +219,90 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.requestedStop = true
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlreadyExited = () => child.exitCode !== null || child.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) => {
|
return new Promise((resolve) => {
|
||||||
const killTimeout = setTimeout(() => {
|
const killTimeout = setTimeout(() => {
|
||||||
child.kill("SIGKILL")
|
console.warn(
|
||||||
}, 4000)
|
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||||
|
)
|
||||||
|
sendStopSignal("SIGKILL")
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
child.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
@@ -184,7 +312,15 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
child.kill("SIGTERM")
|
if (isAlreadyExited()) {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStopSignal("SIGTERM")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +334,16 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
this.child.kill("SIGKILL")
|
const pid = this.child.pid
|
||||||
|
if (pid && process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGKILL")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.child.kill("SIGKILL")
|
||||||
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||||
@@ -227,42 +372,42 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
const trimmed = line.trim()
|
||||||
console.info(`[cli][${stream}] ${line}`)
|
if (!trimmed) continue
|
||||||
this.emit("log", { stream, message: line })
|
|
||||||
|
|
||||||
const port = this.extractPort(line)
|
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||||
if (port && this.status.state === "starting") {
|
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||||
const url = `http://127.0.0.1:${port}`
|
if (token && !this.bootstrapToken) {
|
||||||
console.info(`[cli] ready on ${url}`)
|
this.bootstrapToken = token
|
||||||
this.updateStatus({ state: "ready", port, url })
|
this.emit("bootstrapToken", token)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[cli][${stream}] ${trimmed}`)
|
||||||
|
this.emit("log", { stream, message: trimmed })
|
||||||
|
|
||||||
|
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)
|
this.emit("ready", this.status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractPort(line: string): number | null {
|
private extractLocalUrl(line: string): string | null {
|
||||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
||||||
if (readyMatch) {
|
if (!match) {
|
||||||
return parseInt(readyMatch[1], 10)
|
return null
|
||||||
}
|
}
|
||||||
|
return match[1] ?? 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(patch: Partial<CliStatus>) {
|
private updateStatus(patch: Partial<CliStatus>) {
|
||||||
@@ -271,10 +416,24 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--port", "0"]
|
const args = ["serve", "--host", host, "--generate-token"]
|
||||||
|
|
||||||
if (options.dev) {
|
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
|
return args
|
||||||
@@ -361,4 +520,3 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
@@ -12,6 +12,16 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||||
|
getPathForFile: (file) => {
|
||||||
|
try {
|
||||||
|
return webUtils.getPathForFile(file)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
"email": "codenomad@neuralnomads.ai"
|
"email": "codenomad@neuralnomads.ai"
|
||||||
@@ -14,7 +15,10 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "npm run dev:info",
|
||||||
|
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
|
||||||
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
@@ -35,11 +39,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neuralnomads/codenomad": "file:../server",
|
"@neuralnomads/codenomad": "file:../server",
|
||||||
"@codenomad/ui": "file:../ui"
|
"@codenomad/ui": "file:../ui",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"name": "@codenomad/opencode-config",
|
"name": "@codenomad/opencode-config",
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.1"
|
"@opencode-ai/plugin": "1.2.24"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { tool } from "@opencode-ai/plugin/tool"
|
import { tool } from "@opencode-ai/plugin/tool"
|
||||||
|
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
|
||||||
|
|
||||||
type BackgroundProcess = {
|
type BackgroundProcess = {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,11 +13,6 @@ type BackgroundProcess = {
|
|||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodeNomadConfig = {
|
|
||||||
instanceId: string
|
|
||||||
baseUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackgroundProcessOptions = {
|
type BackgroundProcessOptions = {
|
||||||
baseDir: string
|
baseDir: string
|
||||||
}
|
}
|
||||||
@@ -27,30 +23,10 @@ type ParsedCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||||
|
const requester = createCodeNomadRequester(config)
|
||||||
|
|
||||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
|
return requester.requestJson<T>(`/background-processes${path}`, init)
|
||||||
const base = config.baseUrl.replace(/\/+$/, "")
|
|
||||||
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
|
|
||||||
const headers = normalizeHeaders(init?.headers)
|
|
||||||
if (init?.body !== undefined) {
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = await response.text()
|
|
||||||
throw new Error(message || `Request failed with ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as T
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await response.json()) as T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
|
|||||||
|
|
||||||
if (char === "|" || char === "&" || char === ";") {
|
if (char === "|" || char === "&" || char === ";") {
|
||||||
flush()
|
flush()
|
||||||
const next = input[index + 1]
|
tokens.push(char)
|
||||||
if ((char === "|" || char === "&") && next === char) {
|
|
||||||
tokens.push(char + next)
|
|
||||||
index += 1
|
|
||||||
} else {
|
|
||||||
tokens.push(char)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
|
|||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSeparator(token: string) {
|
function isSeparator(token: string): boolean {
|
||||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
return token === "|" || token === "&" || token === ";"
|
||||||
}
|
}
|
||||||
|
|
||||||
function unquote(value: string) {
|
function unquote(token: string): string {
|
||||||
if (value.length >= 2) {
|
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||||
const first = value[0]
|
return token.slice(1, -1)
|
||||||
const last = value[value.length - 1]
|
|
||||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
||||||
return value.slice(1, -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return value
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWithinBase(baseDir: string, target: string) {
|
function isWithinBase(base: string, candidate: string): boolean {
|
||||||
const relative = path.relative(baseDir, target)
|
const relative = path.relative(base, candidate)
|
||||||
if (!relative) return true
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||||
return !relative.startsWith("..") && !path.isAbsolute(relative)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
||||||
const output: Record<string, string> = {}
|
|
||||||
if (!headers) return output
|
|
||||||
|
|
||||||
if (headers instanceof Headers) {
|
|
||||||
headers.forEach((value, key) => {
|
|
||||||
output[key] = value
|
|
||||||
})
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(headers)) {
|
|
||||||
for (const [key, value] of headers) {
|
|
||||||
output[key] = value
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...headers }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,41 @@
|
|||||||
export type PluginEvent = {
|
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||||
type: string
|
|
||||||
properties?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodeNomadConfig = {
|
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||||
instanceId: string
|
|
||||||
baseUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
|
||||||
return {
|
|
||||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
|
||||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||||
return {
|
const requester = createCodeNomadRequester(config)
|
||||||
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
|
||||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
return {
|
||||||
const value = process.env[key]
|
postEvent: (event: PluginEvent) =>
|
||||||
if (!value || !value.trim()) {
|
requester.requestVoid("/event", {
|
||||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
method: "POST",
|
||||||
|
body: JSON.stringify(event),
|
||||||
|
}),
|
||||||
|
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
async function startPluginEvents(
|
||||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||||
const response = await fetch(url, {
|
onEvent: (event: PluginEvent) => void,
|
||||||
method: "POST",
|
) {
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(event),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
|
||||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
|
||||||
|
|
||||||
// Fail plugin startup if we cannot establish the initial connection.
|
// Fail plugin startup if we cannot establish the initial connection.
|
||||||
const initialBody = await connectWithRetries(url, 3)
|
const initialBody = await connectWithRetries(requester, 3)
|
||||||
|
|
||||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||||
void consumeWithReconnect(url, onEvent, initialBody)
|
void consumeWithReconnect(requester, onEvent, initialBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
|
||||||
let lastError: unknown
|
let lastError: unknown
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
return await requester.requestSseBody("/events")
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
|
||||||
}
|
|
||||||
return response.body
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error
|
||||||
await delay(500 * attempt)
|
await delay(500 * attempt)
|
||||||
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
const url = requester.buildUrl("/events")
|
||||||
|
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function consumeWithReconnect(
|
async function consumeWithReconnect(
|
||||||
url: string,
|
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||||
onEvent: (event: PluginEvent) => void,
|
onEvent: (event: PluginEvent) => void,
|
||||||
initialBody: ReadableStream<Uint8Array>,
|
initialBody: ReadableStream<Uint8Array>,
|
||||||
) {
|
) {
|
||||||
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
if (!body) {
|
if (!body) {
|
||||||
body = await connectWithRetries(url, 3)
|
body = await connectWithRetries(requester, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
await consumeSseBody(body, onEvent)
|
await consumeSseBody(body, onEvent)
|
||||||
|
|||||||
214
packages/opencode-config/plugin/lib/request.ts
Normal file
214
packages/opencode-config/plugin/lib/request.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import http from "http"
|
||||||
|
import https from "https"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
|
||||||
|
export type PluginEvent = {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeNomadConfig = {
|
||||||
|
instanceId: string
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||||
|
return {
|
||||||
|
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||||
|
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||||
|
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||||
|
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||||
|
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||||
|
const authorization = buildInstanceAuthorizationHeader()
|
||||||
|
|
||||||
|
const buildUrl = (path: string) => {
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||||
|
return `${pluginBase}${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
||||||
|
const output: Record<string, string> = normalizeHeaders(headers)
|
||||||
|
output.Authorization = authorization
|
||||||
|
if (hasBody) {
|
||||||
|
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = buildUrl(path)
|
||||||
|
const hasBody = init?.body !== undefined
|
||||||
|
const headers = buildHeaders(init?.headers, hasBody)
|
||||||
|
|
||||||
|
// 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> => {
|
||||||
|
const response = await fetchWithAuth(path, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
||||||
|
const response = await fetchWithAuth(path, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`SSE unavailable (${response.status})`)
|
||||||
|
}
|
||||||
|
return response.body as ReadableStream<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl,
|
||||||
|
fetch: fetchWithAuth,
|
||||||
|
requestJson,
|
||||||
|
requestVoid,
|
||||||
|
requestSseBody,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstanceAuthorizationHeader(): string {
|
||||||
|
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
||||||
|
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||||
|
const output: Record<string, string> = {}
|
||||||
|
if (!headers) return output
|
||||||
|
|
||||||
|
if (headers instanceof Headers) {
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
output[key] = value
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(headers)) {
|
||||||
|
for (const [key, value] of headers) {
|
||||||
|
output[key] = value
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...headers }
|
||||||
|
}
|
||||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
public/
|
public/
|
||||||
|
|
||||||
|
# Local developer config (may contain secrets)
|
||||||
|
config-*.json
|
||||||
|
|||||||
@@ -5,18 +5,21 @@
|
|||||||
## Features & Capabilities
|
## Features & Capabilities
|
||||||
|
|
||||||
### 🌍 Deployment Freedom
|
### 🌍 Deployment Freedom
|
||||||
|
|
||||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||||
|
|
||||||
### ⚡️ Workspace Power
|
### ⚡️ Workspace Power
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||||
- Node.js 18+ and npm (for running or building from source).
|
- Node.js 18+ and npm (for running or building from source).
|
||||||
- A workspace folder on disk you want to serve.
|
- A workspace folder on disk you want to serve.
|
||||||
@@ -25,13 +28,26 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Run via npx (Recommended)
|
### Run via npx (Recommended)
|
||||||
|
|
||||||
You can run CodeNomad directly without installing it:
|
You can run CodeNomad directly without installing it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npx @neuralnomads/codenomad --launch
|
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
|
### Install Globally
|
||||||
|
|
||||||
Or install it globally to use the `codenomad` command:
|
Or install it globally to use the `codenomad` command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -39,20 +55,119 @@ npm install -g @neuralnomads/codenomad
|
|||||||
codenomad --launch
|
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
|
### Common Flags
|
||||||
|
|
||||||
You can configure the server using flags or environment variables:
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
| Flag | Env Variable | Description |
|
| Flag | Env Variable | Description |
|
||||||
|------|--------------|-------------|
|
|------|--------------|-------------|
|
||||||
| `--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) |
|
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
||||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
| `--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
|
### Data Storage
|
||||||
|
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||||
|
|
||||||
|
|||||||
774
packages/server/package-lock.json
generated
774
packages/server/package-lock.json
generated
@@ -1,20 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"codenomad": "dist/bin.js"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
@@ -475,6 +485,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/accept-negotiator": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/ajv-compiler": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||||
@@ -486,6 +505,15 @@
|
|||||||
"fast-uri": "^2.0.0"
|
"fast-uri": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/busboy": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/cors": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "8.5.0",
|
"version": "8.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
||||||
@@ -520,6 +548,77 @@
|
|||||||
"fast-deep-equal": "^3.1.3"
|
"fast-deep-equal": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/reply-from": {
|
||||||
|
"version": "9.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
|
||||||
|
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/error": "^3.0.0",
|
||||||
|
"end-of-stream": "^1.4.4",
|
||||||
|
"fast-content-type-parse": "^1.1.0",
|
||||||
|
"fast-querystring": "^1.0.0",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"toad-cache": "^3.7.0",
|
||||||
|
"undici": "^5.19.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/reply-from/node_modules/undici": {
|
||||||
|
"version": "5.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||||
|
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/send": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.1",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"fast-decode-uri-component": "^1.0.1",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"mime": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/static": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/accept-negotiator": "^1.0.0",
|
||||||
|
"@fastify/send": "^2.0.0",
|
||||||
|
"content-disposition": "^0.5.3",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"fastq": "^1.17.0",
|
||||||
|
"glob": "^10.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -548,12 +647,31 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/ms": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||||
@@ -593,6 +711,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/yauzl": {
|
||||||
|
"version": "2.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
|
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -674,6 +802,30 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
@@ -700,6 +852,48 @@
|
|||||||
"fastq": "^1.17.1"
|
"fastq": "^1.17.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
@@ -709,6 +903,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
@@ -725,6 +931,48 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "src/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.14",
|
||||||
|
"npm": ">=6",
|
||||||
|
"yarn": ">=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
@@ -735,6 +983,27 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -777,6 +1046,12 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-content-type-parse": {
|
"node_modules/fast-content-type-parse": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
||||||
@@ -891,6 +1166,15 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fd-slicer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pend": "~1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-my-way": {
|
"node_modules/find-my-way": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
||||||
@@ -905,6 +1189,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -929,6 +1229,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fuzzysort": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
@@ -942,6 +1248,48 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"inherits": "2.0.4",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"toidentifier": "1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -951,6 +1299,36 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-ref-resolver": {
|
"node_modules/json-schema-ref-resolver": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
||||||
@@ -977,6 +1355,12 @@
|
|||||||
"set-cookie-parser": "^2.4.1"
|
"set-cookie-parser": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/make-error": {
|
"node_modules/make-error": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
@@ -984,6 +1368,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mnemonist": {
|
"node_modules/mnemonist": {
|
||||||
"version": "0.39.6",
|
"version": "0.39.6",
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
||||||
@@ -1008,6 +1428,52 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pend": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "9.14.0",
|
"version": "9.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||||
@@ -1139,6 +1605,26 @@
|
|||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||||
@@ -1181,6 +1667,45 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
@@ -1199,6 +1724,111 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thread-stream": {
|
"node_modules/thread-stream": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||||
@@ -1217,6 +1847,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-node": {
|
"node_modules/ts-node": {
|
||||||
"version": "10.9.2",
|
"version": "10.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
@@ -1296,6 +1935,15 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -1310,6 +1958,128 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yauzl": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-crc32": "~0.2.3",
|
||||||
|
"fd-slicer": "~1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yn": {
|
"node_modules/yn": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
"email": "codenomad@neuralnomads.ai"
|
"email": "codenomad@neuralnomads.ai"
|
||||||
@@ -16,11 +17,11 @@
|
|||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||||
"build:ui": "npm run build --prefix ../ui",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||||
"dev": "cross-env CODENOMAD_DEV=1 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"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -30,14 +31,19 @@
|
|||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yaml": "^2.4.2",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const cliRoot = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
|
||||||
|
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
|
||||||
|
|
||||||
|
if (!existsSync(sourceDir)) {
|
||||||
|
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(targetDir, { recursive: true, force: true })
|
||||||
|
mkdirSync(targetDir, { recursive: true })
|
||||||
|
cpSync(sourceDir, targetDir, { recursive: true })
|
||||||
|
|
||||||
|
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentModelSelection,
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
ConfigFile,
|
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
OpenCodeBinary,
|
OpenCodeBinary,
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -50,6 +49,38 @@ export interface WorkspaceDeleteResponse {
|
|||||||
status: WorkspaceStatus
|
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 type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
@@ -95,6 +126,26 @@ export interface FileSystemListResponse {
|
|||||||
metadata: FileSystemListingMetadata
|
metadata: FileSystemListingMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileSystemCreateFolderRequest {
|
||||||
|
/**
|
||||||
|
* Path identifier for the currently browsed directory.
|
||||||
|
* Matches the `path` parameter used for `/api/filesystem`.
|
||||||
|
*/
|
||||||
|
parentPath?: string
|
||||||
|
/** Single folder name (no separators). */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemCreateFolderResponse {
|
||||||
|
/**
|
||||||
|
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
|
||||||
|
* Relative for restricted listings, absolute for unrestricted.
|
||||||
|
*/
|
||||||
|
path: string
|
||||||
|
/** Absolute folder path on the server host. */
|
||||||
|
absolutePath: string
|
||||||
|
}
|
||||||
|
|
||||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||||
|
|
||||||
export interface WorkspaceFileResponse {
|
export interface WorkspaceFileResponse {
|
||||||
@@ -131,9 +182,9 @@ export interface BinaryRecord {
|
|||||||
validationError?: string
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppConfig = ConfigFile
|
export type SettingsOwner = string
|
||||||
export type AppConfigResponse = AppConfig
|
export type SettingsBucket = Record<string, unknown>
|
||||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
export interface BinaryListResponse {
|
export interface BinaryListResponse {
|
||||||
binaries: BinaryRecord[]
|
binaries: BinaryRecord[]
|
||||||
@@ -162,12 +213,11 @@ export type WorkspaceEventType =
|
|||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "config.appChanged"
|
| "storage.configChanged"
|
||||||
| "config.binariesChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
| "app.releaseAvailable"
|
|
||||||
|
|
||||||
export type WorkspaceEventPayload =
|
export type WorkspaceEventPayload =
|
||||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
@@ -175,18 +225,18 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "config.appChanged"; config: AppConfig }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
|
||||||
|
|
||||||
export interface NetworkAddress {
|
export interface NetworkAddress {
|
||||||
ip: string
|
ip: string
|
||||||
family: "ipv4" | "ipv6"
|
family: "ipv4" | "ipv6"
|
||||||
scope: "external" | "internal" | "loopback"
|
scope: "external" | "internal" | "loopback"
|
||||||
url: string
|
/** Remote URL using the server's remote protocol/port for this IP. */
|
||||||
|
remoteUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LatestReleaseInfo {
|
export interface LatestReleaseInfo {
|
||||||
@@ -198,25 +248,45 @@ export interface LatestReleaseInfo {
|
|||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UiMeta {
|
||||||
|
version?: string
|
||||||
|
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportMeta {
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerMeta {
|
export interface ServerMeta {
|
||||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
|
||||||
httpBaseUrl: string
|
localUrl: string
|
||||||
|
/** URL remote clients should use (prefers HTTPS when enabled). */
|
||||||
|
remoteUrl?: string
|
||||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||||
eventsUrl: string
|
eventsUrl: string
|
||||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||||
host: string
|
host: string
|
||||||
/** Listening mode derived from host binding. */
|
/** Listening mode derived from host binding. */
|
||||||
listeningMode: "local" | "all"
|
listeningMode: "local" | "all"
|
||||||
/** Actual port in use after binding. */
|
/** Actual local port in use after binding. */
|
||||||
port: number
|
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). */
|
/** Display label for the host (e.g., hostname or friendly name). */
|
||||||
hostLabel: string
|
hostLabel: string
|
||||||
/** Absolute path of the filesystem root exposed to clients. */
|
/** Absolute path of the filesystem root exposed to clients. */
|
||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
/** Reachable addresses for this server, external first. */
|
/** Reachable addresses for this server, external first. */
|
||||||
addresses: NetworkAddress[]
|
addresses: NetworkAddress[]
|
||||||
/** Optional metadata about the most recent public release. */
|
serverVersion?: string
|
||||||
latestRelease?: LatestReleaseInfo
|
ui?: UiMeta
|
||||||
|
support?: SupportMeta
|
||||||
|
/** Optional update info (dev channel only). */
|
||||||
|
update?: LatestReleaseInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|||||||
175
packages/server/src/auth/auth-store.ts
Normal file
175
packages/server/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||||
|
|
||||||
|
export interface AuthFile {
|
||||||
|
version: 1
|
||||||
|
username: string
|
||||||
|
password: PasswordHashRecord
|
||||||
|
userProvided: boolean
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
username: string
|
||||||
|
passwordUserProvided: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthStore {
|
||||||
|
private cachedFile: AuthFile | null = null
|
||||||
|
private overrideAuth: AuthFile | null = null
|
||||||
|
private bootstrapUsername: string | null = null
|
||||||
|
|
||||||
|
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||||
|
|
||||||
|
getAuthFilePath() {
|
||||||
|
return this.authFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): AuthFile | null {
|
||||||
|
if (this.overrideAuth) {
|
||||||
|
return this.overrideAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cachedFile) {
|
||||||
|
return this.cachedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.authFilePath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(this.authFilePath, "utf-8")
|
||||||
|
const parsed = JSON.parse(raw) as AuthFile
|
||||||
|
if (!parsed || parsed.version !== 1) {
|
||||||
|
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
this.cachedFile = parsed
|
||||||
|
return parsed
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureInitialized(params: {
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
allowBootstrapWithoutPassword: boolean
|
||||||
|
}): void {
|
||||||
|
const password = params.password?.trim()
|
||||||
|
if (password) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const runtime: AuthFile = {
|
||||||
|
version: 1,
|
||||||
|
username: params.username,
|
||||||
|
password: hashPassword(password),
|
||||||
|
userProvided: true,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
this.overrideAuth = runtime
|
||||||
|
this.cachedFile = null
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.load()
|
||||||
|
if (existing) {
|
||||||
|
if (existing.username !== params.username) {
|
||||||
|
// Keep existing username unless explicitly overridden later.
|
||||||
|
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
|
||||||
|
}
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.allowBootstrapWithoutPassword) {
|
||||||
|
this.bootstrapUsername = params.username
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCredentials(username: string, password: string): boolean {
|
||||||
|
const auth = this.load()
|
||||||
|
if (!auth) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== auth.username) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyPassword(password, auth.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||||
|
if (this.overrideAuth) {
|
||||||
|
throw new Error(
|
||||||
|
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.load()
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
if (!this.bootstrapUsername) {
|
||||||
|
throw new Error("Auth is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: AuthFile = {
|
||||||
|
version: 1,
|
||||||
|
username: this.bootstrapUsername,
|
||||||
|
password: hashPassword(params.password),
|
||||||
|
userProvided: params.markUserProvided,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist(created)
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
return { username: created.username, passwordUserProvided: created.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: AuthFile = {
|
||||||
|
...current,
|
||||||
|
password: hashPassword(params.password),
|
||||||
|
userProvided: params.markUserProvided,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist(next)
|
||||||
|
return { username: next.username, passwordUserProvided: next.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): AuthStatus {
|
||||||
|
const current = this.load()
|
||||||
|
if (current) {
|
||||||
|
return { username: current.username, passwordUserProvided: current.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.bootstrapUsername) {
|
||||||
|
return { username: this.bootstrapUsername, passwordUserProvided: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Auth is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(auth: AuthFile) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
|
||||||
|
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
|
||||||
|
this.cachedFile = auth
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/server/src/auth/http-auth.ts
Normal file
38
packages/server/src/auth/http-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
|
||||||
|
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
if (!header) return result
|
||||||
|
|
||||||
|
const parts = header.split(";")
|
||||||
|
for (const part of parts) {
|
||||||
|
const index = part.indexOf("=")
|
||||||
|
if (index < 0) continue
|
||||||
|
const key = part.slice(0, index).trim()
|
||||||
|
const value = part.slice(index + 1).trim()
|
||||||
|
if (!key) continue
|
||||||
|
result[key] = decodeURIComponent(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
||||||
|
if (!remoteAddress) return false
|
||||||
|
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
|
||||||
|
if (remoteAddress === "::ffff:127.0.0.1") return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wantsHtml(request: FastifyRequest): boolean {
|
||||||
|
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
|
||||||
|
return accept.includes("text/html") || accept.includes("application/xhtml")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
163
packages/server/src/auth/manager.ts
Normal file
163
packages/server/src/auth/manager.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { AuthStore } from "./auth-store"
|
||||||
|
import { TokenManager } from "./token-manager"
|
||||||
|
import { SessionManager } from "./session-manager"
|
||||||
|
import { isLoopbackAddress, parseCookies } from "./http-auth"
|
||||||
|
|
||||||
|
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
|
||||||
|
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
|
||||||
|
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
|
||||||
|
|
||||||
|
export interface AuthManagerInit {
|
||||||
|
configPath: string
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
generateToken: boolean
|
||||||
|
dangerouslySkipAuth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthManager {
|
||||||
|
private readonly authStore: AuthStore | null
|
||||||
|
private readonly tokenManager: TokenManager | null
|
||||||
|
private readonly sessionManager = new SessionManager()
|
||||||
|
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
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" }))
|
||||||
|
|
||||||
|
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
|
||||||
|
this.authStore.ensureInitialized({
|
||||||
|
username: init.username,
|
||||||
|
password: init.password,
|
||||||
|
allowBootstrapWithoutPassword: init.generateToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthEnabled(): boolean {
|
||||||
|
return this.authEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookieName(): string {
|
||||||
|
return this.cookieName
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenBootstrapEnabled(): boolean {
|
||||||
|
return Boolean(this.tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
issueBootstrapToken(): string | null {
|
||||||
|
if (!this.tokenManager) return null
|
||||||
|
return this.tokenManager.generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeBootstrapToken(token: string): boolean {
|
||||||
|
if (!this.tokenManager) return false
|
||||||
|
return this.tokenManager.consume(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateLogin(username: string, password: string): boolean {
|
||||||
|
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() {
|
||||||
|
if (!this.authEnabled) {
|
||||||
|
return { username: this.init.username, passwordUserProvided: false }
|
||||||
|
}
|
||||||
|
return this.requireAuthStore().getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(password: string) {
|
||||||
|
if (!this.authEnabled) {
|
||||||
|
throw new Error("Internal authentication is disabled")
|
||||||
|
}
|
||||||
|
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||||
|
return isLoopbackAddress(request.socket.remoteAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
if (!this.authEnabled) {
|
||||||
|
// When auth is disabled, treat all requests as authenticated.
|
||||||
|
// We still return a stable username so callers can display it.
|
||||||
|
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = parseCookies(request.headers.cookie)
|
||||||
|
const sessionId = cookies[this.cookieName]
|
||||||
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
|
if (!session) return null
|
||||||
|
return { username: session.username, sessionId: session.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionCookie(reply: FastifyReply, sessionId: string) {
|
||||||
|
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 resolveAuthFilePath(configPath: string) {
|
||||||
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(filePath: string) {
|
||||||
|
if (filePath.startsWith("~/")) {
|
||||||
|
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))}`)
|
||||||
|
}
|
||||||
|
return parts.join("; ")
|
||||||
|
}
|
||||||
49
packages/server/src/auth/password-hash.ts
Normal file
49
packages/server/src/auth/password-hash.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface PasswordHashRecord {
|
||||||
|
algorithm: "scrypt"
|
||||||
|
saltBase64: string
|
||||||
|
hashBase64: string
|
||||||
|
keyLength: number
|
||||||
|
params: {
|
||||||
|
N: number
|
||||||
|
r: number
|
||||||
|
p: number
|
||||||
|
maxmem: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCRYPT_PARAMS = {
|
||||||
|
N: 16384,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
maxmem: 32 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): PasswordHashRecord {
|
||||||
|
const salt = crypto.randomBytes(16)
|
||||||
|
const params = DEFAULT_SCRYPT_PARAMS
|
||||||
|
const keyLength = 64
|
||||||
|
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||||
|
return {
|
||||||
|
algorithm: "scrypt",
|
||||||
|
saltBase64: salt.toString("base64"),
|
||||||
|
hashBase64: Buffer.from(derived).toString("base64"),
|
||||||
|
keyLength,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||||
|
if (record.algorithm !== "scrypt") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = Buffer.from(record.saltBase64, "base64")
|
||||||
|
const expected = Buffer.from(record.hashBase64, "base64")
|
||||||
|
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||||
|
if (expected.length !== derived.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||||
|
}
|
||||||
23
packages/server/src/auth/session-manager.ts
Normal file
23
packages/server/src/auth/session-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string
|
||||||
|
createdAt: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
private sessions = new Map<string, SessionInfo>()
|
||||||
|
|
||||||
|
createSession(username: string): SessionInfo {
|
||||||
|
const id = crypto.randomBytes(32).toString("base64url")
|
||||||
|
const info: SessionInfo = { id, createdAt: Date.now(), username }
|
||||||
|
this.sessions.set(id, info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(id: string | undefined): SessionInfo | undefined {
|
||||||
|
if (!id) return undefined
|
||||||
|
return this.sessions.get(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/server/src/auth/token-manager.ts
Normal file
32
packages/server/src/auth/token-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface BootstrapToken {
|
||||||
|
token: string
|
||||||
|
createdAt: number
|
||||||
|
consumed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenManager {
|
||||||
|
private token: BootstrapToken | null = null
|
||||||
|
|
||||||
|
constructor(private readonly ttlMs: number) {}
|
||||||
|
|
||||||
|
generate(): string {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url")
|
||||||
|
this.token = { token, createdAt: Date.now(), consumed: false }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(token: string): boolean {
|
||||||
|
if (!this.token) return false
|
||||||
|
if (this.token.consumed) return false
|
||||||
|
if (Date.now() - this.token.createdAt > this.ttlMs) return false
|
||||||
|
if (token !== this.token.token) return false
|
||||||
|
this.token.consumed = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
peek(): string | null {
|
||||||
|
return this.token?.token ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
@@ -11,6 +11,7 @@ const ROOT_DIR = ".codenomad/background_processes"
|
|||||||
const INDEX_FILE = "index.json"
|
const INDEX_FILE = "index.json"
|
||||||
const OUTPUT_FILE = "output.txt"
|
const OUTPUT_FILE = "output.txt"
|
||||||
const STOP_TIMEOUT_MS = 2000
|
const STOP_TIMEOUT_MS = 2000
|
||||||
|
const EXIT_WAIT_TIMEOUT_MS = 5000
|
||||||
const MAX_OUTPUT_BYTES = 20 * 1024
|
const MAX_OUTPUT_BYTES = 20 * 1024
|
||||||
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ interface ManagerDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RunningProcess {
|
interface RunningProcess {
|
||||||
|
id: string
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
outputPath: string
|
outputPath: string
|
||||||
exitPromise: Promise<void>
|
exitPromise: Promise<void>
|
||||||
@@ -58,12 +60,21 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||||
|
|
||||||
const child = spawn("bash", ["-c", command], {
|
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
|
||||||
|
|
||||||
|
const child = spawn(shellCommand, shellArgs, {
|
||||||
cwd: workspace.path,
|
cwd: workspace.path,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
...spawnOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", () => {
|
||||||
|
this.killProcessTree(child, "SIGTERM")
|
||||||
})
|
})
|
||||||
|
|
||||||
const record: BackgroundProcess = {
|
const record: BackgroundProcess = {
|
||||||
|
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
title,
|
||||||
@@ -91,7 +102,7 @@ export class BackgroundProcessManager {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
|
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||||
|
|
||||||
let lastPublishAt = 0
|
let lastPublishAt = 0
|
||||||
const maybePublishSize = () => {
|
const maybePublishSize = () => {
|
||||||
@@ -128,7 +139,7 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +160,7 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,24 +266,94 @@ export class BackgroundProcessManager {
|
|||||||
private async cleanupWorkspace(workspaceId: string) {
|
private async cleanupWorkspace(workspaceId: string) {
|
||||||
for (const [, running] of this.running.entries()) {
|
for (const [, running] of this.running.entries()) {
|
||||||
if (running.workspaceId !== workspaceId) continue
|
if (running.workspaceId !== workspaceId) continue
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeWorkspaceDir(workspaceId)
|
await this.removeWorkspaceDir(workspaceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) return
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const args = this.buildWindowsTaskkillArgs(pid, signal)
|
||||||
|
try {
|
||||||
|
spawnSync("taskkill", args, { stdio: "ignore" })
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall back to killing the direct child.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
process.kill(-pid, signal)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall back to killing the direct child.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill(signal)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async waitForExit(running: RunningProcess) {
|
private async waitForExit(running: RunningProcess) {
|
||||||
let resolved = false
|
let exited = false
|
||||||
const timeout = setTimeout(() => {
|
const exitPromise = running.exitPromise.finally(() => {
|
||||||
if (!resolved) {
|
exited = true
|
||||||
running.child.kill("SIGKILL")
|
})
|
||||||
|
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
if (!exited) {
|
||||||
|
this.killProcessTree(running.child, "SIGKILL")
|
||||||
}
|
}
|
||||||
}, STOP_TIMEOUT_MS)
|
}, STOP_TIMEOUT_MS)
|
||||||
|
|
||||||
await running.exitPromise.finally(() => {
|
try {
|
||||||
resolved = true
|
await Promise.race([
|
||||||
clearTimeout(timeout)
|
exitPromise,
|
||||||
})
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!exited) {
|
||||||
|
this.killProcessTree(running.child, "SIGKILL")
|
||||||
|
this.running.delete(running.id)
|
||||||
|
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
return {
|
||||||
|
shellCommand: comspec,
|
||||||
|
shellArgs: ["/d", "/s", "/c", command],
|
||||||
|
spawnOptions: { windowsVerbatimArguments: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep bash for macOS/Linux.
|
||||||
|
return { shellCommand: "bash", shellArgs: ["-c", command] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
|
||||||
|
// Default to graceful termination (no /F), then force kill when we escalate.
|
||||||
|
const force = signal === "SIGKILL"
|
||||||
|
const args = ["/PID", String(pid), "/T"]
|
||||||
|
if (force) {
|
||||||
|
args.push("/F")
|
||||||
|
}
|
||||||
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import {
|
|
||||||
BinaryCreateRequest,
|
|
||||||
BinaryRecord,
|
|
||||||
BinaryUpdateRequest,
|
|
||||||
BinaryValidationResult,
|
|
||||||
} from "../api-types"
|
|
||||||
import { ConfigStore } from "./store"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import type { ConfigFile } from "./schema"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
|
|
||||||
export class BinaryRegistry {
|
|
||||||
constructor(
|
|
||||||
private readonly configStore: ConfigStore,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
list(): BinaryRecord[] {
|
|
||||||
return this.mapRecords()
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveDefault(): BinaryRecord {
|
|
||||||
const binaries = this.mapRecords()
|
|
||||||
if (binaries.length === 0) {
|
|
||||||
this.logger.warn("No configured binaries found, falling back to opencode")
|
|
||||||
return this.buildFallbackRecord("opencode")
|
|
||||||
}
|
|
||||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
create(request: BinaryCreateRequest): BinaryRecord {
|
|
||||||
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
|
||||||
const entry = {
|
|
||||||
path: request.path,
|
|
||||||
version: undefined,
|
|
||||||
lastUsed: Date.now(),
|
|
||||||
label: request.label,
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
|
||||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
|
||||||
|
|
||||||
if (request.makeDefault) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = request.path
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
const record = this.getById(request.path)
|
|
||||||
this.emitChange()
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
|
||||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
|
||||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updates.makeDefault) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = id
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
const record = this.getById(id)
|
|
||||||
this.emitChange()
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(id: string) {
|
|
||||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
|
||||||
nextConfig.opencodeBinaries = remaining
|
|
||||||
|
|
||||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
this.emitChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePath(path: string): BinaryValidationResult {
|
|
||||||
this.logger.debug({ path }, "Validating OpenCode binary path")
|
|
||||||
return this.validateRecord({
|
|
||||||
id: path,
|
|
||||||
path,
|
|
||||||
label: this.prettyLabel(path),
|
|
||||||
isDefault: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
|
||||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapRecords(): BinaryRecord[] {
|
|
||||||
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
|
||||||
id: binary.path,
|
|
||||||
path: binary.path,
|
|
||||||
label: binary.label ?? this.prettyLabel(binary.path),
|
|
||||||
version: binary.version,
|
|
||||||
isDefault: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
|
||||||
|
|
||||||
const annotated = configuredBinaries.map((binary) => ({
|
|
||||||
...binary,
|
|
||||||
isDefault: binary.path === defaultPath,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
|
||||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
return annotated
|
|
||||||
}
|
|
||||||
|
|
||||||
private getById(id: string): BinaryRecord {
|
|
||||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitChange() {
|
|
||||||
this.logger.debug("Emitting binaries changed event")
|
|
||||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
|
||||||
// TODO: call actual binary -v check.
|
|
||||||
return { valid: true, version: record.version }
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFallbackRecord(path: string): BinaryRecord {
|
|
||||||
return {
|
|
||||||
id: path,
|
|
||||||
path,
|
|
||||||
label: this.prettyLabel(path),
|
|
||||||
isDefault: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private prettyLabel(path: string) {
|
|
||||||
const parts = path.split(/[\\/]/)
|
|
||||||
const last = parts[parts.length - 1] || path
|
|
||||||
return last || path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,20 +8,33 @@ const ModelPreferenceSchema = z.object({
|
|||||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||||
|
|
||||||
const PreferencesSchema = z.object({
|
const PreferencesSchema = z
|
||||||
|
.object({
|
||||||
showThinkingBlocks: z.boolean().default(false),
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showTimelineTools: z.boolean().default(true),
|
showTimelineTools: z.boolean().default(true),
|
||||||
|
promptSubmitOnEnter: z.boolean().default(false),
|
||||||
lastUsedBinary: z.string().optional(),
|
lastUsedBinary: z.string().optional(),
|
||||||
|
locale: z.string().optional(),
|
||||||
environmentVariables: z.record(z.string()).default({}),
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
modelFavorites: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
})
|
|
||||||
|
// 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({
|
const RecentFolderSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@@ -35,14 +48,35 @@ const OpenCodeBinarySchema = z.object({
|
|||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ConfigFileSchema = z.object({
|
const ConfigFileSchema = z
|
||||||
preferences: PreferencesSchema.default({}),
|
.object({
|
||||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
preferences: PreferencesSchema.default({}),
|
||||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
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 = ConfigFileSchema.parse({})
|
||||||
|
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
|
||||||
|
const DEFAULT_STATE = StateFileSchema.parse({})
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ModelPreferenceSchema,
|
ModelPreferenceSchema,
|
||||||
@@ -52,7 +86,11 @@ export {
|
|||||||
RecentFolderSchema,
|
RecentFolderSchema,
|
||||||
OpenCodeBinarySchema,
|
OpenCodeBinarySchema,
|
||||||
ConfigFileSchema,
|
ConfigFileSchema,
|
||||||
|
ConfigYamlSchema,
|
||||||
|
StateFileSchema,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
|
DEFAULT_CONFIG_YAML,
|
||||||
|
DEFAULT_STATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||||
@@ -62,3 +100,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
|||||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
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,24 +24,22 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("config.appChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("config.binariesChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
this.on("app.releaseAvailable", handler)
|
|
||||||
return () => {
|
return () => {
|
||||||
this.off("workspace.created", handler)
|
this.off("workspace.created", handler)
|
||||||
this.off("workspace.started", handler)
|
this.off("workspace.started", handler)
|
||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("config.appChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("config.binariesChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
this.off("app.releaseAvailable", handler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "fs"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import {
|
import {
|
||||||
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
FileSystemListingMetadata,
|
FileSystemListingMetadata,
|
||||||
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
|
|||||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
|
||||||
|
const name = this.normalizeFolderName(folderName)
|
||||||
|
|
||||||
|
if (this.unrestricted) {
|
||||||
|
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
|
||||||
|
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
|
||||||
|
throw new Error("Cannot create folders at drive root")
|
||||||
|
}
|
||||||
|
this.assertDirectoryExists(resolvedParent)
|
||||||
|
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
|
||||||
|
fs.mkdirSync(absolutePath)
|
||||||
|
return { path: absolutePath, absolutePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedParent = this.normalizeRelativePath(parentPath)
|
||||||
|
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
|
||||||
|
this.assertDirectoryExists(parentAbsolute)
|
||||||
|
|
||||||
|
const relativePath = this.buildRelativePath(normalizedParent, name)
|
||||||
|
const absolutePath = this.toRestrictedAbsolute(relativePath)
|
||||||
|
fs.mkdirSync(absolutePath)
|
||||||
|
return { path: relativePath, absolutePath }
|
||||||
|
}
|
||||||
|
|
||||||
readFile(relativePath: string): string {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
@@ -157,25 +182,58 @@ export class FileSystemBrowser {
|
|||||||
return { entries, metadata }
|
return { entries, metadata }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeFolderName(input: string): string {
|
||||||
|
const name = input.trim()
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Folder name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
throw new Error("Invalid folder name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith("~")) {
|
||||||
|
throw new Error("Invalid folder name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes("/") || name.includes("\\")) {
|
||||||
|
throw new Error("Folder name must not include path separators")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes("\u0000")) {
|
||||||
|
throw new Error("Invalid folder name")
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDirectoryExists(directory: string) {
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
throw new Error(`Directory does not exist: ${directory}`)
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(directory)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error(`Path is not a directory: ${directory}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||||
const results: FileSystemEntry[] = []
|
const results: FileSystemEntry[] = []
|
||||||
|
|
||||||
for (const entry of dirents) {
|
for (const entry of dirents) {
|
||||||
if (!options.includeFiles && !entry.isDirectory()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const absoluteEntryPath = path.join(directory, entry.name)
|
const absoluteEntryPath = path.join(directory, entry.name)
|
||||||
let stats: fs.Stats
|
let stats: fs.Stats
|
||||||
try {
|
try {
|
||||||
|
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
|
||||||
|
// are treated as directories in directory-only listings.
|
||||||
stats = fs.statSync(absoluteEntryPath)
|
stats = fs.statSync(absoluteEntryPath)
|
||||||
} catch {
|
} catch {
|
||||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDirectory = entry.isDirectory()
|
const isDirectory = stats.isDirectory()
|
||||||
if (!options.includeFiles && !isDirectory) {
|
if (!options.includeFiles && !isDirectory) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { fileURLToPath } from "url"
|
|||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
import { ConfigStore } from "./config/store"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { BinaryRegistry } from "./config/binaries"
|
import { SettingsService } from "./settings/service"
|
||||||
|
import { BinaryResolver } from "./settings/binaries"
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
@@ -17,7 +18,11 @@ import { InstanceStore } from "./storage/instance-store"
|
|||||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -27,8 +32,15 @@ const __dirname = path.dirname(__filename)
|
|||||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
port: number
|
|
||||||
host: string
|
host: string
|
||||||
|
https: boolean
|
||||||
|
http: boolean
|
||||||
|
httpsPort: number
|
||||||
|
httpPort: number
|
||||||
|
tlsKeyPath?: string
|
||||||
|
tlsCertPath?: string
|
||||||
|
tlsCaPath?: string
|
||||||
|
tlsSANs?: string
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configPath: string
|
configPath: string
|
||||||
unrestrictedRoot: boolean
|
unrestrictedRoot: boolean
|
||||||
@@ -36,12 +48,20 @@ interface CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
uiAutoUpdate: boolean
|
||||||
|
uiNoUpdate: boolean
|
||||||
|
uiManifestUrl?: string
|
||||||
launch: boolean
|
launch: boolean
|
||||||
|
authUsername: string
|
||||||
|
authPassword?: string
|
||||||
|
generateToken: boolean
|
||||||
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
|
||||||
const DEFAULT_HOST = "127.0.0.1"
|
const DEFAULT_HOST = "127.0.0.1"
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
const DEFAULT_HTTPS_PORT = 9898
|
||||||
|
const DEFAULT_HTTP_PORT = 9899
|
||||||
|
|
||||||
function parseCliOptions(argv: string[]): CliOptions {
|
function parseCliOptions(argv: string[]): CliOptions {
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
@@ -49,9 +69,16 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.description("CodeNomad CLI server")
|
.description("CodeNomad CLI server")
|
||||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
.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("--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(
|
.addOption(
|
||||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||||
@@ -62,12 +89,41 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||||
|
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
|
||||||
|
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
|
||||||
|
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
|
||||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||||
|
.addOption(
|
||||||
|
new Option("--username <username>", "Username for server authentication")
|
||||||
|
.env("CODENOMAD_SERVER_USERNAME")
|
||||||
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
|
)
|
||||||
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
|
.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" })
|
program.parse(argv, { from: "user" })
|
||||||
const parsed = program.opts<{
|
const parsed = program.opts<{
|
||||||
host: string
|
host: string
|
||||||
port: number
|
https?: string
|
||||||
|
http?: string
|
||||||
|
httpsPort: number
|
||||||
|
httpPort: number
|
||||||
|
tlsKey?: string
|
||||||
|
tlsCert?: string
|
||||||
|
tlsCa?: string
|
||||||
|
tlsSANs?: string
|
||||||
workspaceRoot?: string
|
workspaceRoot?: string
|
||||||
root?: string
|
root?: string
|
||||||
unrestrictedRoot?: boolean
|
unrestrictedRoot?: boolean
|
||||||
@@ -76,16 +132,45 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiDir: string
|
uiDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
uiNoUpdate?: boolean
|
||||||
|
uiAutoUpdate?: string
|
||||||
|
uiManifestUrl?: string
|
||||||
launch?: boolean
|
launch?: boolean
|
||||||
|
username: string
|
||||||
|
password?: 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 resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||||
|
|
||||||
const normalizedHost = resolveHost(parsed.host)
|
const normalizedHost = resolveHost(parsed.host)
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
port: parsed.port,
|
|
||||||
host: normalizedHost,
|
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,
|
rootDir: resolvedRoot,
|
||||||
configPath: parsed.config,
|
configPath: parsed.config,
|
||||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||||
@@ -93,7 +178,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination: parsed.logDestination,
|
logDestination: parsed.logDestination,
|
||||||
uiStaticDir: parsed.uiDir,
|
uiStaticDir: parsed.uiDir,
|
||||||
uiDevServer: parsed.uiDevServer,
|
uiDevServer: parsed.uiDevServer,
|
||||||
|
uiAutoUpdate,
|
||||||
|
uiNoUpdate: Boolean(parsed.uiNoUpdate),
|
||||||
|
uiManifestUrl: parsed.uiManifestUrl,
|
||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
|
authUsername: parsed.username,
|
||||||
|
authPassword: parsed.password,
|
||||||
|
generateToken: Boolean(parsed.generateToken),
|
||||||
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +198,22 @@ function parsePort(input: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveHost(input: string | undefined): string {
|
function resolveHost(input: string | undefined): string {
|
||||||
if (input && input.trim() === "0.0.0.0") {
|
const trimmed = input?.trim()
|
||||||
|
if (!trimmed) return DEFAULT_HOST
|
||||||
|
|
||||||
|
if (trimmed === "0.0.0.0") {
|
||||||
return "0.0.0.0"
|
return "0.0.0.0"
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST
|
|
||||||
|
if (trimmed === "localhost") {
|
||||||
|
return DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function programHasArg(argv: string[], flag: string): boolean {
|
||||||
|
return argv.includes(flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -119,73 +223,255 @@ async function main() {
|
|||||||
const configLogger = logger.child({ component: "config" })
|
const configLogger = logger.child({ component: "config" })
|
||||||
const eventLogger = logger.child({ component: "events" })
|
const eventLogger = logger.child({ component: "events" })
|
||||||
|
|
||||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
const logOptions = {
|
||||||
|
...options,
|
||||||
|
authPassword: options.authPassword ? "[REDACTED]" : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
localUrl: "http://localhost:0",
|
||||||
|
remoteUrl: undefined,
|
||||||
eventsUrl: `/api/events`,
|
eventsUrl: `/api/events`,
|
||||||
host: options.host,
|
host: options.host,
|
||||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||||
port: options.port,
|
localPort: 0,
|
||||||
|
remotePort: undefined,
|
||||||
hostLabel: options.host,
|
hostLabel: options.host,
|
||||||
workspaceRoot: options.rootDir,
|
workspaceRoot: options.rootDir,
|
||||||
addresses: [],
|
addresses: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
const authManager = new AuthManager(
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
{
|
||||||
|
configPath: configLocation.configYamlPath,
|
||||||
|
username: options.authUsername,
|
||||||
|
password: options.authPassword,
|
||||||
|
generateToken: options.generateToken,
|
||||||
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
|
},
|
||||||
|
logger.child({ component: "auth" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (options.generateToken && !options.dangerouslySkipAuth) {
|
||||||
|
const token = authManager.issueBootstrapToken()
|
||||||
|
if (token) {
|
||||||
|
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
binaryResolver,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
|
nodeExtraCaCertsPath,
|
||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore()
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: logger.child({ component: "instance-events" }),
|
logger: logger.child({ component: "instance-events" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const releaseMonitor = startReleaseMonitor({
|
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
|
||||||
currentVersion: packageJson.version,
|
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
|
||||||
logger: logger.child({ component: "release-monitor" }),
|
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
|
||||||
onUpdate: (release) => {
|
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
|
||||||
if (release) {
|
|
||||||
serverMeta.latestRelease = release
|
|
||||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
|
||||||
} else {
|
|
||||||
delete serverMeta.latestRelease
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = createHttpServer({
|
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
const uiResolution = await resolveUi({
|
||||||
workspaceManager,
|
serverVersion: packageJson.version,
|
||||||
configStore,
|
bundledUiDir: DEFAULT_UI_STATIC_DIR,
|
||||||
binaryRegistry,
|
autoUpdate: autoUpdateEnabled,
|
||||||
fileSystemBrowser,
|
overrideUiDir: uiDirOverride,
|
||||||
eventBus,
|
|
||||||
serverMeta,
|
|
||||||
instanceStore,
|
|
||||||
uiStaticDir: options.uiStaticDir,
|
|
||||||
uiDevServerUrl: options.uiDevServer,
|
uiDevServerUrl: options.uiDevServer,
|
||||||
logger,
|
manifestUrl: options.uiManifestUrl,
|
||||||
|
logger: logger.child({ component: "ui" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const startInfo = await server.start()
|
serverMeta.serverVersion = packageJson.version
|
||||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
serverMeta.ui = {
|
||||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
version: uiResolution.uiVersion,
|
||||||
|
source: uiResolution.source,
|
||||||
|
}
|
||||||
|
serverMeta.support = {
|
||||||
|
supported: uiResolution.supported,
|
||||||
|
message: uiResolution.message,
|
||||||
|
latestServerVersion: uiResolution.latestServerVersion,
|
||||||
|
latestServerUrl: uiResolution.latestServerUrl,
|
||||||
|
minServerVersion: uiResolution.minServerVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
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 candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
|
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remoteHost = "localhost"
|
||||||
|
}
|
||||||
|
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 = 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}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.launch) {
|
if (options.launch) {
|
||||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
let shuttingDown = false
|
let shuttingDown = false
|
||||||
@@ -196,23 +482,37 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
shuttingDown = true
|
shuttingDown = true
|
||||||
logger.info("Received shutdown signal, closing server")
|
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||||
try {
|
|
||||||
await server.stop()
|
|
||||||
logger.info("HTTP server stopped")
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const shutdownWorkspaces = (async () => {
|
||||||
instanceEventBridge.shutdown()
|
try {
|
||||||
await workspaceManager.shutdown()
|
instanceEventBridge.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
} catch (error) {
|
||||||
} catch (error) {
|
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
releaseMonitor.stop()
|
try {
|
||||||
|
await workspaceManager.shutdown()
|
||||||
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const shutdownHttp = (async () => {
|
||||||
|
try {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||||
|
|
||||||
|
// no-op: remote UI manifest replaces GitHub release monitor
|
||||||
|
|
||||||
|
devReleaseMonitor?.stop()
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
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> {
|
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||||
const response = await fetch(RELEASES_API_URL, {
|
const response = await fetch(RELEASES_API_URL, {
|
||||||
headers: {
|
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
|
if (!tag) return null
|
||||||
const trimmed = tag.trim()
|
const trimmed = tag.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
|
|||||||
|
|
||||||
function parseVersion(value: string): NormalizedVersion {
|
function parseVersion(value: string): NormalizedVersion {
|
||||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
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 [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||||
const parsed = Number.parseInt(segment, 10)
|
const parsed = Number.parseInt(segment, 10)
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
|||||||
@@ -7,33 +7,41 @@ import path from "path"
|
|||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
import { registerConfigRoutes } from "./routes/config"
|
import { registerSettingsRoutes } from "./routes/settings"
|
||||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
import { registerStorageRoutes } from "./routes/storage"
|
import { registerStorageRoutes } from "./routes/storage"
|
||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
|
import type { AuthManager } from "../auth/manager"
|
||||||
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
host: string
|
bindHost: string
|
||||||
port: number
|
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
|
workspaceManager: WorkspaceManager
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
|
||||||
fileSystemBrowser: FileSystemBrowser
|
fileSystemBrowser: FileSystemBrowser
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -45,10 +53,15 @@ interface HttpServerStartResult {
|
|||||||
displayHost: string
|
displayHost: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HTTP_PORT = 9898
|
|
||||||
|
|
||||||
export function createHttpServer(deps: HttpServerDeps) {
|
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 proxyLogger = deps.logger.child({ component: "proxy" })
|
||||||
const apiLogger = deps.logger.child({ component: "http" })
|
const apiLogger = deps.logger.child({ component: "http" })
|
||||||
const sseLogger = deps.logger.child({ component: "sse" })
|
const sseLogger = deps.logger.child({ component: "sse" })
|
||||||
@@ -88,8 +101,57 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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, {
|
app.register(cors, {
|
||||||
origin: true,
|
origin: (origin, cb) => {
|
||||||
|
if (!origin) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfOrigins = getSelfOrigins()
|
||||||
|
if (selfOrigins.has(origin)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedDevOrigins.has(origin)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||||
|
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
cb(null, false)
|
||||||
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,11 +171,82 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
|
app.addHook("preHandler", (request, reply, done) => {
|
||||||
|
const rawUrl = request.raw.url ?? request.url
|
||||||
|
const pathname = (rawUrl.split("?")[0] ?? "").trim()
|
||||||
|
|
||||||
|
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
|
||||||
|
const publicPagePaths = new Set(["/login"])
|
||||||
|
if (deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
publicPagePaths.add("/auth/token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||||
|
if (requiresAuthForApi && !session) {
|
||||||
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
|
if (pluginMatch) {
|
||||||
|
const workspaceId = pluginMatch[1]
|
||||||
|
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
const provided = Array.isArray(request.headers.authorization)
|
||||||
|
? request.headers.authorization[0]
|
||||||
|
: request.headers.authorization
|
||||||
|
|
||||||
|
if (expected && provided && provided === expected) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUnauthorized(request, reply)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.uiDevServerUrl) {
|
||||||
|
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiDir = deps.uiStaticDir
|
||||||
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
if (uiDir && fs.existsSync(indexPath)) {
|
||||||
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(404).send({ message: "UI bundle missing" })
|
||||||
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
instanceStore: deps.instanceStore,
|
instanceStore: deps.instanceStore,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
@@ -125,21 +258,21 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
|
|
||||||
if (deps.uiDevServerUrl) {
|
if (deps.uiDevServerUrl) {
|
||||||
setupDevProxy(app, deps.uiDevServerUrl)
|
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
|
||||||
} else {
|
} else {
|
||||||
setupStaticUi(app, deps.uiStaticDir)
|
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instance: app,
|
instance: app,
|
||||||
start: async (): Promise<HttpServerStartResult> => {
|
start: async (): Promise<HttpServerStartResult> => {
|
||||||
const attemptListen = async (requestedPort: number) => {
|
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 }
|
return { addressInfo, requestedPort }
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoPortRequested = deps.port === 0
|
const autoPortRequested = deps.bindPort === 0
|
||||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
|
||||||
|
|
||||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||||
if (!autoPortRequested) return false
|
if (!autoPortRequested) return false
|
||||||
@@ -175,15 +308,10 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
|
||||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
|
||||||
|
|
||||||
deps.serverMeta.httpBaseUrl = serverUrl
|
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
|
||||||
deps.serverMeta.host = deps.host
|
|
||||||
deps.serverMeta.port = actualPort
|
|
||||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
|
||||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
|
||||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
|
||||||
|
|
||||||
return { port: actualPort, url: serverUrl, displayHost }
|
return { port: actualPort, url: serverUrl, displayHost }
|
||||||
},
|
},
|
||||||
@@ -204,47 +332,130 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||||
|
|
||||||
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
const proxyBaseHandler = async (
|
||||||
await proxyWorkspaceRequest({
|
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
|
||||||
request,
|
|
||||||
reply,
|
|
||||||
workspaceManager: deps.workspaceManager,
|
|
||||||
pathSuffix: "",
|
|
||||||
logger: deps.logger,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyWildcardHandler = async (
|
|
||||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
) => {
|
) => {
|
||||||
await proxyWorkspaceRequest({
|
await proxyWorkspaceRequest({
|
||||||
request,
|
request,
|
||||||
reply,
|
reply,
|
||||||
workspaceManager: deps.workspaceManager,
|
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["*"] ?? "",
|
pathSuffix: request.params["*"] ?? "",
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
instance.all("/workspaces/:id/instance", proxyBaseHandler)
|
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
|
||||||
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
|
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||||
|
|
||||||
|
// Special-case OpenCode directory override.
|
||||||
|
//
|
||||||
|
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||||
|
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||||
|
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||||
|
// before proxying to the instance.
|
||||||
|
//
|
||||||
|
// Example proxied request path:
|
||||||
|
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||||
|
//
|
||||||
|
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||||
|
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||||
|
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||||
|
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||||
|
|
||||||
async function proxyWorkspaceRequest(args: {
|
async function proxyWorkspaceRequest(args: {
|
||||||
request: FastifyRequest
|
request: FastifyRequest
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
worktreeSlug: string
|
||||||
pathSuffix?: 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 workspaceId = (request.params as { id: string }).id
|
||||||
const workspace = workspaceManager.get(workspaceId)
|
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) {
|
if (!workspace) {
|
||||||
reply.code(404).send({ error: "Workspace not found" })
|
reply.code(404).send({ error: "Workspace not found" })
|
||||||
return
|
return
|
||||||
@@ -256,10 +467,52 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
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 queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||||
|
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
|
||||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||||
if (logger.isLevelEnabled("trace")) {
|
if (logger.isLevelEnabled("trace")) {
|
||||||
@@ -267,6 +520,49 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
return reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||||
|
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 }) => {
|
onError: (proxyReply, { error }) => {
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
if (!proxyReply.sent) {
|
if (!proxyReply.sent) {
|
||||||
@@ -276,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||||
|
overrideDirectory: string | null
|
||||||
|
forwardedSuffix: string | undefined
|
||||||
|
} {
|
||||||
|
if (!pathSuffix) {
|
||||||
|
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify wildcard param does not include a leading slash.
|
||||||
|
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||||
|
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||||
|
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||||
|
const slashIndex = rest.indexOf("/")
|
||||||
|
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||||
|
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||||
|
|
||||||
|
if (!encoded) {
|
||||||
|
throw new Error("Missing directory override")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||||
|
throw new Error("Directory override too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
let overrideDirectory = ""
|
||||||
|
try {
|
||||||
|
overrideDirectory = decodeBase64Url(encoded)
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid directory override")
|
||||||
|
}
|
||||||
|
const forwardedSuffix = remaining
|
||||||
|
return { overrideDirectory, forwardedSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Url(input: string): string {
|
||||||
|
// base64url -> base64
|
||||||
|
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||||
|
const base64 = `${normalized}${padding}`
|
||||||
|
return Buffer.from(base64, "base64").toString("utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||||
|
const raw = params.overrideDirectory.trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Override directory is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.isAbsolute(raw)) {
|
||||||
|
throw new Error("Override directory must be an absolute path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(raw)) {
|
||||||
|
throw new Error(`Override directory does not exist: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(raw)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error(`Override path is not a directory: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedOverride = fs.realpathSync(raw)
|
||||||
|
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||||
|
|
||||||
|
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||||
|
throw new Error("Override directory must be within the workspace root")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubpath(candidate: string, root: string): boolean {
|
||||||
|
const rel = path.relative(root, candidate)
|
||||||
|
if (rel === "") return true
|
||||||
|
if (rel === "..") return false
|
||||||
|
if (rel.startsWith(`..${path.sep}`)) return false
|
||||||
|
if (path.isAbsolute(rel)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||||
if (!pathSuffix || pathSuffix === "/") {
|
if (!pathSuffix || pathSuffix === "/") {
|
||||||
return "/"
|
return "/"
|
||||||
@@ -284,7 +663,53 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
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) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
app.log.warn("UI static directory not provided; API endpoints only")
|
||||||
return
|
return
|
||||||
@@ -310,6 +735,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromRequest(request)
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||||
} else {
|
} else {
|
||||||
@@ -318,7 +749,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
|
||||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const url = request.raw.url ?? ""
|
const url = request.raw.url ?? ""
|
||||||
@@ -326,6 +757,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
|||||||
reply.code(404).send({ message: "Not Found" })
|
reply.code(404).send({ message: "Not Found" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromRequest(request)
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void proxyToDevServer(request, reply, upstreamBase)
|
void proxyToDevServer(request, reply, upstreamBase)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
75
packages/server/src/server/network-addresses.ts
Normal file
75
packages/server/src/server/network-addresses.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import os from "os"
|
||||||
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
|
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 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
|
||||||
|
})
|
||||||
|
}
|
||||||
135
packages/server/src/server/routes/auth-pages/login.html
Normal file
135
packages/server/src/server/routes/auth-pages/login.html
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad Login</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
background: #0b0b0f;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: #0f0f16;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: #4c6fff;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||||
|
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" autocomplete="current-password" value="" />
|
||||||
|
|
||||||
|
<button id="submit" type="button">Continue</button>
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id)
|
||||||
|
const errorEl = $("error")
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorEl.textContent = msg
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
const hideError = () => {
|
||||||
|
errorEl.textContent = ""
|
||||||
|
errorEl.style.display = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
hideError()
|
||||||
|
const username = $("username").value.trim()
|
||||||
|
const password = $("password").value
|
||||||
|
if (!username || !password) {
|
||||||
|
showError("Username and password are required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || `Login failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Replace history entry so Back doesn't return to /login.
|
||||||
|
window.location.replace("/")
|
||||||
|
} catch (e) {
|
||||||
|
showError(e && e.message ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("submit").addEventListener("click", submit)
|
||||||
|
$("password").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") submit()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
background: #0b0b0f;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Connecting…</h1>
|
||||||
|
<p>Finalizing local authentication.</p>
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const token = (location.hash || "").replace(/^#/, "").trim()
|
||||||
|
const errorEl = document.getElementById("error")
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorEl.textContent = msg
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!token) {
|
||||||
|
showError("Missing bootstrap token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || `Token exchange failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace("/")
|
||||||
|
} catch (e) {
|
||||||
|
showError(e && e.message ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
181
packages/server/src/server/routes/auth.ts
Normal file
181
packages/server/src/server/routes/auth.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import fs from "fs"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { AuthManager } from "../../auth/manager"
|
||||||
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const TokenSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
})
|
||||||
|
|
||||||
|
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||||
|
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||||
|
|
||||||
|
let cachedLoginTemplate: string | null = null
|
||||||
|
let cachedTokenTemplate: string | null = null
|
||||||
|
|
||||||
|
function readTemplate(url: URL, cache: string | null): string {
|
||||||
|
if (cache) return cache
|
||||||
|
const content = fs.readFileSync(url, "utf-8")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginHtml(defaultUsername: string): string {
|
||||||
|
if (!cachedLoginTemplate) {
|
||||||
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
|
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenHtml(): string {
|
||||||
|
if (!cachedTokenTemplate) {
|
||||||
|
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedTokenTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/auth/token", async (request, reply) => {
|
||||||
|
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/status", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.send({ authenticated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (request, reply) => {
|
||||||
|
const body = LoginSchema.parse(request.body ?? {})
|
||||||
|
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(401).send({ error: "Invalid credentials" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deps.authManager.createSession(body.username)
|
||||||
|
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/token", async (request, reply) => {
|
||||||
|
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = TokenSchema.parse(request.body ?? {})
|
||||||
|
const ok = deps.authManager.consumeBootstrapToken(body.token)
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(401).send({ error: "Invalid token" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = deps.authManager.getStatus().username
|
||||||
|
const session = deps.authManager.createSession(username)
|
||||||
|
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", async (request, reply) => {
|
||||||
|
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/password", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = PasswordSchema.parse(request.body ?? {})
|
||||||
|
try {
|
||||||
|
const status = deps.authManager.setPassword(body.password)
|
||||||
|
reply.send({ ok: true, ...status })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
reply.code(409).type("text/plain").send(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
case "&":
|
||||||
|
return "&"
|
||||||
|
case "<":
|
||||||
|
return "<"
|
||||||
|
case ">":
|
||||||
|
return ">"
|
||||||
|
case '"':
|
||||||
|
return """
|
||||||
|
default:
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
|
|||||||
includeFiles: z.coerce.boolean().optional(),
|
includeFiles: z.coerce.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FilesystemCreateFolderSchema = z.object({
|
||||||
|
parentPath: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/filesystem", async (request, reply) => {
|
app.get("/api/filesystem", async (request, reply) => {
|
||||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||||
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
|||||||
return { error: (error as Error).message }
|
return { error: (error as Error).message }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post("/api/filesystem/folders", async (request, reply) => {
|
||||||
|
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
|
||||||
|
reply.code(201)
|
||||||
|
return created
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException
|
||||||
|
if (err?.code === "EEXIST") {
|
||||||
|
reply.code(409).type("text/plain").send("Folder already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
||||||
|
reply.code(403).type("text/plain").send("Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(400).type("text/plain").send((error as Error).message)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import os from "os"
|
import { ServerMeta } from "../../api-types"
|
||||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
import { resolveNetworkAddresses } from "../network-addresses"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const port = resolvePort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
const remote = resolveRemote(meta)
|
||||||
|
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
port,
|
localPort,
|
||||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
remotePort: remote?.port,
|
||||||
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
addresses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePort(meta: ServerMeta): number {
|
function resolveLocalPort(meta: ServerMeta): number {
|
||||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
|
||||||
return meta.port
|
return meta.localPort
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(meta.httpBaseUrl)
|
const parsed = new URL(meta.localUrl)
|
||||||
const port = Number(parsed.port)
|
const port = Number(parsed.port)
|
||||||
return Number.isInteger(port) && port > 0 ? port : 0
|
return Number.isInteger(port) && port > 0 ? port : 0
|
||||||
} catch {
|
} catch {
|
||||||
@@ -35,70 +37,22 @@ function resolvePort(meta: ServerMeta): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
|
||||||
const interfaces = os.networkInterfaces()
|
if (!meta.remoteUrl) {
|
||||||
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
|
return null
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
if (host === "0.0.0.0") {
|
const parsed = new URL(meta.remoteUrl)
|
||||||
// Enumerate system interfaces (IPv4 only)
|
const protocol = parsed.protocol === "https:" ? "https" : "http"
|
||||||
for (const entries of Object.values(interfaces)) {
|
const port = Number(parsed.port)
|
||||||
if (!entries) continue
|
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
|
||||||
for (const entry of entries) {
|
} catch {
|
||||||
const family = normalizeFamily(entry.family)
|
return null
|
||||||
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 {
|
function isLoopbackHost(host: string): boolean {
|
||||||
if (!value) return false
|
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
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
|
||||||
|
|||||||
80
packages/server/src/server/routes/settings.ts
Normal file
80
packages/server/src/server/routes/settings.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||||
|
import type { SettingsService } from "../../settings/service"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
|
||||||
|
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 () => deps.settings.getDoc("config"))
|
||||||
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return 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 deps.settings.getOwner("config", request.params.owner)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return 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" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
269
packages/server/src/settings/migrate.ts
Normal file
269
packages/server/src/settings/migrate.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
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 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",
|
||||||
|
"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")
|
||||||
|
}
|
||||||
55
packages/server/src/settings/service.ts
Normal file
55
packages/server/src/settings/service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
|
|
||||||
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
|
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||||
|
this.publish(kind, "*")
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
|
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
|
const updated =
|
||||||
|
kind === "config" ? 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 payload: WorkspaceEventPayload = {
|
||||||
|
type,
|
||||||
|
owner,
|
||||||
|
value: value ?? this.getOwner(kind, owner),
|
||||||
|
} 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { afterEach, beforeEach, describe, it } from "node:test"
|
||||||
|
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import { resolveUi } from "../remote-ui"
|
||||||
|
|
||||||
|
const noopLogger: Logger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
trace: () => {},
|
||||||
|
child: () => noopLogger,
|
||||||
|
isLevelEnabled: () => false,
|
||||||
|
} as any
|
||||||
|
|
||||||
|
let tempRoot: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveUi local version preference", () => {
|
||||||
|
it("prefers bundled when bundled version is higher", 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.0" }))
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
571
packages/server/src/ui/remote-ui.ts
Normal file
571
packages/server/src/ui/remote-ui.ts
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import { createHash } from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { fetch } from "undici"
|
||||||
|
import yauzl from "yauzl"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
export interface RemoteUiManifest {
|
||||||
|
minServerVersion: string
|
||||||
|
latestUIVersion: string
|
||||||
|
uiPackageURL: string
|
||||||
|
sha256: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||||
|
|
||||||
|
export interface UiResolution {
|
||||||
|
uiStaticDir?: string
|
||||||
|
uiDevServerUrl?: string
|
||||||
|
source: UiSource
|
||||||
|
uiVersion?: string
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteUiOptions {
|
||||||
|
serverVersion: string
|
||||||
|
bundledUiDir: string
|
||||||
|
autoUpdate: boolean
|
||||||
|
overrideUiDir?: string
|
||||||
|
uiDevServerUrl?: string
|
||||||
|
manifestUrl?: string
|
||||||
|
configDir?: string
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
|
||||||
|
|
||||||
|
const MANIFEST_TIMEOUT_MS = 5_000
|
||||||
|
const ZIP_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
|
||||||
|
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
|
||||||
|
|
||||||
|
if (options.uiDevServerUrl) {
|
||||||
|
return {
|
||||||
|
uiDevServerUrl: options.uiDevServerUrl,
|
||||||
|
source: "dev-proxy",
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.overrideUiDir) {
|
||||||
|
const resolved = await resolveStaticUiDir(options.overrideUiDir)
|
||||||
|
return {
|
||||||
|
uiStaticDir: resolved ?? options.overrideUiDir,
|
||||||
|
source: "override",
|
||||||
|
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiRoot = resolveUiCacheRoot(options.configDir)
|
||||||
|
const currentDir = path.join(uiRoot, "current")
|
||||||
|
const previousDir = path.join(uiRoot, "previous")
|
||||||
|
|
||||||
|
if (!options.autoUpdate) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: RemoteUiManifest | null = null
|
||||||
|
try {
|
||||||
|
manifest = await fetchManifest(manifestUrl, options.logger)
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
|
||||||
|
if (!supported) {
|
||||||
|
const message = "Upgrade App to use latest features"
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: false,
|
||||||
|
message,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestLocal = await pickBestLocalUi({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteIsNewer =
|
||||||
|
!bestLocal ||
|
||||||
|
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
|
||||||
|
|
||||||
|
if (!remoteIsNewer) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installRemoteUi({
|
||||||
|
manifest,
|
||||||
|
uiRoot,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
logger: options.logger,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = await resolveStaticUiDir(currentDir)
|
||||||
|
if (installed) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: installed,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(installed),
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUiCacheRoot(configDir?: string): string {
|
||||||
|
if (configDir) {
|
||||||
|
return path.join(configDir, "ui")
|
||||||
|
}
|
||||||
|
return path.join(os.homedir(), ".config", "codenomad", "ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFromCacheOrBundled(args: {
|
||||||
|
logger: Logger
|
||||||
|
bundledUiDir: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
}): Promise<UiResolution> {
|
||||||
|
const bestLocal = await pickBestLocalUi({
|
||||||
|
logger: args.logger,
|
||||||
|
bundledUiDir: args.bundledUiDir,
|
||||||
|
currentDir: args.currentDir,
|
||||||
|
previousDir: args.previousDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (bestLocal) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: bestLocal.uiStaticDir,
|
||||||
|
source: bestLocal.source,
|
||||||
|
uiVersion: bestLocal.uiVersion,
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
|
||||||
|
return {
|
||||||
|
uiStaticDir: args.bundledUiDir,
|
||||||
|
source: "missing",
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickBestLocalUi(args: {
|
||||||
|
logger: Logger
|
||||||
|
bundledUiDir: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
|
||||||
|
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
|
||||||
|
|
||||||
|
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
||||||
|
if (currentResolved) {
|
||||||
|
candidates.push({
|
||||||
|
uiStaticDir: currentResolved,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(currentResolved),
|
||||||
|
priority: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
|
||||||
|
if (bundledResolved) {
|
||||||
|
candidates.push({
|
||||||
|
uiStaticDir: bundledResolved,
|
||||||
|
source: "bundled",
|
||||||
|
uiVersion: await readUiVersion(bundledResolved),
|
||||||
|
priority: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
||||||
|
if (previousResolved) {
|
||||||
|
candidates.push({
|
||||||
|
uiStaticDir: previousResolved,
|
||||||
|
source: "previous",
|
||||||
|
uiVersion: await readUiVersion(previousResolved),
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
|
||||||
|
if (versionCmp !== 0) return -versionCmp
|
||||||
|
return b.priority - a.priority
|
||||||
|
})
|
||||||
|
|
||||||
|
const best = candidates[0]
|
||||||
|
if (!best) return null
|
||||||
|
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
|
||||||
|
if (!a && !b) return 0
|
||||||
|
if (!a) return -1
|
||||||
|
if (!b) return 1
|
||||||
|
return compareSemverCore(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
await fsp.access(indexPath, fs.constants.R_OK)
|
||||||
|
return uiDir
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiVersionFile {
|
||||||
|
uiVersion?: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUiVersion(uiDir: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
|
||||||
|
const parsed = JSON.parse(content) as UiVersionFile
|
||||||
|
return parsed.uiVersion ?? parsed.version
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Manifest responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
const json = (await response.json()) as RemoteUiManifest
|
||||||
|
validateManifest(json)
|
||||||
|
return json
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(manifest: RemoteUiManifest) {
|
||||||
|
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
|
||||||
|
for (const key of required) {
|
||||||
|
const value = manifest[key]
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`Manifest missing ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
|
||||||
|
throw new Error("uiPackageURL must be https")
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
|
||||||
|
throw new Error("sha256 must be 64 hex chars")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRemoteUi(args: {
|
||||||
|
manifest: RemoteUiManifest
|
||||||
|
uiRoot: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
await fsp.mkdir(args.uiRoot, { recursive: true })
|
||||||
|
|
||||||
|
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
|
||||||
|
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
|
||||||
|
const digest = await sha256File(zipPath)
|
||||||
|
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
|
||||||
|
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await extractZip(zipPath, tmpDir)
|
||||||
|
|
||||||
|
const indexPath = path.join(tmpDir, "index.html")
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error("Extracted UI missing index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
|
||||||
|
|
||||||
|
fs.rmSync(args.currentDir, { recursive: true, force: true })
|
||||||
|
fs.renameSync(tmpDir, args.currentDir)
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
fs.rmSync(zipPath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(args.previousDir)) {
|
||||||
|
fs.rmSync(args.previousDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
if (fs.existsSync(args.currentDir)) {
|
||||||
|
fs.renameSync(args.currentDir, args.previousDir)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, targetPath: string, logger: Logger) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/octet-stream",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`UI zip download failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||||
|
const fileStream = fs.createWriteStream(targetPath)
|
||||||
|
|
||||||
|
const body = response.body
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("UI zip response missing body")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeStream = Readable.fromWeb(body as any)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
nodeStream.pipe(fileStream)
|
||||||
|
nodeStream.on("error", reject)
|
||||||
|
fileStream.on("error", reject)
|
||||||
|
fileStream.on("finish", () => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(filePath: string): Promise<string> {
|
||||||
|
const hash = createHash("sha256")
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk))
|
||||||
|
stream.on("error", reject)
|
||||||
|
stream.on("end", () => resolve())
|
||||||
|
})
|
||||||
|
return hash.digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
|
||||||
|
if (openErr || !zipfile) {
|
||||||
|
reject(openErr ?? new Error("Unable to open zip"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.resolve(targetDir)
|
||||||
|
|
||||||
|
const closeWithError = (error: unknown) => {
|
||||||
|
try {
|
||||||
|
zipfile.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipfile.readEntry()
|
||||||
|
|
||||||
|
zipfile.on("entry", (entry) => {
|
||||||
|
// Normalize and guard against zip-slip.
|
||||||
|
const entryPath = entry.fileName.replace(/\\/g, "/")
|
||||||
|
|
||||||
|
const segments = entryPath.split("/").filter(Boolean)
|
||||||
|
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
|
||||||
|
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = path.resolve(targetDir, entryPath)
|
||||||
|
if (!destination.startsWith(root + path.sep) && destination !== root) {
|
||||||
|
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectory = entry.fileName.endsWith("/")
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
fsp
|
||||||
|
.mkdir(destination, { recursive: true })
|
||||||
|
.then(() => zipfile.readEntry())
|
||||||
|
.catch((error) => closeWithError(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fsp
|
||||||
|
.mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
.then(() => {
|
||||||
|
zipfile.openReadStream(entry, (streamErr, readStream) => {
|
||||||
|
if (streamErr || !readStream) {
|
||||||
|
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeStream = fs.createWriteStream(destination)
|
||||||
|
const cleanup = (error?: unknown) => {
|
||||||
|
readStream.destroy()
|
||||||
|
writeStream.destroy()
|
||||||
|
if (error) {
|
||||||
|
closeWithError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readStream.on("error", cleanup)
|
||||||
|
writeStream.on("error", cleanup)
|
||||||
|
writeStream.on("finish", () => zipfile.readEntry())
|
||||||
|
|
||||||
|
readStream.pipe(writeStream)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => closeWithError(error))
|
||||||
|
})
|
||||||
|
|
||||||
|
zipfile.on("end", () => {
|
||||||
|
zipfile.close()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
zipfile.on("error", (error) => closeWithError(error))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemverCore(a: string, b: string): number {
|
||||||
|
const pa = parseSemverCore(a)
|
||||||
|
const pb = parseSemverCore(b)
|
||||||
|
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
|
||||||
|
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
|
||||||
|
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
|
||||||
|
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
|
||||||
|
const parts = core.split(".")
|
||||||
|
const parsePart = (input: string | undefined) => {
|
||||||
|
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
major: parsePart(parts[0]),
|
||||||
|
minor: parsePart(parts[1]),
|
||||||
|
patch: parsePart(parts[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,9 +95,16 @@ export class InstanceEventBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
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)
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: "text/event-stream" },
|
headers,
|
||||||
signal,
|
signal,
|
||||||
dispatcher: STREAM_AGENT,
|
dispatcher: STREAM_AGENT,
|
||||||
})
|
})
|
||||||
@@ -158,8 +165,32 @@ export class InstanceEventBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
const parsed = JSON.parse(payload) as any
|
||||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
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")) {
|
if (this.options.logger.isLevelEnabled("trace")) {
|
||||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { connect } from "net"
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import type { BinaryResolver } from "../settings/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
@@ -11,16 +11,25 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
|
|||||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||||
|
import {
|
||||||
|
buildOpencodeBasicAuthHeader,
|
||||||
|
DEFAULT_OPENCODE_USERNAME,
|
||||||
|
generateOpencodeServerPassword,
|
||||||
|
OPENCODE_SERVER_PASSWORD_ENV,
|
||||||
|
OPENCODE_SERVER_USERNAME_ENV,
|
||||||
|
} from "./opencode-auth"
|
||||||
|
|
||||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
binaryResolver: BinaryResolver
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
|
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
|
||||||
|
nodeExtraCaCertsPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||||
@@ -29,6 +38,7 @@ export class WorkspaceManager {
|
|||||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||||
private readonly runtime: WorkspaceRuntime
|
private readonly runtime: WorkspaceRuntime
|
||||||
private readonly opencodeConfigDir: string
|
private readonly opencodeConfigDir: string
|
||||||
|
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
||||||
|
|
||||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||||
@@ -47,6 +57,10 @@ export class WorkspaceManager {
|
|||||||
return this.workspaces.get(id)?.port
|
return this.workspaces.get(id)?.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInstanceAuthorizationHeader(id: string): string | undefined {
|
||||||
|
return this.opencodeAuth.get(id)?.authorization
|
||||||
|
}
|
||||||
|
|
||||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||||
const workspace = this.requireWorkspace(workspaceId)
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
@@ -72,14 +86,14 @@ export class WorkspaceManager {
|
|||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryResolver.resolveDefault()
|
||||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
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 = {
|
const descriptor: WorkspaceRecord = {
|
||||||
@@ -95,22 +109,31 @@ export class WorkspaceManager {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!descriptor.binaryVersion) {
|
|
||||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.workspaces.set(id, descriptor)
|
this.workspaces.set(id, descriptor)
|
||||||
|
|
||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const envVars = (serverConfig as any)?.environmentVariables
|
||||||
|
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||||
|
|
||||||
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
|
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
|
||||||
|
if (!authorization) {
|
||||||
|
throw new Error("Failed to build OpenCode auth header")
|
||||||
|
}
|
||||||
|
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
|
||||||
|
|
||||||
const environment = {
|
const environment = {
|
||||||
...userEnvironment,
|
...userEnvironment,
|
||||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||||
CODENOMAD_INSTANCE_ID: id,
|
CODENOMAD_INSTANCE_ID: id,
|
||||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -122,7 +145,10 @@ export class WorkspaceManager {
|
|||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||||
|
if (runtimeVersion) {
|
||||||
|
descriptor.binaryVersion = runtimeVersion
|
||||||
|
}
|
||||||
|
|
||||||
descriptor.pid = pid
|
descriptor.pid = pid
|
||||||
descriptor.port = port
|
descriptor.port = port
|
||||||
@@ -154,6 +180,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.workspaces.delete(id)
|
this.workspaces.delete(id)
|
||||||
|
this.opencodeAuth.delete(id)
|
||||||
clearWorkspaceSearchCache(workspace.path)
|
clearWorkspaceSearchCache(workspace.path)
|
||||||
if (!wasRunning) {
|
if (!wasRunning) {
|
||||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||||
@@ -163,17 +190,29 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
this.options.logger.info("Shutting down all workspaces")
|
this.options.logger.info("Shutting down all workspaces")
|
||||||
|
|
||||||
|
const stopTasks: Array<Promise<void>> = []
|
||||||
|
|
||||||
for (const [id, workspace] of this.workspaces) {
|
for (const [id, workspace] of this.workspaces) {
|
||||||
if (workspace.pid) {
|
if (!workspace.pid) {
|
||||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
|
||||||
await this.runtime.stop(id).catch((error) => {
|
|
||||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||||
|
stopTasks.push(
|
||||||
|
this.runtime.stop(id).catch((error) => {
|
||||||
|
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stopTasks.length > 0) {
|
||||||
|
await Promise.allSettled(stopTasks)
|
||||||
|
}
|
||||||
|
|
||||||
this.workspaces.clear()
|
this.workspaces.clear()
|
||||||
|
this.opencodeAuth.clear()
|
||||||
this.options.logger.info("All workspaces cleared")
|
this.options.logger.info("All workspaces cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +239,15 @@ export class WorkspaceManager {
|
|||||||
try {
|
try {
|
||||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||||
if (result.status === 0 && result.stdout) {
|
if (result.status === 0 && result.stdout) {
|
||||||
const resolved = result.stdout
|
const candidates = result.stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.find((line) => line.length > 0)
|
.filter((line) => line.length > 0)
|
||||||
|
.filter((line) => !/^INFO:/i.test(line))
|
||||||
|
|
||||||
if (resolved) {
|
if (candidates.length > 0) {
|
||||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
const resolved = this.pickBinaryCandidate(candidates)
|
||||||
|
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
@@ -219,34 +260,21 @@ export class WorkspaceManager {
|
|||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
private pickBinaryCandidate(candidates: string[]): string {
|
||||||
if (!resolvedPath) {
|
if (process.platform !== "win32") {
|
||||||
return undefined
|
return candidates[0] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
|
||||||
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
|
||||||
if (result.status === 0 && result.stdout) {
|
for (const ext of extensionPreference) {
|
||||||
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
|
||||||
if (line) {
|
if (match) {
|
||||||
const normalized = line.trim()
|
return match
|
||||||
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
|
return candidates[0] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForWorkspaceReadiness(params: {
|
private async waitForWorkspaceReadiness(params: {
|
||||||
@@ -254,7 +282,7 @@ export class WorkspaceManager {
|
|||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}) {
|
}): Promise<string | undefined> {
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.waitForPortAvailability(params.port),
|
this.waitForPortAvailability(params.port),
|
||||||
@@ -268,7 +296,7 @@ export class WorkspaceManager {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
await this.waitForInstanceHealth(params)
|
const version = await this.waitForInstanceHealth(params)
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||||
@@ -281,6 +309,8 @@ export class WorkspaceManager {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForInstanceHealth(params: {
|
private async waitForInstanceHealth(params: {
|
||||||
@@ -288,7 +318,7 @@ export class WorkspaceManager {
|
|||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}) {
|
}): Promise<string | undefined> {
|
||||||
const probeResult = await Promise.race([
|
const probeResult = await Promise.race([
|
||||||
this.probeInstance(params.workspaceId, params.port),
|
this.probeInstance(params.workspaceId, params.port),
|
||||||
params.exitPromise.then((info) => {
|
params.exitPromise.then((info) => {
|
||||||
@@ -302,7 +332,7 @@ export class WorkspaceManager {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (probeResult.ok) {
|
if (probeResult.ok) {
|
||||||
return
|
return probeResult.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestOutput = params.getLastOutput().trim()
|
const latestOutput = params.getLastOutput().trim()
|
||||||
@@ -313,17 +343,37 @@ export class WorkspaceManager {
|
|||||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
private async probeInstance(
|
||||||
const url = `http://127.0.0.1:${port}/project/current`
|
workspaceId: string,
|
||||||
|
port: number,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||||
|
const url = `http://127.0.0.1:${port}/global/health`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const headers: Record<string, string> = {}
|
||||||
|
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = `health probe returned HTTP ${response.status}`
|
const reason = `/global/health returned HTTP ${response.status}`
|
||||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
return { ok: false, reason }
|
return { ok: false, reason }
|
||||||
}
|
}
|
||||||
return { ok: true }
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
||||||
|
const healthy = payload?.healthy === true
|
||||||
|
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
||||||
|
|
||||||
|
if (!healthy) {
|
||||||
|
const reason = "Instance reported unhealthy"
|
||||||
|
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
||||||
|
return { ok: false, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, version: version || undefined }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||||
@@ -408,6 +458,8 @@ export class WorkspaceManager {
|
|||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|
||||||
|
this.opencodeAuth.delete(workspaceId)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||||
|
|
||||||
workspace.pid = undefined
|
workspace.pid = undefined
|
||||||
|
|||||||
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
|
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
|
||||||
|
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
|
||||||
|
|
||||||
|
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
|
||||||
|
|
||||||
|
export function generateOpencodeServerPassword(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
|
||||||
|
const username = params.username
|
||||||
|
const password = params.password
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
@@ -1,10 +1,116 @@
|
|||||||
import { ChildProcess, spawn } from "child_process"
|
import { ChildProcess, spawn, spawnSync } from "child_process"
|
||||||
import { existsSync, statSync } from "fs"
|
import { existsSync, statSync } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(binaryPath).toLowerCase()
|
||||||
|
|
||||||
|
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
// cmd.exe requires the full command as a single string.
|
||||||
|
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||||
|
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: comspec,
|
||||||
|
args: ["/d", "/s", "/c", commandLine],
|
||||||
|
options: { windowsVerbatimArguments: true } as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||||
|
// powershell.exe ships with Windows. (pwsh may not.)
|
||||||
|
return {
|
||||||
|
command: "powershell.exe",
|
||||||
|
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||||
|
options: {} as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: binaryPath, args, options: {} as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const redacted: Record<string, string | undefined> = {}
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
redacted[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
interface LaunchOptions {
|
interface LaunchOptions {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
folder: string
|
folder: string
|
||||||
@@ -59,22 +165,41 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||||
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
folder: options.folder,
|
folder: options.folder,
|
||||||
binary: options.binaryPath,
|
binary: options.binaryPath,
|
||||||
args,
|
spawnCommand: spec.command,
|
||||||
commandLine,
|
commandLine,
|
||||||
env,
|
|
||||||
},
|
},
|
||||||
"Launching OpenCode process",
|
"Launching OpenCode process",
|
||||||
)
|
)
|
||||||
const child = spawn(options.binaryPath, args, {
|
|
||||||
|
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,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached,
|
||||||
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const managed: ManagedProcess = { child, requestedStop: false }
|
const managed: ManagedProcess = { child, requestedStop: false }
|
||||||
@@ -207,10 +332,96 @@ export class WorkspaceRuntime {
|
|||||||
const child = managed.child
|
const child = managed.child
|
||||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlreadyExited = () => child.exitCode !== null || child.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
|
||||||
|
}
|
||||||
|
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
|
||||||
|
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") || combined.includes("process") && combined.includes("not")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Best-effort: terminate the whole process tree rooted at pid.
|
||||||
|
// Use /F only for escalation.
|
||||||
|
tryTaskkill(signal === "SIGKILL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
|
||||||
|
const groupOk = tryKillPosixGroup(signal)
|
||||||
|
if (!groupOk) {
|
||||||
|
// Fallback to direct PID kill.
|
||||||
|
tryKillSinglePid(signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let escalationTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
child.removeListener("exit", onExit)
|
child.removeListener("exit", onExit)
|
||||||
child.removeListener("error", onError)
|
child.removeListener("error", onError)
|
||||||
|
if (escalationTimer) {
|
||||||
|
clearTimeout(escalationTimer)
|
||||||
|
escalationTimer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onExit = () => {
|
const onExit = () => {
|
||||||
@@ -222,32 +433,30 @@ export class WorkspaceRuntime {
|
|||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveIfAlreadyExited = () => {
|
if (isAlreadyExited()) {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
cleanup()
|
||||||
cleanup()
|
resolve()
|
||||||
resolve()
|
return
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
child.once("exit", onExit)
|
child.once("exit", onExit)
|
||||||
child.once("error", onError)
|
child.once("error", onError)
|
||||||
|
|
||||||
if (resolveIfAlreadyExited()) {
|
this.logger.debug(
|
||||||
return
|
{ workspaceId, pid, detached: process.platform !== "win32" },
|
||||||
}
|
"Sending SIGTERM to workspace process (tree/group)",
|
||||||
|
)
|
||||||
|
sendStopSignal("SIGTERM")
|
||||||
|
|
||||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
escalationTimer = setTimeout(() => {
|
||||||
child.kill("SIGTERM")
|
escalationTimer = null
|
||||||
setTimeout(() => {
|
if (isAlreadyExited()) {
|
||||||
if (!child.killed) {
|
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
|
||||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
return
|
||||||
child.kill("SIGKILL")
|
|
||||||
} else {
|
|
||||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
|
||||||
}
|
}
|
||||||
|
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
|
||||||
|
sendStopSignal("SIGKILL")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
1933
packages/tauri-app/Cargo.lock
generated
1933
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { execSync } = require("child_process")
|
const { execSync } = require("child_process")
|
||||||
|
const { pathToFileURL } = require("url")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..")
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..")
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
|
|||||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
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() {
|
function ensureUiBuild() {
|
||||||
const loadingHtml = path.join(uiDist, "loading.html")
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
if (fs.existsSync(loadingHtml)) {
|
if (fs.existsSync(loadingHtml)) {
|
||||||
@@ -42,5 +57,11 @@ function copyUiLoadingAssets() {
|
|||||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureUiBuild()
|
;(async () => {
|
||||||
copyUiLoadingAssets()
|
await ensureMonacoAssets()
|
||||||
|
ensureUiBuild()
|
||||||
|
copyUiLoadingAssets()
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("[dev-prep] failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { execSync } = require("child_process")
|
const { execSync } = require("child_process")
|
||||||
|
const { pathToFileURL } = require("url")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..")
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..")
|
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"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -37,6 +39,20 @@ const braceExpansionPath = path.join(
|
|||||||
|
|
||||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
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() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist")
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public")
|
const publicPath = path.join(serverRoot, "public")
|
||||||
@@ -76,6 +92,15 @@ function ensureUiBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncServerUiBundle() {
|
||||||
|
console.log("[prebuild] syncing server public UI bundle...")
|
||||||
|
execSync(serverPrepareUiCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
return
|
return
|
||||||
@@ -166,6 +191,44 @@ function copyServerArtifacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = path.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 = path.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) {
|
||||||
|
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyUiLoadingAssets() {
|
function copyUiLoadingAssets() {
|
||||||
const loadingSource = path.join(uiDist, "loading.html")
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
const assetsSource = path.join(uiDist, "assets")
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
@@ -185,11 +248,19 @@ function copyUiLoadingAssets() {
|
|||||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureServerDevDependencies()
|
;(async () => {
|
||||||
ensureUiDevDependencies()
|
ensureServerDevDependencies()
|
||||||
ensureRollupPlatformBinary()
|
ensureUiDevDependencies()
|
||||||
ensureServerDependencies()
|
await ensureMonacoAssets()
|
||||||
ensureServerBuild()
|
ensureRollupPlatformBinary()
|
||||||
ensureUiBuild()
|
ensureServerDependencies()
|
||||||
copyServerArtifacts()
|
ensureServerBuild()
|
||||||
copyUiLoadingAssets()
|
ensureUiBuild()
|
||||||
|
syncServerUiBundle()
|
||||||
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
|
copyUiLoadingAssets()
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("[prebuild] failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.1.0"
|
version = "0.12.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.2", features = [] }
|
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"] }
|
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
@@ -17,7 +19,12 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
keepawake = "0.6"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"identifier": "main-window-native-dialogs",
|
"identifier": "main-window-native-dialogs",
|
||||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||||
"remote": {
|
"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"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
|
"opener:allow-open-url",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-show",
|
||||||
"core:webview:allow-set-webview-zoom"
|
"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,204 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save 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`",
|
"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",
|
"type": "string",
|
||||||
|
|||||||
@@ -2378,6 +2378,204 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save 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`",
|
"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",
|
"type": "string",
|
||||||
|
|||||||
@@ -7,19 +7,36 @@ use std::collections::VecDeque;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn configure_spawn(command: &mut Command) {
|
||||||
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn configure_spawn(_command: &mut Command) {}
|
||||||
|
|
||||||
fn workspace_root() -> Option<PathBuf> {
|
fn workspace_root() -> Option<PathBuf> {
|
||||||
std::env::current_dir().ok().and_then(|mut dir| {
|
std::env::current_dir().ok().and_then(|mut dir| {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
@@ -31,9 +48,57 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||||
|
|
||||||
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn configure_posix_process_group(command: &mut Command) {
|
||||||
|
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
||||||
|
// processes (login shell/tsx) without leaving the server orphaned.
|
||||||
|
unsafe {
|
||||||
|
command.pre_exec(|| {
|
||||||
|
if libc::setpgid(0, 0) != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
||||||
|
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
||||||
|
if force {
|
||||||
|
args.push("/F".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut command = Command::new("taskkill");
|
||||||
|
command.args(&args);
|
||||||
|
configure_spawn(&mut command);
|
||||||
|
|
||||||
|
match command.output() {
|
||||||
|
Ok(output) => {
|
||||||
|
if output.status.success() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the PID is already gone, treat it as success.
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||||
|
let combined = format!("{stdout}\n{stderr}");
|
||||||
|
combined.contains("not found") || combined.contains("no running instance")
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
log_line(&format!("navigating main to {url}"));
|
let mut display = url.to_string();
|
||||||
|
if let Some(hash_index) = display.find('#') {
|
||||||
|
display.replace_range(hash_index + 1.., "[REDACTED]");
|
||||||
|
}
|
||||||
|
log_line(&format!("navigating main to {display}"));
|
||||||
if let Ok(parsed) = Url::parse(url) {
|
if let Ok(parsed) = Url::parse(url) {
|
||||||
let _ = win.navigate(parsed);
|
let _ = win.navigate(parsed);
|
||||||
} else {
|
} else {
|
||||||
@@ -44,6 +109,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{name}=");
|
||||||
|
let cookie_kv = set_cookie.split(';').next()?.trim();
|
||||||
|
if !cookie_kv.starts_with(&prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let parsed = Url::parse(base_url)?;
|
||||||
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||||
|
|
||||||
|
// This is only used for local bootstrap; we assume plain HTTP.
|
||||||
|
let mut stream = TcpStream::connect((host, port))?;
|
||||||
|
|
||||||
|
let body = format!("{{\"token\":\"{}\"}}", token);
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.as_bytes().len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
stream.write_all(request.as_bytes())?;
|
||||||
|
stream.flush()?;
|
||||||
|
|
||||||
|
let mut response = String::new();
|
||||||
|
stream.read_to_string(&mut response)?;
|
||||||
|
|
||||||
|
let (raw_headers, _rest) = response
|
||||||
|
.split_once("\r\n\r\n")
|
||||||
|
.or_else(|| response.split_once("\n\n"))
|
||||||
|
.unwrap_or((response.as_str(), ""));
|
||||||
|
|
||||||
|
let mut lines = raw_headers.lines();
|
||||||
|
let status_line = lines.next().unwrap_or("");
|
||||||
|
if !status_line.contains(" 200 ") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
// handle case-insensitive header name
|
||||||
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||||
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
|
return Ok(Some(session_id));
|
||||||
|
}
|
||||||
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||||
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
|
return Ok(Some(session_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||||
|
let parsed = Url::parse(base_url)?;
|
||||||
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||||
|
|
||||||
|
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||||
|
.domain(domain)
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(tauri::webview::cookie::SameSite::Lax)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
|
win.set_cookie(cookie)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -53,16 +197,44 @@ struct PreferencesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AppConfig {
|
struct ServerConfig {
|
||||||
preferences: Option<PreferencesConfig>,
|
#[serde(rename = "listeningMode")]
|
||||||
|
listening_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_path() -> PathBuf {
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AppConfig {
|
||||||
|
preferences: Option<PreferencesConfig>,
|
||||||
|
server: Option<ServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||||
let raw = env::var("CLI_CONFIG")
|
let raw = env::var("CLI_CONFIG")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||||
expand_home(&raw)
|
|
||||||
|
let expanded = expand_home(&raw);
|
||||||
|
let lower = raw.trim().to_lowercase();
|
||||||
|
|
||||||
|
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
|
||||||
|
let base = expanded
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| expanded.clone());
|
||||||
|
return (expanded, base.join("config.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.ends_with(".json") {
|
||||||
|
let base = expanded
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| expanded.clone());
|
||||||
|
return (base.join("config.yaml"), expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as directory.
|
||||||
|
(expanded.join("config.yaml"), expanded.join("config.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_home(path: &str) -> PathBuf {
|
fn expand_home(path: &str) -> PathBuf {
|
||||||
@@ -75,14 +247,46 @@ fn expand_home(path: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_listening_mode() -> String {
|
fn resolve_listening_mode() -> String {
|
||||||
let path = resolve_config_path();
|
let (yaml_path, json_path) = resolve_config_locations();
|
||||||
if let Ok(content) = fs::read_to_string(path) {
|
|
||||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
if let Some(mode) = config
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||||
.preferences
|
let mode = config
|
||||||
|
.server
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
{
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(mode) = mode {
|
||||||
|
if mode == "local" {
|
||||||
|
return "local".to_string();
|
||||||
|
}
|
||||||
|
if mode == "all" {
|
||||||
|
return "all".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback.
|
||||||
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
|
let mode = config
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
});
|
||||||
|
if let Some(mode) = mode {
|
||||||
if mode == "local" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
@@ -139,6 +343,7 @@ pub struct CliProcessManager {
|
|||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliProcessManager {
|
impl CliProcessManager {
|
||||||
@@ -147,6 +352,7 @@ impl CliProcessManager {
|
|||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +360,7 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("start requested (dev={dev})"));
|
log_line(&format!("start requested (dev={dev})"));
|
||||||
self.stop()?;
|
self.stop()?;
|
||||||
self.ready.store(false, Ordering::SeqCst);
|
self.ready.store(false, Ordering::SeqCst);
|
||||||
|
*self.bootstrap_token.lock() = None;
|
||||||
{
|
{
|
||||||
let mut status = self.status.lock();
|
let mut status = self.status.lock();
|
||||||
status.state = CliState::Starting;
|
status.state = CliState::Starting;
|
||||||
@@ -167,8 +374,16 @@ impl CliProcessManager {
|
|||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
if let Err(err) = Self::spawn_cli(
|
||||||
|
app.clone(),
|
||||||
|
status_arc.clone(),
|
||||||
|
child_arc,
|
||||||
|
ready_flag,
|
||||||
|
token_arc,
|
||||||
|
dev,
|
||||||
|
) {
|
||||||
log_line(&format!("cli spawn failed: {err}"));
|
log_line(&format!("cli spawn failed: {err}"));
|
||||||
let mut locked = status_arc.lock();
|
let mut locked = status_arc.lock();
|
||||||
locked.state = CliState::Error;
|
locked.state = CliState::Error;
|
||||||
@@ -186,13 +401,22 @@ impl CliProcessManager {
|
|||||||
pub fn stop(&self) -> anyhow::Result<()> {
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
let pid = child.id() as i32;
|
||||||
|
// Prefer signaling the process group to avoid orphaning children
|
||||||
|
// when the CLI was launched via a wrapper shell.
|
||||||
|
let group_res = libc::kill(-pid, libc::SIGTERM);
|
||||||
|
if group_res != 0 {
|
||||||
|
let _ = libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = child.kill();
|
if !kill_process_tree_windows(child.id(), false) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -200,14 +424,25 @@ impl CliProcessManager {
|
|||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
if start.elapsed() > Duration::from_secs(4) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
|
log_line(&format!(
|
||||||
|
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||||
|
CLI_STOP_GRACE_SECS,
|
||||||
|
child.id()
|
||||||
|
));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
let pid = child.id() as i32;
|
||||||
|
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||||
|
if group_res != 0 {
|
||||||
|
let _ = libc::kill(pid, libc::SIGKILL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = child.kill();
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -237,6 +472,7 @@ impl CliProcessManager {
|
|||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
log_line("resolving CLI entry");
|
log_line("resolving CLI entry");
|
||||||
@@ -270,7 +506,9 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
if !supports_user_shell() {
|
if !supports_user_shell() {
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Node binary not found. Make sure Node.js is installed."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,9 +520,12 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
configure_posix_process_group(&mut c);
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
ShellCommandType::Direct(cmd) => {
|
ShellCommandType::Direct(cmd) => {
|
||||||
@@ -294,9 +535,12 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
configure_posix_process_group(&mut c);
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -318,6 +562,7 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
|
let token_clone = bootstrap_token.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
@@ -332,10 +577,24 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
Self::process_stream(
|
||||||
|
reader,
|
||||||
|
"stdout",
|
||||||
|
&app_clone,
|
||||||
|
&status_clone,
|
||||||
|
&ready_clone,
|
||||||
|
&token_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
Self::process_stream(
|
||||||
|
reader,
|
||||||
|
"stderr",
|
||||||
|
&app_clone,
|
||||||
|
&status_clone,
|
||||||
|
&ready_clone,
|
||||||
|
&token_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,7 +613,24 @@ impl CliProcessManager {
|
|||||||
locked.error = Some("CLI did not start in time".to_string());
|
locked.error = Some("CLI did not start in time".to_string());
|
||||||
log_line("timeout waiting for CLI readiness");
|
log_line("timeout waiting for CLI readiness");
|
||||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||||
let _ = child.kill();
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
let pid = child.id() as i32;
|
||||||
|
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||||
|
if group_res != 0 {
|
||||||
|
let _ = libc::kill(pid, libc::SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||||
Self::emit_status(&app_clone, &locked);
|
Self::emit_status(&app_clone, &locked);
|
||||||
@@ -363,13 +639,33 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let code = {
|
// Do not hold the child mutex while waiting for process exit.
|
||||||
let mut guard = child_holder.lock();
|
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
|
||||||
if let Some(child) = guard.as_mut() {
|
// same lock to send SIGTERM/SIGKILL when the user quits the app.
|
||||||
child.wait().ok()
|
let code = loop {
|
||||||
} else {
|
let maybe_exited = {
|
||||||
None
|
let mut guard = child_holder.lock();
|
||||||
|
if guard.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match guard
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|child| child.try_wait().ok().flatten())
|
||||||
|
{
|
||||||
|
Some(status) => {
|
||||||
|
// Drop the handle after the process exits so other callers
|
||||||
|
// don't attempt to stop/kill a finished process.
|
||||||
|
*guard = None;
|
||||||
|
Some(status)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(status) = maybe_exited {
|
||||||
|
break Some(status);
|
||||||
}
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut locked = status_clone.lock();
|
let mut locked = status_clone.lock();
|
||||||
@@ -388,8 +684,14 @@ impl CliProcessManager {
|
|||||||
if locked.error.is_none() {
|
if locked.error.is_none() {
|
||||||
locked.error = err_msg.clone();
|
locked.error = err_msg.clone();
|
||||||
}
|
}
|
||||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
log_line(&format!(
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
"cli process exited before ready: {:?}",
|
||||||
|
locked.error
|
||||||
|
));
|
||||||
|
let _ = app_clone.emit(
|
||||||
|
"cli:error",
|
||||||
|
json!({"message": locked.error.clone().unwrap_or_default()}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
locked.state = CliState::Stopped;
|
locked.state = CliState::Stopped;
|
||||||
log_line("cli process stopped cleanly");
|
log_line("cli process stopped cleanly");
|
||||||
@@ -407,10 +709,12 @@ impl CliProcessManager {
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||||
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
@@ -419,18 +723,29 @@ impl CliProcessManager {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let line = buffer.trim_end();
|
let line = buffer.trim_end();
|
||||||
if !line.is_empty() {
|
if !line.is_empty() {
|
||||||
|
if line.starts_with(token_prefix) {
|
||||||
|
let token = line.trim_start_matches(token_prefix).trim();
|
||||||
|
if !token.is_empty() {
|
||||||
|
let mut guard = bootstrap_token.lock();
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(token.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
log_line(&format!("[cli][{}] {}", stream, line));
|
log_line(&format!("[cli][{}] {}", stream, line));
|
||||||
|
|
||||||
if ready.load(Ordering::SeqCst) {
|
if ready.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(port) = port_regex
|
if let Some(url) = local_url_regex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.map(|m| m.as_str().to_string())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,13 +755,25 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, port);
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
format!("http://localhost:{port}"),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||||
Self::mark_ready(app, status, ready, port as u16);
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
format!("http://localhost:{}", port),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,16 +785,56 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
fn mark_ready(
|
||||||
|
app: &AppHandle,
|
||||||
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
|
ready: &Arc<AtomicBool>,
|
||||||
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
base_url: String,
|
||||||
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
|
let port = Url::parse(&base_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.port_or_known_default())
|
||||||
|
.map(|p| p as u16);
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
let url = format!("http://127.0.0.1:{port}");
|
locked.port = port;
|
||||||
locked.port = Some(port);
|
locked.url = Some(base_url.clone());
|
||||||
locked.url = Some(url.clone());
|
|
||||||
locked.state = CliState::Ready;
|
locked.state = CliState::Ready;
|
||||||
locked.error = None;
|
locked.error = None;
|
||||||
log_line(&format!("cli ready on {url}"));
|
log_line(&format!("cli ready on {base_url}"));
|
||||||
navigate_main(app, &url);
|
|
||||||
|
let token = bootstrap_token.lock().take();
|
||||||
|
|
||||||
|
if let Some(token) = token {
|
||||||
|
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
|
||||||
|
// skip the exchange and let the user authenticate normally.
|
||||||
|
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
|
||||||
|
if scheme.as_deref() != Some("http") {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
} else {
|
||||||
|
match exchange_bootstrap_token(&base_url, &token) {
|
||||||
|
Ok(Some(session_id)) => {
|
||||||
|
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||||
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
} else {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log_line("bootstrap token exchange failed (invalid token)");
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
}
|
||||||
let _ = app.emit("cli:ready", locked.clone());
|
let _ = app.emit("cli:ready", locked.clone());
|
||||||
Self::emit_status(app, &locked);
|
Self::emit_status(app, &locked);
|
||||||
}
|
}
|
||||||
@@ -549,14 +916,42 @@ impl CliEntry {
|
|||||||
"serve".to_string(),
|
"serve".to_string(),
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
host.to_string(),
|
host.to_string(),
|
||||||
"--port".to_string(),
|
"--generate-token".to_string(),
|
||||||
"0".to_string(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
|
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var("ELECTRON_RENDERER_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||||
|
let log_level = std::env::var("CLI_LOG_LEVEL")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_lowercase())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "info".to_string());
|
||||||
|
|
||||||
|
args.push("--https".to_string());
|
||||||
|
args.push("false".to_string());
|
||||||
|
args.push("--http".to_string());
|
||||||
|
args.push("true".to_string());
|
||||||
|
args.push("--http-port".to_string());
|
||||||
|
args.push("0".to_string());
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
args.push("http://localhost:3000".to_string());
|
args.push(ui_dev_server);
|
||||||
args.push("--log-level".to_string());
|
args.push("--log-level".to_string());
|
||||||
args.push("debug".to_string());
|
args.push(log_level);
|
||||||
|
} else {
|
||||||
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
|
args.push("--https".to_string());
|
||||||
|
args.push("true".to_string());
|
||||||
|
args.push("--http".to_string());
|
||||||
|
args.push("true".to_string());
|
||||||
}
|
}
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
@@ -581,9 +976,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
std::env::current_exe()
|
std::env::current_exe().ok().and_then(|ex| {
|
||||||
.ok()
|
ex.parent()
|
||||||
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
@@ -606,13 +1002,19 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
let base = workspace_root();
|
let base = workspace_root();
|
||||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
base.as_ref()
|
||||||
|
.map(|p| p.join("packages/server/dist/index.js")),
|
||||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||||
@@ -621,7 +1023,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
candidates.push(Some(
|
||||||
|
resources.join("resources/server/dist/server/index.js"),
|
||||||
|
));
|
||||||
|
|
||||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
for root in linux_resource_roots {
|
for root in linux_resource_roots {
|
||||||
@@ -640,8 +1044,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
fn build_shell_command_string(
|
||||||
|
entry: &CliEntry,
|
||||||
|
cli_args: &[String],
|
||||||
|
) -> anyhow::Result<ShellCommand> {
|
||||||
let shell = default_shell();
|
let shell = default_shell();
|
||||||
let mut quoted: Vec<String> = Vec::new();
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
quoted.push(shell_escape(&entry.node_binary));
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
@@ -672,7 +1078,7 @@ fn shell_escape(input: &str) -> String {
|
|||||||
"''".to_string()
|
"''".to_string()
|
||||||
} else if !input
|
} else if !input
|
||||||
.chars()
|
.chars()
|
||||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
|
||||||
{
|
{
|
||||||
input.to_string()
|
input.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -704,9 +1110,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: PathBuf) -> String {
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
if let Ok(clean) = path.canonicalize() {
|
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||||
clean.to_string_lossy().to_string()
|
clean
|
||||||
} else {
|
} else {
|
||||||
path.to_string_lossy().to_string()
|
path
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = resolved.to_string_lossy().to_string();
|
||||||
|
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
||||||
|
format!("\\\\{}", stripped)
|
||||||
|
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
||||||
|
stripped.to_string()
|
||||||
|
} else {
|
||||||
|
rendered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
|
use keepawake::KeepAwake;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
@@ -11,9 +15,31 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[cfg(windows)]
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::iter;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||||
|
|
||||||
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
struct WakeLockConfig {
|
||||||
|
display: bool,
|
||||||
|
idle: bool,
|
||||||
|
sleep: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -32,6 +58,39 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn wake_lock_start(
|
||||||
|
state: tauri::State<AppState>,
|
||||||
|
config: Option<WakeLockConfig>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let config = config.unwrap_or(WakeLockConfig {
|
||||||
|
display: true,
|
||||||
|
idle: false,
|
||||||
|
sleep: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut builder = keepawake::Builder::default();
|
||||||
|
builder
|
||||||
|
.display(config.display)
|
||||||
|
.idle(config.idle)
|
||||||
|
.sleep(config.sleep)
|
||||||
|
.reason("CodeNomad active session")
|
||||||
|
.app_name("CodeNomad")
|
||||||
|
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
||||||
|
|
||||||
|
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
||||||
|
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||||
|
*state_lock = Some(wake_lock);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
||||||
|
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||||
|
state_lock.take();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
}
|
}
|
||||||
@@ -39,7 +98,13 @@ fn is_dev_mode() -> bool {
|
|||||||
fn should_allow_internal(url: &Url) -> bool {
|
fn should_allow_internal(url: &Url) -> bool {
|
||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
"tauri" | "asset" | "file" => true,
|
"tauri" | "asset" | "file" => true,
|
||||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
|
// This must be treated as an internal origin or the navigation guard will
|
||||||
|
// redirect it to the system browser and the app will appear blank.
|
||||||
|
"http" | "https" => matches!(
|
||||||
|
url.host_str(),
|
||||||
|
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||||
|
),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +124,55 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|path| match std::fs::metadata(path) {
|
||||||
|
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
||||||
|
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||||
|
let _ = window.emit(event_name, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_folder_drop_event(
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
window_label: &str,
|
||||||
|
event_name: &str,
|
||||||
|
paths: &[std::path::PathBuf],
|
||||||
|
) {
|
||||||
|
let directories = collect_directory_paths(paths);
|
||||||
|
|
||||||
|
if directories.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||||
|
let _ = window.emit(event_name, json!({ "paths": directories }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn set_windows_app_user_model_id() {
|
||||||
|
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
||||||
|
if result < 0 {
|
||||||
|
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn set_windows_app_user_model_id() {}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -67,11 +181,14 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
|
wake_lock: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
set_windows_app_user_model_id();
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
let dev_mode = is_dev_mode();
|
let dev_mode = is_dev_mode();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -83,7 +200,12 @@ fn main() {
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
cli_get_status,
|
||||||
|
cli_restart,
|
||||||
|
wake_lock_start,
|
||||||
|
wake_lock_stop
|
||||||
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
// File menu
|
// File menu
|
||||||
@@ -163,7 +285,13 @@ fn main() {
|
|||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app_handle, event| match event {
|
.run(|app_handle, event| match event {
|
||||||
tauri::RunEvent::ExitRequested { .. } => {
|
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||||
|
// `app_handle.exit(0)` triggers another `ExitRequested`. Without a guard, we can
|
||||||
|
// prevent exit forever and the app never quits (Cmd+Q / Quit menu appears stuck).
|
||||||
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.prevent_exit();
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
@@ -173,18 +301,42 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::Destroyed,
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if app_handle.webview_windows().len() <= 1 {
|
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
||||||
let app = app_handle.clone();
|
}
|
||||||
std::thread::spawn(move || {
|
tauri::RunEvent::WindowEvent {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
label,
|
||||||
let _ = state.manager.stop();
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
||||||
}
|
..
|
||||||
app.exit(0);
|
} => {
|
||||||
});
|
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Ensure we have time to stop the CLI process before the app exits.
|
||||||
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
api.prevent_close();
|
||||||
|
let app = app_handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
let _ = state.manager.stop();
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
@@ -216,13 +368,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"new_instance",
|
"new_instance",
|
||||||
"New Instance",
|
"New Instance",
|
||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+N")
|
Some("CmdOrCtrl+N"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "File")
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
.item(&new_instance_item)
|
.item(&new_instance_item)
|
||||||
.separator()
|
.separator()
|
||||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
.text(
|
||||||
|
if is_mac { "close" } else { "quit" },
|
||||||
|
if is_mac { "Close" } else { "Quit" },
|
||||||
|
)
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
@@ -245,7 +400,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.text("force_reload", "Force Reload")
|
.text("force_reload", "Force Reload")
|
||||||
.text("toggle_devtools", "Toggle Developer Tools")
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
|
|
||||||
.separator()
|
.separator()
|
||||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -259,9 +413,12 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// Build the main menu with all submenus
|
||||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||||
|
.iter()
|
||||||
|
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||||
|
.collect();
|
||||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.1.0",
|
"version": "0.12.3",
|
||||||
"identifier": "ai.opencode.client",
|
"identifier": "ai.neuralnomads.codenomad.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
|
|||||||
2
packages/ui/.gitignore
vendored
2
packages/ui/.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.vite/
|
.vite/
|
||||||
|
src/renderer/public/logo.png
|
||||||
|
src/renderer/public/monaco/
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.5.1",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -12,27 +13,36 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0",
|
||||||
|
"virtua": "^0.48.8",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"autoprefixer": "10.4.21",
|
"autoprefixer": "10.4.21",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"tailwindcss": "3",
|
"tailwindcss": "3",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type CopyMonacoPublicAssetsParams = {
|
||||||
|
uiRendererRoot: string
|
||||||
|
warn?: (message: string) => void
|
||||||
|
sourceRoots?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void
|
||||||
97
packages/ui/scripts/monaco-public-assets.js
Normal file
97
packages/ui/scripts/monaco-public-assets.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy Monaco's AMD `min/vs` assets into the UI renderer public folder.
|
||||||
|
*
|
||||||
|
* Monaco is loaded at runtime via `/monaco/vs/loader.js`. These assets are gitignored
|
||||||
|
* and generated on demand in dev/build so the repo stays clean.
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.uiRendererRoot Absolute path to `packages/ui/src/renderer`.
|
||||||
|
* @param {(message: string) => void} [params.warn] Warning logger.
|
||||||
|
* @param {string[]} [params.sourceRoots] Optional override list of `.../monaco-editor/min/vs` roots.
|
||||||
|
*/
|
||||||
|
export function copyMonacoPublicAssets(params) {
|
||||||
|
const uiRendererRoot = params?.uiRendererRoot
|
||||||
|
if (!uiRendererRoot) {
|
||||||
|
throw new Error("copyMonacoPublicAssets: uiRendererRoot is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = params?.warn ?? ((message) => console.warn(message))
|
||||||
|
const publicDir = resolve(uiRendererRoot, "public")
|
||||||
|
const destRoot = resolve(publicDir, "monaco/vs")
|
||||||
|
|
||||||
|
const candidates =
|
||||||
|
params?.sourceRoots?.length > 0
|
||||||
|
? params.sourceRoots
|
||||||
|
: [
|
||||||
|
// Workspace root hoisted deps.
|
||||||
|
resolve(process.cwd(), "node_modules/monaco-editor/min/vs"),
|
||||||
|
// UI package local deps (covers non-hoisted installs).
|
||||||
|
resolve(process.cwd(), "packages/ui/node_modules/monaco-editor/min/vs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js")))
|
||||||
|
if (!sourceRoot) {
|
||||||
|
warn("Monaco source directory not found; skipping copy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRecursive = (src, dest) => {
|
||||||
|
const stat = fs.statSync(src)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true })
|
||||||
|
for (const entry of fs.readdirSync(src)) {
|
||||||
|
copyRecursive(resolve(src, entry), resolve(dest, entry))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the working tree clean; these assets are generated.
|
||||||
|
try {
|
||||||
|
fs.rmSync(destRoot, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
fs.mkdirSync(destRoot, { recursive: true })
|
||||||
|
|
||||||
|
// Copy core Monaco runtime.
|
||||||
|
for (const dir of ["base", "editor", "platform"]) {
|
||||||
|
const src = resolve(sourceRoot, dir)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loader.js is required.
|
||||||
|
copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js"))
|
||||||
|
|
||||||
|
// Copy baseline rich language packages + workers.
|
||||||
|
for (const lang of ["typescript", "html", "json", "css"]) {
|
||||||
|
const src = resolve(sourceRoot, "language", lang)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, "language", lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy baseline basic tokenizers.
|
||||||
|
for (const lang of ["python", "markdown", "cpp", "kotlin"]) {
|
||||||
|
const src = resolve(sourceRoot, "basic-languages", lang)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, "basic-languages", lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy monaco.contribution.js entrypoints (needed by some loads).
|
||||||
|
const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
|
||||||
|
if (fs.existsSync(monacoContribution)) {
|
||||||
|
copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js"))
|
||||||
|
}
|
||||||
|
const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js")
|
||||||
|
if (fs.existsSync(underscoreContribution)) {
|
||||||
|
copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Toaster } from "solid-toast"
|
import { Toaster } from "solid-toast"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
|
import { Minimize2 } from "lucide-solid"
|
||||||
import AlertDialog from "./components/alert-dialog"
|
import AlertDialog from "./components/alert-dialog"
|
||||||
import FolderSelectionView from "./components/folder-selection-view"
|
import FolderSelectionView from "./components/folder-selection-view"
|
||||||
import { showConfirmDialog } from "./stores/alerts"
|
import { showConfirmDialog } from "./stores/alerts"
|
||||||
import InstanceTabs from "./components/instance-tabs"
|
import InstanceTabs from "./components/instance-tabs"
|
||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
|
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||||
|
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
|
import { useI18n } from "./lib/i18n"
|
||||||
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -46,47 +51,159 @@ import {
|
|||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
|
import { openSettings } from "./stores/settings-screen"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
|
serverSettings,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
interface LaunchErrorState {
|
|
||||||
message: string
|
|
||||||
binaryPath: string
|
|
||||||
missingBinary: boolean
|
|
||||||
}
|
|
||||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
|
||||||
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
|
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||||
|
|
||||||
|
// In-memory only: hides chrome on phone; may also request browser fullscreen.
|
||||||
|
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
|
||||||
|
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
|
||||||
|
|
||||||
|
const fullscreenSupported = () => {
|
||||||
|
if (typeof document === "undefined") return false
|
||||||
|
const el = document.documentElement as any
|
||||||
|
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncBrowserFullscreenState = () => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterMobileFullscreen = async () => {
|
||||||
|
if (!isPhoneLayout()) return
|
||||||
|
setMobileFullscreenMode(true)
|
||||||
|
if (!fullscreenSupported()) return
|
||||||
|
try {
|
||||||
|
await document.documentElement.requestFullscreen()
|
||||||
|
} catch {
|
||||||
|
// Ignore: immersive mode still works without browser fullscreen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitMobileFullscreen = async () => {
|
||||||
|
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
|
||||||
|
try {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMobileFullscreenMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const shouldShow =
|
||||||
|
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||||
|
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||||
|
})
|
||||||
|
|
||||||
const updateInstanceTabBarHeight = () => {
|
const updateInstanceTabBarHeight = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
syncBrowserFullscreenState()
|
||||||
|
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
||||||
|
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const vv = window.visualViewport
|
||||||
|
if (!vv) return
|
||||||
|
|
||||||
|
const updateKeyboardOffset = () => {
|
||||||
|
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
|
||||||
|
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
|
||||||
|
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
|
||||||
|
schedule()
|
||||||
|
vv.addEventListener("resize", schedule)
|
||||||
|
vv.addEventListener("scroll", schedule)
|
||||||
|
window.addEventListener("orientationchange", schedule)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
vv.removeEventListener("resize", schedule)
|
||||||
|
vv.removeEventListener("scroll", schedule)
|
||||||
|
window.removeEventListener("orientationchange", schedule)
|
||||||
|
document.documentElement.style.removeProperty("--keyboard-offset")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the user exits browser fullscreen via browser UI, restore chrome.
|
||||||
|
let lastBrowserFullscreen = false
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
const active = browserFullscreenActive()
|
||||||
|
const mode = mobileFullscreenMode()
|
||||||
|
if (mode && lastBrowserFullscreen && !active) {
|
||||||
|
setMobileFullscreenMode(false)
|
||||||
|
}
|
||||||
|
lastBrowserFullscreen = active
|
||||||
|
})
|
||||||
|
|
||||||
|
// If we leave phone layout (rotation / resize), restore chrome.
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
||||||
|
void exitMobileFullscreen()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shouldHoldWakeLock = createMemo(() => {
|
||||||
|
const map = instances()
|
||||||
|
for (const id of map.keys()) {
|
||||||
|
const status = getInstanceSessionIndicatorStatus(id)
|
||||||
|
if (status !== "idle") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const hold = shouldHoldWakeLock()
|
||||||
|
void setWakeLockDesired(hold)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
void setWakeLockDesired(false)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
instances()
|
instances()
|
||||||
hasInstances()
|
hasInstances()
|
||||||
@@ -94,6 +211,7 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
void initGithubStars()
|
||||||
updateInstanceTabBarHeight()
|
updateInstanceTabBarHeight()
|
||||||
const handleResize = () => updateInstanceTabBarHeight()
|
const handleResize = () => updateInstanceTabBarHeight()
|
||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
@@ -115,60 +233,26 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||||
|
|
||||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
|
||||||
if (!error) {
|
|
||||||
return "Failed to launch workspace"
|
|
||||||
}
|
|
||||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (parsed && typeof parsed.error === "string") {
|
|
||||||
return parsed.error
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore JSON parse errors
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMissingBinaryMessage = (message: string): boolean => {
|
|
||||||
const normalized = message.toLowerCase()
|
|
||||||
return (
|
|
||||||
normalized.includes("opencode binary not found") ||
|
|
||||||
normalized.includes("binary not found") ||
|
|
||||||
normalized.includes("no such file or directory") ||
|
|
||||||
normalized.includes("binary is not executable") ||
|
|
||||||
normalized.includes("enoent")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearLaunchError = () => setLaunchError(null)
|
|
||||||
|
|
||||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
instanceId,
|
instanceId,
|
||||||
port: instances().get(instanceId)?.port,
|
port: instances().get(instanceId)?.port,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = formatLaunchErrorMessage(error)
|
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||||
const missingBinary = isMissingBinaryMessage(message)
|
const missingBinary = isMissingBinaryMessage(message)
|
||||||
setLaunchError({
|
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||||
message,
|
|
||||||
binaryPath: selectedBinary,
|
|
||||||
missingBinary,
|
|
||||||
})
|
|
||||||
log.error("Failed to create instance", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
setIsSelectingFolder(false)
|
||||||
@@ -181,7 +265,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
function handleLaunchErrorAdvanced() {
|
function handleLaunchErrorAdvanced() {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
setIsAdvancedSettingsOpen(true)
|
openSettings("opencode")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
@@ -200,12 +284,12 @@ const App: Component = () => {
|
|||||||
|
|
||||||
async function handleCloseInstance(instanceId: string) {
|
async function handleCloseInstance(instanceId: string) {
|
||||||
const confirmed = await showConfirmDialog(
|
const confirmed = await showConfirmDialog(
|
||||||
"Stop OpenCode instance? This will stop the server.",
|
t("app.stopInstance.confirmMessage"),
|
||||||
{
|
{
|
||||||
title: "Stop instance",
|
title: t("app.stopInstance.title"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: "Stop",
|
confirmLabel: t("app.stopInstance.confirmLabel"),
|
||||||
cancelLabel: "Keep running",
|
cancelLabel: t("app.stopInstance.cancelLabel"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,12 +349,15 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
@@ -325,77 +412,102 @@ const App: Component = () => {
|
|||||||
<Dialog open={Boolean(launchError())} modal>
|
<Dialog open={Boolean(launchError())} modal>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
{t("app.launchError.description")}
|
||||||
binary from Advanced Settings.
|
</Dialog.Description>
|
||||||
</Dialog.Description>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
||||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||||
</div>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={launchErrorMessage()}>
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||||
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={launchErrorMessage()}>
|
<div class="flex justify-end gap-2">
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<Show when={launchError()?.missingBinary}>
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
<button
|
||||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
type="button"
|
||||||
</div>
|
class="selector-button selector-button-secondary"
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Show when={launchError()?.missingBinary}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary"
|
|
||||||
onClick={handleLaunchErrorAdvanced}
|
onClick={handleLaunchErrorAdvanced}
|
||||||
>
|
>
|
||||||
Open Advanced Settings
|
{t("app.launchError.openAdvancedSettings")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||||
Close
|
{t("app.launchError.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<div class="h-screen w-screen flex flex-col">
|
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
|
||||||
|
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
|
||||||
|
<div class="mobile-fullscreen-exit-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button mobile-fullscreen-exit-button"
|
||||||
|
onClick={() => void exitMobileFullscreen()}
|
||||||
|
aria-label={t("instanceShell.fullscreen.exit")}
|
||||||
|
title={t("instanceShell.fullscreen.exit")}
|
||||||
|
>
|
||||||
|
<Minimize2 class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={!hasInstances()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<InstanceTabs
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
instances={instances()}
|
<InstanceTabs
|
||||||
activeInstanceId={activeInstanceId()}
|
instances={instances()}
|
||||||
onSelect={setActiveInstanceId}
|
activeInstanceId={activeInstanceId()}
|
||||||
onClose={handleCloseInstance}
|
onSelect={setActiveInstanceId}
|
||||||
onNew={handleNewInstanceRequest}
|
onClose={handleCloseInstance}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<For each={Array.from(instances().values())}>
|
<For each={Array.from(instances().values())}>
|
||||||
{(instance) => {
|
{(instance) => {
|
||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
<div
|
||||||
<InstanceMetadataProvider instance={instance}>
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
<InstanceShell
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
instance={instance}
|
data-instance-id={instance.id}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||||
paletteCommands={paletteCommands}
|
data-instance-visible={isVisible() ? "true" : "false"}
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
>
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
<InstanceMetadataProvider instance={instance}>
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
<InstanceShell
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
instance={instance}
|
||||||
onExecuteCommand={executeCommand}
|
isActiveInstance={isActiveInstance()}
|
||||||
tabBarOffset={instanceTabBarHeight()}
|
escapeInDebounce={escapeInDebounce()}
|
||||||
|
paletteCommands={paletteCommands}
|
||||||
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||||
|
onNewSession={() => handleNewSession(instance.id)}
|
||||||
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
|
onExecuteCommand={executeCommand}
|
||||||
|
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||||
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
|
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||||
/>
|
/>
|
||||||
</InstanceMetadataProvider>
|
</InstanceMetadataProvider>
|
||||||
|
|
||||||
@@ -411,41 +523,25 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showFolderSelection()}>
|
<Show when={showFolderSelection()}>
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowFolderSelection(false)
|
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
clearLaunchError()
|
|
||||||
}}
|
|
||||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
title="Close (Esc)"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
onClose={() => {
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
setShowFolderSelection(false)
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
clearLaunchError()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
<SettingsScreen />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface AdvancedSettingsModalProps {
|
interface AdvancedSettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
@@ -19,7 +22,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("advancedSettings.title")}</Dialog.Title>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
@@ -32,8 +35,8 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3 class="panel-title">Environment Variables</h3>
|
<h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
<p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||||
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
Close
|
{t("advancedSettings.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface AgentSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentSelector(props: AgentSelectorProps) {
|
export default function AgentSelector(props: AgentSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||||
|
|
||||||
const session = createMemo(() => {
|
const session = createMemo(() => {
|
||||||
@@ -29,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
const availableAgents = createMemo(() => {
|
const availableAgents = createMemo(() => {
|
||||||
const allAgents = instanceAgents()
|
const allAgents = instanceAgents()
|
||||||
if (isChildSession()) {
|
if (isChildSession()) {
|
||||||
return allAgents
|
return allAgents.filter((agent) => !agent.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
|
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
|
||||||
|
|
||||||
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
||||||
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
||||||
@@ -71,7 +73,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
options={availableAgents()}
|
options={availableAgents()}
|
||||||
optionValue="name"
|
optionValue="name"
|
||||||
optionTextValue="name"
|
optionTextValue="name"
|
||||||
placeholder="Select agent..."
|
placeholder={t("agentSelector.placeholder")}
|
||||||
itemComponent={(itemProps) => (
|
itemComponent={(itemProps) => (
|
||||||
<Select.Item
|
<Select.Item
|
||||||
item={itemProps.item}
|
item={itemProps.item}
|
||||||
@@ -81,7 +83,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||||
<span>{itemProps.item.rawValue.name}</span>
|
<span>{itemProps.item.rawValue.name}</span>
|
||||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||||
<span class="neutral-badge">subagent</span>
|
<span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</Select.ItemLabel>
|
</Select.ItemLabel>
|
||||||
<Show when={itemProps.item.rawValue.description}>
|
<Show when={itemProps.item.rawValue.description}>
|
||||||
@@ -99,15 +101,17 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
data-agent-selector
|
data-agent-selector
|
||||||
class="selector-trigger"
|
class="selector-trigger"
|
||||||
>
|
>
|
||||||
<Select.Value<Agent>>
|
<div class="flex-1 min-w-0">
|
||||||
{(state) => (
|
<Select.Value<Agent>>
|
||||||
<div class="selector-trigger-label">
|
{() => (
|
||||||
<span class="selector-trigger-primary">
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
Agent: {state.selectedOption()?.name ?? "None"}
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
</span>
|
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</Select.Value>
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
<Select.Icon class="selector-trigger-icon">
|
<Select.Icon class="selector-trigger-icon">
|
||||||
<ChevronDown class="w-3 h-3" />
|
<ChevronDown class="w-3 h-3" />
|
||||||
</Select.Icon>
|
</Select.Icon>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user