mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 18:43:08 +08:00
Compare commits
117 Commits
chore/use-
...
feat/add-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a4ab8a45 | ||
|
|
dcd7044258 | ||
|
|
655501faba | ||
|
|
7c6d00ee93 | ||
|
|
84fadad662 | ||
|
|
5c14e08493 | ||
|
|
f188648a2a | ||
|
|
cdf1a04e26 | ||
|
|
048c671b13 | ||
|
|
2ebe64f218 | ||
|
|
b6cf33308d | ||
|
|
6761f31a56 | ||
|
|
35da5d090d | ||
|
|
d882f80b6d | ||
|
|
053f244d14 | ||
|
|
97c9b67bfc | ||
|
|
ed4ae3114a | ||
|
|
591e8e7642 | ||
|
|
c947eaaee5 | ||
|
|
4a503b1dc8 | ||
|
|
f6326c8082 | ||
|
|
c5fe127958 | ||
|
|
4712431be8 | ||
|
|
7ca355651f | ||
|
|
a12ca8eed3 | ||
|
|
e88539170e | ||
|
|
c03ddb25fe | ||
|
|
d7a38a567a | ||
|
|
fec91d3deb | ||
|
|
c6c153e7f2 | ||
|
|
4758ccf36e | ||
|
|
e23e6af06a | ||
|
|
56b2e14059 | ||
|
|
39b0473e38 | ||
|
|
f319d2cf8d | ||
|
|
9fb2d91b26 | ||
|
|
9cb2afd67e | ||
|
|
d0adddbbda | ||
|
|
2abb45636b | ||
|
|
677d330981 | ||
|
|
1b336e9aa9 | ||
|
|
7bed675ad5 | ||
|
|
5b9adbbdee | ||
|
|
416a737d76 | ||
|
|
3bbb42c233 | ||
|
|
123ae31020 | ||
|
|
89f05247ed | ||
|
|
00e526b6e9 | ||
|
|
591b18e9e3 | ||
|
|
881e72d4a0 | ||
|
|
f5eac2ec12 | ||
|
|
3d948217ef | ||
|
|
86f64797b0 | ||
|
|
21b0f14e7a | ||
|
|
f12af8a61b | ||
|
|
afc717e69e | ||
|
|
f549bd99e7 | ||
|
|
bc34085af9 | ||
|
|
6a53c31e90 | ||
|
|
92de0ed613 | ||
|
|
b6a45b3183 | ||
|
|
ce327b6fa9 | ||
|
|
276639099b | ||
|
|
f4f88318c2 | ||
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
f439a8a3d5 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
f7c0024fe1 | ||
|
|
ca8fd0ee23 | ||
|
|
b7e6bca2e3 | ||
|
|
84c166c4cb | ||
|
|
231ed04002 | ||
|
|
d70dc077bf | ||
|
|
1faa1a6a00 | ||
|
|
3cd89956ba | ||
|
|
01dbe2a8bf | ||
|
|
f4a1614a0a | ||
|
|
c235b05e1d | ||
|
|
dd77649053 | ||
|
|
af3a28abc7 | ||
|
|
371ff034e4 | ||
|
|
3b4d6885aa | ||
|
|
bc9d2dd830 | ||
|
|
c21a9f4561 | ||
|
|
ed9cdf0114 | ||
|
|
b39997c429 | ||
|
|
d638a8982c | ||
|
|
f238050b85 | ||
|
|
beaa2d2533 | ||
|
|
c90b34108e | ||
|
|
323357384e | ||
|
|
d509aa635b | ||
|
|
2149b8776b | ||
|
|
2b416f2dcb | ||
|
|
bb8db5815c | ||
|
|
b3d0f9037d | ||
|
|
3ec76b5bb1 | ||
|
|
14ddbc7c57 | ||
|
|
ebb0e59e80 | ||
|
|
957f53fb99 | ||
|
|
ef2fd48b46 | ||
|
|
cdcac182d4 | ||
|
|
94785bfa57 | ||
|
|
9e608b8426 | ||
|
|
c667b6a179 | ||
|
|
fa9eaf5573 | ||
|
|
2edfef2e3f | ||
|
|
96b16b42e4 | ||
|
|
f082cdc63b |
@@ -28,6 +28,9 @@ HOST=0.0.0.0
|
||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||
# CLAUDE_CLI_PATH=claude
|
||||
|
||||
# Uncomment the following line if you want a custom Hermes ACP launcher
|
||||
# HERMES_CLI_PATH=hermes acp
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATION
|
||||
# =============================================================================
|
||||
@@ -42,4 +45,3 @@ HOST=0.0.0.0
|
||||
VITE_CONTEXT_WINDOW=160000
|
||||
CONTEXT_WINDOW=160000
|
||||
|
||||
|
||||
|
||||
109
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
109
.github/workflows/desktop-macos-branch-build.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Desktop macOS Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- electron-app
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build macOS desktop artifact
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Resolve artifact metadata
|
||||
id: artifact
|
||||
run: |
|
||||
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
echo "name=CloudCLI-macOS-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure branch server bundle source
|
||||
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
|
||||
|
||||
- name: Verify signing secrets are configured
|
||||
run: |
|
||||
test -n "$CSC_LINK"
|
||||
test -n "$CSC_KEY_PASSWORD"
|
||||
test -n "$APPLE_ID"
|
||||
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
|
||||
test -n "$APPLE_TEAM_ID"
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build signed and notarized macOS artifacts
|
||||
run: npm run desktop:dist:mac -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build branch server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Verify branch server runtime artifacts
|
||||
run: |
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
|
||||
- name: Publish branch server bundle
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
|
||||
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
|
||||
body: |
|
||||
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
|
||||
|
||||
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||
|
||||
You do not need to download these runtime files manually.
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/local-server/*
|
||||
|
||||
- name: Verify macOS artifacts
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Upload branch build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: |
|
||||
release/desktop/*.dmg
|
||||
release/SHASUMS256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
305
.github/workflows/desktop-release.yml
vendored
Normal file
305
.github/workflows/desktop-release.yml
vendored
Normal file
@@ -0,0 +1,305 @@
|
||||
name: Desktop Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to create or update (defaults to v<package version>)"
|
||||
required: false
|
||||
type: string
|
||||
release_name:
|
||||
description: 'Release name (defaults to "CloudCLI Desktop <tag>")'
|
||||
required: false
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark the GitHub release as a prerelease"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
name: Resolve release metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_name: ${{ steps.release.outputs.release_name }}
|
||||
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve release metadata
|
||||
id: release
|
||||
env:
|
||||
TAG_INPUT: ${{ inputs.tag }}
|
||||
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
|
||||
run: |
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
TAG="$TAG_INPUT"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="v${VERSION}"
|
||||
fi
|
||||
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Resolved release tag is empty after normalization." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_NAME="$RELEASE_NAME_INPUT"
|
||||
if [ -z "$RELEASE_NAME" ]; then
|
||||
RELEASE_NAME="CloudCLI Desktop ${TAG}"
|
||||
fi
|
||||
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
|
||||
|
||||
{
|
||||
echo "tag=$TAG"
|
||||
echo "release_name<<$RELEASE_NAME_DELIMITER"
|
||||
printf '%s\n' "$RELEASE_NAME"
|
||||
echo "$RELEASE_NAME_DELIMITER"
|
||||
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-macos:
|
||||
name: Build signed macOS desktop app
|
||||
needs: resolve-release
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Configure release server bundle source
|
||||
env:
|
||||
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||
|
||||
- name: Verify macOS signing secrets are configured
|
||||
run: |
|
||||
test -n "$CSC_LINK"
|
||||
test -n "$CSC_KEY_PASSWORD"
|
||||
test -n "$APPLE_ID"
|
||||
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
|
||||
test -n "$APPLE_TEAM_ID"
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build signed and notarized macOS artifacts
|
||||
run: npm run desktop:dist:mac -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build macOS local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Stage macOS release assets
|
||||
run: |
|
||||
mkdir -p desktop-release-assets server-release-assets
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt
|
||||
cp release/desktop/*.dmg desktop-release-assets/
|
||||
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
cp release/local-server/* server-release-assets/
|
||||
|
||||
- name: Upload macOS desktop assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: desktop-release-macos
|
||||
path: desktop-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload macOS server assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: server-release-macos
|
||||
path: server-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Build Windows desktop app
|
||||
needs: resolve-release
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Configure release server bundle source
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||
|
||||
- name: Check Windows signing secrets
|
||||
id: windows-signing
|
||||
shell: bash
|
||||
env:
|
||||
WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
|
||||
WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build signed Windows artifacts
|
||||
if: steps.windows-signing.outputs.enabled == 'true'
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
if: steps.windows-signing.outputs.enabled != 'true'
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Build Windows local server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Stage Windows release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p desktop-release-assets server-release-assets
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt
|
||||
cp release/desktop/*.exe desktop-release-assets/
|
||||
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
cp release/local-server/* server-release-assets/
|
||||
|
||||
- name: Upload Windows desktop assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: desktop-release-windows
|
||||
path: desktop-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows server assets
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: server-release-windows
|
||||
path: server-release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish desktop release
|
||||
needs:
|
||||
- resolve-release
|
||||
- build-macos
|
||||
- build-windows
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download desktop assets
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: desktop-release-*
|
||||
path: release/desktop
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download server assets
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: server-release-*
|
||||
path: release/local-server
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify release assets
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
test -f release/desktop/SHASUMS256-macos.txt
|
||||
test -f release/desktop/SHASUMS256-windows.txt
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
find release -maxdepth 2 -type f -print | sort
|
||||
|
||||
- name: Publish local server runtime assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }})
|
||||
body: |
|
||||
This prerelease contains the Local mode runtime for CloudCLI Desktop.
|
||||
|
||||
Download CloudCLI Desktop from the main ${{ needs.resolve-release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||
|
||||
You do not need to download these runtime files manually.
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/local-server/*
|
||||
|
||||
- name: Publish GitHub release assets
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ needs.resolve-release.outputs.tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ needs.resolve-release.outputs.release_name }}
|
||||
body: |
|
||||
Download the CloudCLI Desktop installer for your platform.
|
||||
|
||||
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/desktop/*
|
||||
95
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
95
.github/workflows/desktop-windows-branch-build.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Desktop Windows Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- electron-app
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build unsigned Windows desktop artifact
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Resolve artifact metadata
|
||||
id: artifact
|
||||
shell: bash
|
||||
run: |
|
||||
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
echo "name=CloudCLI-windows-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure branch server bundle source
|
||||
shell: bash
|
||||
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
run: npm run desktop:dist:win -- --publish never
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
|
||||
- name: Build branch server bundle
|
||||
run: node scripts/release/build-server-bundle.js
|
||||
|
||||
- name: Verify branch server runtime artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
|
||||
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
|
||||
|
||||
- name: Publish branch server bundle
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
|
||||
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
|
||||
body: |
|
||||
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
|
||||
|
||||
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
|
||||
|
||||
You do not need to download these runtime files manually.
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: false
|
||||
overwrite_files: true
|
||||
files: |
|
||||
release/local-server/*
|
||||
|
||||
- name: Verify Windows artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
|
||||
sha256sum release/desktop/*.exe > release/SHASUMS256.txt
|
||||
cat release/SHASUMS256.txt
|
||||
|
||||
- name: Upload branch build artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: |
|
||||
release/desktop/*.exe
|
||||
release/SHASUMS256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -4,28 +4,109 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
increment:
|
||||
description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)'
|
||||
description: "Version bump: patch, minor, major, or explicit (e.g. 1.27.0)"
|
||||
required: true
|
||||
default: 'patch'
|
||||
default: "patch"
|
||||
type: string
|
||||
release_name:
|
||||
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# This workflow publishes releases with write credentials, so actions are pinned
|
||||
# to immutable commit SHAs. The trailing comments keep the original major tag
|
||||
# visible for maintenance context.
|
||||
jobs:
|
||||
build-macos-semantic-helper:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runs_on: macos-15
|
||||
target_dir: darwin-arm64
|
||||
- runs_on: macos-15-intel
|
||||
target_dir: darwin-x64
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build macOS semantic helper
|
||||
run: node scripts/build-computer-semantics.mjs
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
- name: Verify macOS semantic helper target
|
||||
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
|
||||
- name: Stage macOS semantic helper artifact
|
||||
run: |
|
||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: semantic-helper-${{ matrix.target_dir }}
|
||||
path: semantic-helper-artifact/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows-semantic-helper:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runs_on: windows-2025
|
||||
target_dir: win32-x64
|
||||
- runs_on: windows-11-arm
|
||||
target_dir: win32-arm64
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Build Windows semantic helper
|
||||
run: node scripts/build-computer-semantics.mjs
|
||||
env:
|
||||
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
|
||||
- name: Verify Windows semantic helper target
|
||||
shell: bash
|
||||
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
|
||||
- name: Stage Windows semantic helper artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
|
||||
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: semantic-helper-${{ matrix.target_dir }}
|
||||
path: semantic-helper-artifact/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs:
|
||||
- build-macos-semantic-helper
|
||||
- build-windows-semantic-helper
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -37,6 +118,23 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
||||
with:
|
||||
pattern: semantic-helper-*
|
||||
path: server/modules/computer-use/semantics/bin
|
||||
merge-multiple: true
|
||||
|
||||
- name: Restore semantic helper permissions
|
||||
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
|
||||
|
||||
- name: Verify bundled semantic helpers
|
||||
run: |
|
||||
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
|
||||
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
|
||||
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
|
||||
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
|
||||
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
ARGS="--ci --increment=${{ inputs.increment }}"
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -134,6 +134,7 @@ tasks/
|
||||
|
||||
# Translations
|
||||
!src/i18n/locales/en/tasks.json
|
||||
!src/i18n/locales/fr/tasks.json
|
||||
!src/i18n/locales/ja/tasks.json
|
||||
!src/i18n/locales/ru/tasks.json
|
||||
!src/i18n/locales/de/tasks.json
|
||||
@@ -142,3 +143,11 @@ tasks/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local desktop packaging artifacts
|
||||
/.desktop-build/
|
||||
/release/
|
||||
/electron/server-bundle-config.json
|
||||
cloudcli-sidebar-app-source.tar.gz
|
||||
cloudcli-sidebar.html
|
||||
electron/*.tar.gz
|
||||
|
||||
121
CHANGELOG.md
121
CHANGELOG.md
@@ -3,6 +3,127 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
|
||||
|
||||
### New Features
|
||||
|
||||
* add Electron desktop app ([97c9b67](https://github.com/siteboon/claudecodeui/commit/97c9b67bfc2d803560cd1559a4e79eea9731c7b5))
|
||||
* **chat:** derive activity indicator from per-session state and unify provider lifecycle events ([afc717e](https://github.com/siteboon/claudecodeui/commit/afc717e69e67f53173c30d2230722236f9180d39))
|
||||
* **chat:** unify session gateway with stable IDs and a single WS protocol ([f5eac2e](https://github.com/siteboon/claudecodeui/commit/f5eac2ec12c8575bf80202fafe807d9e04720105))
|
||||
* **i18n:** add French (fr) locale ([#878](https://github.com/siteboon/claudecodeui/issues/878)) ([f319d2c](https://github.com/siteboon/claudecodeui/commit/f319d2cf8d61452deaf6adf345494dd3e6898284))
|
||||
* play sound for pending tool requests ([#918](https://github.com/siteboon/claudecodeui/issues/918)) ([c947eaa](https://github.com/siteboon/claudecodeui/commit/c947eaaee5fbc959563efb917f4ec7c88847dd6b))
|
||||
* render changelog as markdown in version upgrade modal ([6a53c31](https://github.com/siteboon/claudecodeui/commit/6a53c31e907fffa79320997c27f99660c946b4a6))
|
||||
* **sidebar:** improve running session state tracking ([591b18e](https://github.com/siteboon/claudecodeui/commit/591b18e9e343fda23affe100a53911f76aaa8f57))
|
||||
* **skills:** add provider skill management ([#909](https://github.com/siteboon/claudecodeui/issues/909)) ([c5fe127](https://github.com/siteboon/claudecodeui/commit/c5fe127958d830eee19d008d8634c0e7d77fe1b9))
|
||||
* **version:** warn when the server was updated but not restarted ([#898](https://github.com/siteboon/claudecodeui/issues/898)) ([f6326c8](https://github.com/siteboon/claudecodeui/commit/f6326c8082dfbe8a65dcdb836d3e71c635594c26))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* changes provider logos to svg for fast load ([7bed675](https://github.com/siteboon/claudecodeui/commit/7bed675ad5fd1ecf7912d1a04afe9db5b1032823))
|
||||
* **chat:** prevent chat interface crash on malformed AskUserQuestion payload ([#920](https://github.com/siteboon/claudecodeui/issues/920)) ([ed4ae31](https://github.com/siteboon/claudecodeui/commit/ed4ae3114aafc1d4ecb0b621eaf9d3b26dbca5b1))
|
||||
* **chat:** prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks ([#903](https://github.com/siteboon/claudecodeui/issues/903)) ([4712431](https://github.com/siteboon/claudecodeui/commit/4712431be81718dfb559ef43d7d7d5315bf4e01a))
|
||||
* **chat:** sort messages appropriately ([123ae31](https://github.com/siteboon/claudecodeui/commit/123ae310207fe5969c3b313f62b9dee27e5d7489))
|
||||
* **claude-sync:** skip subagent transcripts to prevent main session corruption ([#854](https://github.com/siteboon/claudecodeui/issues/854)) ([a12ca8e](https://github.com/siteboon/claudecodeui/commit/a12ca8eed373ef56cd37fbdd097845eaab34dee9))
|
||||
* correct notification session id ([881e72d](https://github.com/siteboon/claudecodeui/commit/881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8))
|
||||
* create one unified function for frontend session processing ([677d330](https://github.com/siteboon/claudecodeui/commit/677d330981ef29a856f09e62b9f69bac0fa580d4))
|
||||
* **i18n:** add missing sidebar message keys to all locales ([#896](https://github.com/siteboon/claudecodeui/issues/896)) ([7ca3556](https://github.com/siteboon/claudecodeui/commit/7ca355651f0a805965bc27af3d75def626c5fb96))
|
||||
* keep running-session polling active ([39b0473](https://github.com/siteboon/claudecodeui/commit/39b0473e38201c29ff1e5388946452d2eed44527))
|
||||
* normalize project session payloads ([d0adddb](https://github.com/siteboon/claudecodeui/commit/d0adddbbdafecfd5713a8ac5b95c87a8f7fc54f8))
|
||||
* **opencode:** bind watcher sessions to app rows early ([5b9adbb](https://github.com/siteboon/claudecodeui/commit/5b9adbbdee8561439a27ad90744388225823427b))
|
||||
* **opencode:** pass workspace dir explicitly ([416a737](https://github.com/siteboon/claudecodeui/commit/416a737d76e654d2fc649206c2b921a7db150775))
|
||||
* recover pending permission requests ([56b2e14](https://github.com/siteboon/claudecodeui/commit/56b2e1405967c50301d0c773567349763edc8560))
|
||||
* remove provider specific token usage calculator ([2abb456](https://github.com/siteboon/claudecodeui/commit/2abb45636b5e1109733cfa58c8ab92fd4c812165))
|
||||
* resolve session provider on backend reads ([9fb2d91](https://github.com/siteboon/claudecodeui/commit/9fb2d91b26bef9579337d953a29718802c466fed))
|
||||
* **sessions:** canonicalize sidebar ids and timestamps ([3bbb42c](https://github.com/siteboon/claudecodeui/commit/3bbb42c23324c3cbb5587f2bcab09b1dc23086a8))
|
||||
* **shell:** prioritize user npm binaries ([#913](https://github.com/siteboon/claudecodeui/issues/913)) ([4a503b1](https://github.com/siteboon/claudecodeui/commit/4a503b1dc87ff58821670c8bfb1d8a8c1dab2bcf))
|
||||
* **shell:** use correct session id ([89f0524](https://github.com/siteboon/claudecodeui/commit/89f05247eddec4fe53bd1616c6a5563e3ae2427a))
|
||||
* **sidebar:** align session status controls across layouts ([1b336e9](https://github.com/siteboon/claudecodeui/commit/1b336e9aa9d2cccf0676d852815d9ba613ac04d2))
|
||||
* upgrade gemini logo ([9cb2afd](https://github.com/siteboon/claudecodeui/commit/9cb2afd67eb25a4f869b88abcf86f7748b2b6d71))
|
||||
* voice tts format settings ([#919](https://github.com/siteboon/claudecodeui/issues/919)) ([591e8e7](https://github.com/siteboon/claudecodeui/commit/591e8e7642589b0584f9b29b46b881aaab54624e))
|
||||
|
||||
### Documentation
|
||||
|
||||
* update available plugin readmes ([f549bd9](https://github.com/siteboon/claudecodeui/commit/f549bd99e7106362a27cf4ccee6e9d434b8b5363))
|
||||
* update session activity guard comment ([e23e6af](https://github.com/siteboon/claudecodeui/commit/e23e6af06a44cc4b016df5778984602d49e52629))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add github issues board plugin ([21b0f14](https://github.com/siteboon/claudecodeui/commit/21b0f14e7a86f257c65484742c43b9f85152b32c))
|
||||
* add more plugins list ([bc34085](https://github.com/siteboon/claudecodeui/commit/bc34085af9912da8d8592881a5845cff84a53f7d))
|
||||
* move tests to appropriate folder ([d7a38a5](https://github.com/siteboon/claudecodeui/commit/d7a38a567a5e9039935353a886310b3c32b25a79))
|
||||
* move tests to appropriate folder ([c6c153e](https://github.com/siteboon/claudecodeui/commit/c6c153e7f2a60572b08d687b59f010b4ad4f5d72))
|
||||
* remove a log ([00e526b](https://github.com/siteboon/claudecodeui/commit/00e526b6e90ee0baf09ebf48873bc10824ab80ba))
|
||||
* remove unused modelConstants from the project ([92de0ed](https://github.com/siteboon/claudecodeui/commit/92de0ed6137bf4571056deb3b930cc9fd22e2a08))
|
||||
* upgrade gemini models ([3d94821](https://github.com/siteboon/claudecodeui/commit/3d948217ef3084e764171ebc5dda55f663150b2c))
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
||||
|
||||
### New Features
|
||||
|
||||
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
|
||||
|
||||
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
|
||||
|
||||
### New Features
|
||||
|
||||
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
|
||||
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
|
||||
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
|
||||
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
|
||||
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
|
||||
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
|
||||
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
|
||||
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
|
||||
|
||||
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
||||
|
||||
### New Features
|
||||
|
||||
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
|
||||
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
|
||||
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
|
||||
|
||||
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
|
||||
|
||||
### New Features
|
||||
|
||||
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
|
||||
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
|
||||
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
|
||||
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
|
||||
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
|
||||
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
|
||||
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
|
||||
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
|
||||
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
|
||||
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
|
||||
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
|
||||
|
||||
### Documentation
|
||||
|
||||
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
|
||||
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
|
||||
|
||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
||||
|
||||
### New Features
|
||||
|
||||
12
README.de.md
12
README.de.md
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
|
||||
- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Schnellstart
|
||||
@@ -164,6 +164,14 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
|
||||
| Plugin | Beschreibung |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
|
||||
|
||||
### Eigenes Plugin erstellen
|
||||
|
||||
|
||||
10
README.ja.md
10
README.ja.md
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
|
||||
| プラグイン | 説明 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
|
||||
|
||||
### 自作する
|
||||
|
||||
|
||||
12
README.ko.md
12
README.ko.md
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
|
||||
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`public/modelConstants.js`에서 전체 지원 모델 확인)
|
||||
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
|
||||
| 플러그인 | 설명 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
|
||||
|
||||
### 직접 만들기
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -59,10 +59,11 @@
|
||||
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Browser Use** - Open browser sessions for web research, testing, and agent-driven browser tasks
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](public/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Quick Start
|
||||
@@ -73,6 +74,11 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
||||
|
||||
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
|
||||
|
||||
### Desktop App
|
||||
|
||||
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
|
||||
|
||||
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
|
||||
|
||||
### Self-Hosted (Open source)
|
||||
|
||||
@@ -163,8 +169,15 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
|
||||
| Plugin | Description |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI|
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
|
||||
|
||||
### Build Your Own
|
||||
|
||||
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
||||
|
||||
12
README.ru.md
12
README.ru.md
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
|
||||
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`public/modelConstants.js`](public/modelConstants.js) для полного списка поддерживаемых моделей)
|
||||
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Быстрый старт
|
||||
@@ -164,6 +164,14 @@ CloudCLI UI — это open source UI-слой, на котором постро
|
||||
| Плагин | Описание |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
|
||||
|
||||
### Создать свой
|
||||
|
||||
|
||||
11
README.tr.md
11
README.tr.md
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
|
||||
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
|
||||
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için [`public/modelConstants.js`](public/modelConstants.js) dosyasına bak)
|
||||
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak)
|
||||
|
||||
|
||||
## Hızlı Başlangıç
|
||||
@@ -164,6 +164,13 @@ CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel s
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
|
||||
|
||||
### Kendi Eklentini Yaz
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
|
||||
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`public/modelConstants.js`](public/modelConstants.js))
|
||||
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -158,6 +158,14 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
|
||||
| 插件 | 描述 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
|
||||
|
||||
### 自行构建
|
||||
|
||||
|
||||
250
README.zh-TW.md
Normal file
250
README.zh-TW.md
Normal file
@@ -0,0 +1,250 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||
<h1>Cloud CLI(又名 Claude Code UI)</h1>
|
||||
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>、<a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>、<a href="https://developers.openai.com/codex">Codex</a> 和 <a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用,從任何地方查看您的專案與工作階段。</p>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
|
||||
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
|
||||
<br><br>
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||
|
||||
---
|
||||
|
||||
## 截圖
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>桌面檢視</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
|
||||
<br>
|
||||
<em>顯示專案總覽和聊天的主介面</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>行動裝置體驗</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
|
||||
<br>
|
||||
<em>具有觸控導覽的響應式行動裝置設計</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 選擇</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
|
||||
<br>
|
||||
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
## 功能
|
||||
|
||||
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
|
||||
- **互動聊天介面** — 內建聊天 UI,輕鬆與 Agents 交流
|
||||
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
|
||||
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
|
||||
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
|
||||
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
|
||||
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
|
||||
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
|
||||
|
||||
## 快速開始
|
||||
|
||||
### CloudCLI Cloud(推薦)
|
||||
|
||||
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
|
||||
|
||||
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
|
||||
|
||||
### 自架(開源)
|
||||
|
||||
#### npm
|
||||
|
||||
啟動 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||
|
||||
```bash
|
||||
npx @cloudcli-ai/cloudcli
|
||||
```
|
||||
|
||||
或進行全域安裝,便於日常使用:
|
||||
|
||||
```bash
|
||||
npm install -g @cloudcli-ai/cloudcli
|
||||
cloudcli
|
||||
```
|
||||
|
||||
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
|
||||
|
||||
更多設定選項、PM2、遠端伺服器設定等,請參閱 **[文件 →](https://cloudcli.ai/docs)**。
|
||||
|
||||
#### Docker Sandboxes(實驗性)
|
||||
|
||||
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
|
||||
|
||||
```bash
|
||||
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||
```
|
||||
|
||||
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
|
||||
|
||||
---
|
||||
|
||||
## 哪個選項更適合你?
|
||||
|
||||
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
|
||||
|
||||
| | CloudCLI UI(自架) | CloudCLI Cloud |
|
||||
|---|---|---|
|
||||
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
|
||||
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
|
||||
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
|
||||
| **機器需保持開機嗎** | 是 | 否 |
|
||||
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
|
||||
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
|
||||
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
|
||||
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
|
||||
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
|
||||
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
|
||||
| **REST API** | 是 | 是 |
|
||||
| **n8n 節點** | 否 | 是 |
|
||||
| **團隊共享** | 否 | 是 |
|
||||
| **平台費用** | 免費開源 | 起價 $7/月 |
|
||||
|
||||
> 兩種方式都使用你自己的 AI 訂閱(Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
|
||||
|
||||
---
|
||||
|
||||
## 安全與工具設定
|
||||
|
||||
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
|
||||
|
||||
### 啟用工具
|
||||
|
||||
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
|
||||
2. **選擇性啟用** — 僅啟用所需工具
|
||||
3. **套用設定** — 偏好設定儲存在本機
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*工具設定介面 — 只啟用你需要的內容*
|
||||
|
||||
</div>
|
||||
|
||||
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
|
||||
|
||||
---
|
||||
|
||||
## 外掛
|
||||
|
||||
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
|
||||
|
||||
### 可用外掛
|
||||
|
||||
| 外掛 | 描述 |
|
||||
|---|---|
|
||||
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
||||
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
|
||||
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
|
||||
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
|
||||
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
|
||||
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
|
||||
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
|
||||
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
|
||||
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
|
||||
|
||||
### 自行建構
|
||||
|
||||
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
|
||||
|
||||
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
|
||||
|
||||
---
|
||||
|
||||
## 常見問題
|
||||
|
||||
<details>
|
||||
<summary>與 Claude Code Remote Control 有何不同?</summary>
|
||||
|
||||
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
|
||||
|
||||
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
|
||||
|
||||
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
|
||||
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
|
||||
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
|
||||
- **完整 UI** — 除了聊天介面,還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
|
||||
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>需要額外購買 AI 訂閱嗎?</summary>
|
||||
|
||||
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
|
||||
|
||||
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
|
||||
|
||||
會的。自架模式下,CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 社群與支援
|
||||
|
||||
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
|
||||
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
|
||||
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
|
||||
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
|
||||
|
||||
## 授權條款
|
||||
|
||||
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
|
||||
|
||||
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
|
||||
|
||||
## 致謝
|
||||
|
||||
### 使用技術
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
|
||||
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
|
||||
- **[React](https://react.dev/)** — 使用者介面函式庫
|
||||
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
|
||||
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
|
||||
|
||||
### 贊助商
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
|
||||
</div>
|
||||
45
docs/hermes-gateway.md
Normal file
45
docs/hermes-gateway.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Hermes Gateway Controls
|
||||
|
||||
CloudCLI can manage the Hermes Gateway process from **Settings -> Agents -> Hermes -> Gateway**.
|
||||
|
||||
The gateway is optional. Normal CloudCLI chat and automation should continue to use `POST /api/agent` with `provider: "hermes"`. The gateway is for long-running Hermes integrations such as Telegram, Discord, WhatsApp, and other messaging surfaces configured by Hermes.
|
||||
|
||||
## What The Gateway Tab Does
|
||||
|
||||
- Shows whether Hermes is installed and whether the gateway is running.
|
||||
- Starts Hermes with `hermes gateway run` in the current CloudCLI server environment.
|
||||
- Stops or restarts a gateway process started by CloudCLI.
|
||||
- Opens `hermes gateway setup` in the terminal for platform configuration.
|
||||
- Shows detected Hermes profiles for visibility.
|
||||
- Shows recent logs from the gateway process managed by CloudCLI.
|
||||
|
||||
## What It Does Not Do
|
||||
|
||||
- It does not expose the Hermes HTTP gateway as a raw public API.
|
||||
- It does not add a new authentication model.
|
||||
- It does not replace the CloudCLI Agent API.
|
||||
- It does not create Docker containers or require Docker.
|
||||
|
||||
## API Boundaries
|
||||
|
||||
CloudCLI has two separate surfaces:
|
||||
|
||||
- **CloudCLI Agent API**: `POST /api/agent`
|
||||
Use this for programmatic CloudCLI tasks, including Hermes tasks. It supports `projectPath`, `githubUrl`, sessions, branches, and other CloudCLI workflow options.
|
||||
|
||||
- **Hermes Gateway controls**: `/api/providers/hermes/gateway/*`
|
||||
These are browser-authenticated UI control endpoints used by the settings page.
|
||||
|
||||
The gateway controls intentionally stay inside the authenticated provider API. They are not a customer-facing replacement for `POST /api/agent`.
|
||||
|
||||
## Hosted Environments
|
||||
|
||||
In hosted or containerized environments, CloudCLI runs the gateway in foreground mode because system service managers such as systemd or launchd may not be available. This matches Hermes' recommended foreground mode for containers and similar runtimes.
|
||||
|
||||
If Docker is not available, the Gateway tab still works for the local Hermes process. Docker is only relevant for advanced deployments where a user chooses to run Hermes separately.
|
||||
|
||||
## References
|
||||
|
||||
- [Hermes Agent releases](https://github.com/NousResearch/hermes-agent/releases) for current gateway and messaging platform capabilities.
|
||||
- [Hermes hooks documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) for non-interactive gateway runs and hook approval behavior.
|
||||
- [Hermes environment variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) for Docker-image-specific gateway supervision details.
|
||||
218
docs/nginx-subpath-template.conf
Normal file
218
docs/nginx-subpath-template.conf
Normal file
@@ -0,0 +1,218 @@
|
||||
# CloudCLI UI Nginx subpath deployment template.
|
||||
#
|
||||
# Purpose:
|
||||
# Serve CloudCLI UI from a path prefix such as:
|
||||
# http://localhost/ai/
|
||||
# https://example.com/ai/
|
||||
#
|
||||
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
||||
# http://127.0.0.1:3001/
|
||||
#
|
||||
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
||||
# remaining path to CloudCLI. For example:
|
||||
# /ai/ -> /
|
||||
# /ai/session/abc -> /session/abc
|
||||
# /ai/assets/index.js -> /assets/index.js
|
||||
#
|
||||
# Important Nginx limitation:
|
||||
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
||||
# The configurable variables below are still useful for proxy/filter values,
|
||||
# but if you change /ai to a different subpath, also update every line marked:
|
||||
# [SUBPATH LITERAL]
|
||||
#
|
||||
# To use a different subpath, replace these literal matchers:
|
||||
# location = /ai
|
||||
# location ^~ /ai/
|
||||
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
||||
#
|
||||
# Recommended deployment shape:
|
||||
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
||||
# are also proxied because the current frontend still calls those endpoints
|
||||
# with root-relative URLs.
|
||||
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
# Maximum simultaneous connections handled by each worker process.
|
||||
# The default is enough for local testing and small self-hosted deployments.
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
||||
# This map gives us the right Connection header for both cases:
|
||||
# Upgrade present -> "upgrade"
|
||||
# Upgrade absent -> "close"
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
||||
# add ssl_certificate / ssl_certificate_key lines.
|
||||
listen 80 default_server;
|
||||
|
||||
# Use your real hostname in production, for example:
|
||||
# server_name cloudcli.example.com;
|
||||
server_name localhost 127.0.0.1;
|
||||
|
||||
# ---- User settings -------------------------------------------------
|
||||
#
|
||||
# Public path prefix where users access CloudCLI.
|
||||
# Do not add a trailing slash.
|
||||
#
|
||||
# This variable can be used in redirects and response rewrites. It
|
||||
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
||||
# lines too if you change it.
|
||||
set $cloudcli_subpath /ai;
|
||||
|
||||
# Private upstream URL where the CloudCLI server is listening.
|
||||
# For a default local server this is usually http://127.0.0.1:3001.
|
||||
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||
|
||||
# Allow larger file uploads through the code editor/project file APIs.
|
||||
client_max_body_size 200m;
|
||||
|
||||
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||
location = /ai {
|
||||
return 301 $cloudcli_subpath/;
|
||||
}
|
||||
|
||||
# Main prefixed CloudCLI UI route.
|
||||
#
|
||||
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
||||
# $cloudcli_subpath.
|
||||
location ^~ /ai/ {
|
||||
# Strip the public subpath before proxying. CloudCLI expects to see
|
||||
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
||||
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
||||
|
||||
# Forward the rewritten request to the private CloudCLI server.
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
||||
# browser reaches a socket endpoint under the subpath.
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Preserve useful request metadata for logs and future app support.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Long-running agent and terminal sessions can stay open for a long
|
||||
# time, so avoid closing idle proxied connections too aggressively.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
|
||||
# Disable gzip from the upstream response so sub_filter can inspect
|
||||
# and rewrite HTML/JSON/JS response bodies.
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
# Rewrite browser-visible root-relative URLs so the runtime can
|
||||
# discover that the app is mounted under the subpath.
|
||||
#
|
||||
# Examples:
|
||||
# href="/manifest.json" -> href="/ai/manifest.json"
|
||||
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
||||
#
|
||||
# These rewrites are important for React Router basename detection.
|
||||
sub_filter_once off;
|
||||
sub_filter_types
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/javascript
|
||||
text/javascript;
|
||||
|
||||
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
||||
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
||||
|
||||
# The production HTML and JS register the service worker at /sw.js.
|
||||
# Rewrite that registration so the worker is served from /ai/sw.js.
|
||||
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
||||
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
||||
|
||||
# The manifest and service worker contain root-relative paths too.
|
||||
# Rewriting them keeps PWA metadata and cached manifest requests
|
||||
# under the same public subpath.
|
||||
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
||||
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
||||
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
||||
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
||||
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
||||
}
|
||||
|
||||
# Root API proxy.
|
||||
#
|
||||
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
||||
# as /api/auth/login. Keep this location unless the frontend becomes
|
||||
# fully prefix-aware for API requests.
|
||||
location ^~ /api/ {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Main app WebSocket proxy.
|
||||
#
|
||||
# The frontend opens /ws for realtime chat/session/task updates.
|
||||
location /ws {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Shell WebSocket proxy.
|
||||
#
|
||||
# The browser terminal uses /shell. It requires the same WebSocket
|
||||
# upgrade handling as /ws.
|
||||
location /shell {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Optional health endpoint proxy used by the frontend version checker.
|
||||
location = /health {
|
||||
proxy_pass $cloudcli_upstream;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
electron/assets/logo-macos.icns
Normal file
BIN
electron/assets/logo-macos.icns
Normal file
Binary file not shown.
BIN
electron/assets/logo-macos.png
Normal file
BIN
electron/assets/logo-macos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
electron/assets/logo-windows.ico
Normal file
BIN
electron/assets/logo-windows.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
260
electron/cloud.js
Normal file
260
electron/cloud.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { safeStorage } from 'electron';
|
||||
|
||||
const CLOUD_API_TIMEOUT_MS = 15000;
|
||||
|
||||
function encryptSecret(secret) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { encrypted: false, value: secret };
|
||||
}
|
||||
|
||||
return {
|
||||
encrypted: true,
|
||||
value: safeStorage.encryptString(secret).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSecret(record) {
|
||||
if (!record?.value) return null;
|
||||
if (!record.encrypted) return record.value;
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class CloudController {
|
||||
constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
|
||||
this.storePath = storePath;
|
||||
this.controlPlaneUrl = controlPlaneUrl;
|
||||
this.callbackUrl = callbackUrl;
|
||||
this.onChange = onChange;
|
||||
this.cloudAccount = null;
|
||||
this.cloudEnvironments = [];
|
||||
this.authState = 'logged_out';
|
||||
}
|
||||
|
||||
getAccount() {
|
||||
return this.cloudAccount;
|
||||
}
|
||||
|
||||
getAuthState() {
|
||||
return this.authState;
|
||||
}
|
||||
|
||||
getEnvironments() {
|
||||
return this.cloudEnvironments;
|
||||
}
|
||||
|
||||
getEnvironmentUrl(environment) {
|
||||
return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
|
||||
}
|
||||
|
||||
async getEnvironmentLaunchUrl(environment) {
|
||||
if (!environment?.id) {
|
||||
return this.getEnvironmentUrl(environment);
|
||||
}
|
||||
|
||||
const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
|
||||
}
|
||||
|
||||
findEnvironment(environmentId) {
|
||||
return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
|
||||
}
|
||||
|
||||
async loadCloudAccount() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.storePath, 'utf8');
|
||||
const stored = JSON.parse(raw);
|
||||
const apiKey = decryptSecret(stored.apiKey);
|
||||
this.cloudAccount = {
|
||||
deviceId: stored.deviceId || crypto.randomUUID(),
|
||||
email: stored.email || null,
|
||||
apiKey: apiKey || null,
|
||||
};
|
||||
this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
|
||||
return this.cloudAccount;
|
||||
} catch {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
this.authState = 'logged_out';
|
||||
return this.cloudAccount;
|
||||
}
|
||||
}
|
||||
|
||||
async saveCloudAccount(account) {
|
||||
const payload = {
|
||||
deviceId: account.deviceId || crypto.randomUUID(),
|
||||
email: account.email || null,
|
||||
apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
this.cloudAccount = {
|
||||
deviceId: payload.deviceId,
|
||||
email: payload.email,
|
||||
apiKey: account.apiKey || null,
|
||||
};
|
||||
this.authState = account.apiKey ? 'connected' : 'logged_out';
|
||||
this.onChange?.();
|
||||
return this.cloudAccount;
|
||||
}
|
||||
|
||||
async clearCloudAccount() {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
this.cloudEnvironments = [];
|
||||
this.authState = 'logged_out';
|
||||
await fs.rm(this.storePath, { force: true });
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async invalidateCloudAccount() {
|
||||
this.cloudEnvironments = [];
|
||||
if (!this.cloudAccount) {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
} else {
|
||||
this.cloudAccount = {
|
||||
...this.cloudAccount,
|
||||
apiKey: null,
|
||||
};
|
||||
}
|
||||
this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
|
||||
const payload = {
|
||||
deviceId: this.cloudAccount.deviceId,
|
||||
email: this.cloudAccount.email || null,
|
||||
apiKey: null,
|
||||
};
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async cloudApi(pathname, options = {}) {
|
||||
if (!this.cloudAccount?.apiKey) {
|
||||
throw new Error('Connect your CloudCLI account first.');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
|
||||
...options,
|
||||
signal: options.signal || controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.cloudAccount.apiKey,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
await this.invalidateCloudAccount();
|
||||
}
|
||||
throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async refreshCloudEnvironments() {
|
||||
if (!this.cloudAccount?.apiKey) {
|
||||
this.cloudEnvironments = [];
|
||||
this.onChange?.();
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await this.cloudApi('/api/v1/environments');
|
||||
this.cloudEnvironments = data.environments || [];
|
||||
this.onChange?.();
|
||||
return this.cloudEnvironments;
|
||||
}
|
||||
|
||||
async startEnvironment(environment) {
|
||||
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async stopEnvironment(environment) {
|
||||
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getEnvironmentCredentials(environment) {
|
||||
return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
|
||||
}
|
||||
|
||||
async startEnvironmentAndWait(environment, timeoutMs) {
|
||||
await this.startEnvironment(environment);
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const environments = await this.refreshCloudEnvironments();
|
||||
const current = environments.find((env) => env.id === environment.id);
|
||||
if (current?.status === 'running') {
|
||||
return current;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
throw new Error(`${environment.name} did not become ready in time.`);
|
||||
}
|
||||
|
||||
buildConnectUrl() {
|
||||
if (!this.cloudAccount?.deviceId) {
|
||||
this.cloudAccount = {
|
||||
deviceId: crypto.randomUUID(),
|
||||
email: null,
|
||||
apiKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
|
||||
connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
|
||||
connectUrl.searchParams.set('callback_url', this.callbackUrl);
|
||||
connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
|
||||
connectUrl.searchParams.set('client_platform', 'desktop');
|
||||
return connectUrl.toString();
|
||||
}
|
||||
|
||||
async saveFromCallback({ apiKey, email }) {
|
||||
await this.saveCloudAccount({
|
||||
deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
|
||||
email,
|
||||
apiKey,
|
||||
});
|
||||
return this.cloudAccount;
|
||||
}
|
||||
}
|
||||
378
electron/desktopNotifications.js
Normal file
378
electron/desktopNotifications.js
Normal file
@@ -0,0 +1,378 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Notification } from 'electron';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const RECONNECT_MIN_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
const TARGET_REGISTER_TIMEOUT_MS = 8000;
|
||||
|
||||
function toNotificationsWsUrl(httpUrl) {
|
||||
try {
|
||||
const parsed = new URL(httpUrl);
|
||||
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
parsed.pathname = '/desktop-notifications';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonMessage(raw) {
|
||||
try {
|
||||
return JSON.parse(String(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
...(body == null ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopNotificationsController {
|
||||
constructor({
|
||||
settingsPath,
|
||||
appVersion,
|
||||
appName,
|
||||
getDeviceId,
|
||||
getAccountEmail,
|
||||
getRunningEnvironmentUrls,
|
||||
getApiKey,
|
||||
getAuthToken,
|
||||
getIconPath,
|
||||
openNotificationTarget,
|
||||
onChange,
|
||||
}) {
|
||||
this.settingsPath = settingsPath;
|
||||
this.appVersion = appVersion;
|
||||
this.appName = appName;
|
||||
this.getDeviceId = getDeviceId;
|
||||
this.getAccountEmail = getAccountEmail;
|
||||
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
|
||||
this.getApiKey = getApiKey;
|
||||
this.getAuthToken = getAuthToken;
|
||||
this.getIconPath = getIconPath;
|
||||
this.openNotificationTarget = openNotificationTarget;
|
||||
this.onChange = onChange;
|
||||
this.settings = { enabled: false };
|
||||
this.connections = new Map();
|
||||
this.lastEvent = null;
|
||||
this.lastError = null;
|
||||
}
|
||||
|
||||
getState() {
|
||||
const connectedTargets = [];
|
||||
for (const [url, connection] of this.connections.entries()) {
|
||||
if (connection.ws?.readyState === WebSocket.OPEN) {
|
||||
connectedTargets.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: this.settings.enabled,
|
||||
supported: Notification.isSupported(),
|
||||
targetCount: this.connections.size,
|
||||
connectedCount: connectedTargets.length,
|
||||
connectedTargets,
|
||||
lastEvent: this.lastEvent,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
||||
const stored = JSON.parse(raw);
|
||||
this.settings = { enabled: Boolean(stored.enabled) };
|
||||
} catch {
|
||||
this.settings = { enabled: false };
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async saveSettings(next) {
|
||||
const enabled = Boolean(next?.enabled);
|
||||
if (!enabled && this.settings.enabled) {
|
||||
await this.disableCurrentTargets();
|
||||
}
|
||||
this.settings = { enabled };
|
||||
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
||||
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
|
||||
await this.sync();
|
||||
this.onChange?.();
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async sync() {
|
||||
if (!this.settings.enabled) {
|
||||
this.stop();
|
||||
this.lastEvent = 'disabled';
|
||||
this.onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
this.stop();
|
||||
this.lastEvent = 'unsupported';
|
||||
this.lastError = 'Native notifications are not supported on this system.';
|
||||
this.onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = this.getDeviceId?.();
|
||||
if (!deviceId) {
|
||||
this.stop();
|
||||
this.lastEvent = 'missing-device';
|
||||
this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
|
||||
this.onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = (this.getRunningEnvironmentUrls?.() || [])
|
||||
.map((httpUrl) => ({
|
||||
httpUrl,
|
||||
wsUrl: toNotificationsWsUrl(httpUrl),
|
||||
}))
|
||||
.filter((target) => target.wsUrl);
|
||||
|
||||
const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
|
||||
for (const [wsUrl, connection] of this.connections.entries()) {
|
||||
if (!nextWsUrls.has(wsUrl)) {
|
||||
this.closeConnection(connection);
|
||||
this.connections.delete(wsUrl);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (!this.connections.has(target.wsUrl)) {
|
||||
void this.connect(target).catch((error) => {
|
||||
this.lastEvent = 'connect-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastEvent = targets.length ? 'sync' : 'no-targets';
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async connect(target, attempt = 0) {
|
||||
const existing = this.connections.get(target.wsUrl);
|
||||
if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
...target,
|
||||
ws: null,
|
||||
reconnectTimer: null,
|
||||
closed: false,
|
||||
attempt,
|
||||
};
|
||||
this.connections.set(target.wsUrl, connection);
|
||||
|
||||
const headers = await this.getTargetAuthHeaders(target.httpUrl);
|
||||
if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
|
||||
connection.ws = ws;
|
||||
|
||||
ws.on('open', async () => {
|
||||
try {
|
||||
await this.registerTarget(target.httpUrl);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'register',
|
||||
deviceId: this.getDeviceId?.(),
|
||||
label: this.getAccountEmail?.() || this.appName,
|
||||
platform: process.platform,
|
||||
appVersion: this.appVersion,
|
||||
}));
|
||||
connection.attempt = 0;
|
||||
this.lastEvent = 'connected';
|
||||
this.lastError = null;
|
||||
this.onChange?.();
|
||||
} catch (error) {
|
||||
this.lastEvent = 'register-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
try { ws.close(); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => this.handleMessage(target, ws, raw));
|
||||
ws.on('close', () => this.scheduleReconnect(target.wsUrl));
|
||||
ws.on('error', (error) => {
|
||||
this.lastEvent = 'socket-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
}
|
||||
|
||||
async registerTarget(httpUrl) {
|
||||
const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
|
||||
await requestJson(url, {
|
||||
method: 'POST',
|
||||
headers: await this.getTargetAuthHeaders(httpUrl),
|
||||
body: {
|
||||
channel: 'desktop',
|
||||
endpointId: this.getDeviceId?.(),
|
||||
label: this.getAccountEmail?.() || this.appName,
|
||||
metadata: {
|
||||
platform: process.platform,
|
||||
appVersion: this.appVersion,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async disableCurrentTargets() {
|
||||
const deviceId = this.getDeviceId?.();
|
||||
if (!deviceId) return;
|
||||
|
||||
const targets = new Set([
|
||||
...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
|
||||
...(this.getRunningEnvironmentUrls?.() || []),
|
||||
]);
|
||||
|
||||
const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
|
||||
const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
|
||||
await requestJson(url, {
|
||||
method: 'PATCH',
|
||||
headers: await this.getTargetAuthHeaders(httpUrl),
|
||||
body: { enabled: false },
|
||||
});
|
||||
}));
|
||||
|
||||
const rejected = results.find((result) => result.status === 'rejected');
|
||||
if (rejected) {
|
||||
this.lastEvent = 'disable-endpoint-error';
|
||||
this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
|
||||
}
|
||||
}
|
||||
|
||||
async getTargetAuthHeaders(httpUrl) {
|
||||
const headers = {};
|
||||
const apiKey = this.getApiKey?.();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
handleMessage(target, ws, raw) {
|
||||
const message = readJsonMessage(raw);
|
||||
if (!message || message.type !== 'notification' || !message.payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shown = this.showNativeNotification(target, message.payload);
|
||||
if (shown && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'notification_ack',
|
||||
id: message.id || message.payload?.data?.tag || null,
|
||||
action: 'shown',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
showNativeNotification(target, payload) {
|
||||
if (!Notification.isSupported()) return false;
|
||||
|
||||
const notification = new Notification({
|
||||
title: payload.title || this.appName,
|
||||
body: payload.body || '',
|
||||
icon: this.getIconPath?.(),
|
||||
silent: false,
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
void this.openNotificationTarget?.({
|
||||
environmentUrl: target.httpUrl,
|
||||
sessionId: payload.data?.sessionId || null,
|
||||
provider: payload.data?.provider || null,
|
||||
}).catch((error) => {
|
||||
this.lastEvent = 'click-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
});
|
||||
|
||||
notification.show();
|
||||
this.lastEvent = 'notification-shown';
|
||||
this.lastError = null;
|
||||
this.onChange?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
scheduleReconnect(wsUrl) {
|
||||
const connection = this.connections.get(wsUrl);
|
||||
if (!connection || connection.closed || !this.settings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = connection.attempt + 1;
|
||||
connection.attempt = attempt;
|
||||
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
|
||||
void this.connect({
|
||||
httpUrl: connection.httpUrl,
|
||||
wsUrl: connection.wsUrl,
|
||||
}, attempt).catch((error) => {
|
||||
this.lastEvent = 'connect-error';
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.onChange?.();
|
||||
});
|
||||
}, delay);
|
||||
this.lastEvent = 'reconnecting';
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
closeConnection(connection) {
|
||||
connection.closed = true;
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer);
|
||||
connection.reconnectTimer = null;
|
||||
}
|
||||
try { connection.ws?.close(); } catch {}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const connection of this.connections.values()) {
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
this.connections.clear();
|
||||
this.onChange?.();
|
||||
}
|
||||
}
|
||||
766
electron/desktopWindow.js
Normal file
766
electron/desktopWindow.js
Normal file
@@ -0,0 +1,766 @@
|
||||
import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron';
|
||||
|
||||
import { ViewHost } from './viewHost.js';
|
||||
|
||||
const TITLEBAR_HEIGHT = 44;
|
||||
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
|
||||
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
|
||||
try {
|
||||
const source = new URL(sourceUrl);
|
||||
if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') {
|
||||
return true;
|
||||
}
|
||||
if (source.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
const controlPlane = new URL(controlPlaneUrl);
|
||||
return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getWebContentsProcessId(contents) {
|
||||
return {
|
||||
osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null,
|
||||
processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null,
|
||||
};
|
||||
}
|
||||
|
||||
export class DesktopWindowManager {
|
||||
constructor({
|
||||
appName,
|
||||
getWindowIconPath,
|
||||
getLauncherPath,
|
||||
getPreloadPath,
|
||||
openExternalUrl,
|
||||
getDesktopState,
|
||||
getDisplayTargetName,
|
||||
getRemoteEnvironmentMenuItems,
|
||||
getCloudState,
|
||||
getLocalState,
|
||||
actions,
|
||||
tabs,
|
||||
}) {
|
||||
this.appName = appName;
|
||||
this.getWindowIconPath = getWindowIconPath;
|
||||
this.getLauncherPath = getLauncherPath;
|
||||
this.getPreloadPath = getPreloadPath;
|
||||
this.openExternalUrl = openExternalUrl;
|
||||
this.getDesktopState = getDesktopState;
|
||||
this.getDisplayTargetName = getDisplayTargetName;
|
||||
this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
|
||||
this.getCloudState = getCloudState;
|
||||
this.getLocalState = getLocalState;
|
||||
this.actions = actions;
|
||||
this.tabs = tabs;
|
||||
|
||||
this.mainWindow = null;
|
||||
this.settingsWindow = null;
|
||||
this.tray = null;
|
||||
this.launcherLoaded = false;
|
||||
this.viewHost = new ViewHost({
|
||||
appName: this.appName,
|
||||
getMainWindow: () => this.mainWindow,
|
||||
getContentViewBounds: () => this.getContentViewBounds(),
|
||||
getPreloadPath: this.getPreloadPath,
|
||||
openExternalUrl: this.openExternalUrl,
|
||||
showError: this.actions.showError,
|
||||
});
|
||||
}
|
||||
|
||||
getMainWindow() {
|
||||
return this.mainWindow;
|
||||
}
|
||||
|
||||
getTrayImage() {
|
||||
const image = nativeImage.createFromPath(this.getWindowIconPath());
|
||||
return image.resize({ width: 18, height: 18 });
|
||||
}
|
||||
|
||||
getContentViewBounds() {
|
||||
if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
|
||||
const [width, height] = this.mainWindow.getContentSize();
|
||||
return {
|
||||
x: 0,
|
||||
y: TITLEBAR_HEIGHT,
|
||||
width,
|
||||
height: Math.max(0, height - TITLEBAR_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
detachActiveContentView() {
|
||||
this.viewHost.detachAll();
|
||||
}
|
||||
|
||||
async showTabPlaceholder(target, message) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
await this.viewHost.showTabPlaceholder(tabId, target, message);
|
||||
}
|
||||
|
||||
async showLocalStartupTarget(target, logs) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
|
||||
}
|
||||
|
||||
async showContentTarget(target) {
|
||||
const tabId = this.tabs.getTabIdForTarget(target);
|
||||
await this.viewHost.showContentTarget(tabId, target);
|
||||
}
|
||||
|
||||
destroyTabView(tabId) {
|
||||
this.viewHost.destroyTabView(tabId);
|
||||
}
|
||||
|
||||
emitDesktopState() {
|
||||
const state = this.getDesktopState();
|
||||
if (this.mainWindow && !this.mainWindow.webContents.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('cloudcli-desktop:state-updated', state);
|
||||
}
|
||||
if (this.settingsWindow && !this.settingsWindow.webContents.isDestroyed()) {
|
||||
this.settingsWindow.webContents.send('cloudcli-desktop:state-updated', state);
|
||||
}
|
||||
}
|
||||
|
||||
emitLauncherCommand(command) {
|
||||
if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
|
||||
this.mainWindow.webContents.send('cloudcli-desktop:launcher-command', command);
|
||||
}
|
||||
|
||||
emitSettingsCommand(command) {
|
||||
if (!this.settingsWindow || this.settingsWindow.webContents.isDestroyed()) return;
|
||||
this.settingsWindow.webContents.send('cloudcli-desktop:launcher-command', command);
|
||||
}
|
||||
|
||||
syncSettingsWindowBounds() {
|
||||
if (!this.mainWindow || !this.settingsWindow || this.settingsWindow.isDestroyed()) return;
|
||||
this.settingsWindow.setBounds(this.mainWindow.getBounds());
|
||||
}
|
||||
|
||||
async ensureSettingsWindow(sheet = 'desktop-settings') {
|
||||
if (!this.mainWindow) return null;
|
||||
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.syncSettingsWindowBounds();
|
||||
this.emitSettingsCommand({ type: 'open-sheet', sheet });
|
||||
this.settingsWindow.focus();
|
||||
return this.settingsWindow;
|
||||
}
|
||||
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
parent: this.mainWindow,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
movable: false,
|
||||
skipTaskbar: true,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: this.getPreloadPath(),
|
||||
},
|
||||
});
|
||||
this.syncSettingsWindowBounds();
|
||||
this.viewHost.configureChildWebContents(this.settingsWindow.webContents);
|
||||
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null;
|
||||
});
|
||||
await this.settingsWindow.loadFile(this.getLauncherPath(), {
|
||||
query: { modal: '1', sheet },
|
||||
});
|
||||
return this.settingsWindow;
|
||||
}
|
||||
|
||||
closeSettingsWindow() {
|
||||
if (!this.settingsWindow || this.settingsWindow.isDestroyed()) return;
|
||||
this.settingsWindow.close();
|
||||
}
|
||||
|
||||
async showTarget(target, { trackTab = true } = {}) {
|
||||
if (!this.mainWindow) return;
|
||||
if (trackTab) {
|
||||
this.tabs.upsertTarget(target);
|
||||
}
|
||||
this.actions.setActiveTarget(target);
|
||||
this.buildAppMenu();
|
||||
this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
|
||||
const finalUrl = await this.showContentTarget(target);
|
||||
this.emitDesktopState();
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
async showLauncher() {
|
||||
if (!this.mainWindow) return;
|
||||
const target = { kind: 'launcher', name: this.appName, url: null };
|
||||
this.tabs.upsertTarget(target);
|
||||
this.actions.setActiveTarget(target);
|
||||
this.detachActiveContentView();
|
||||
this.buildAppMenu();
|
||||
this.mainWindow.setTitle(this.appName);
|
||||
this.mainWindow.webContents.focus();
|
||||
if (!this.launcherLoaded) {
|
||||
await this.mainWindow.loadFile(this.getLauncherPath());
|
||||
this.launcherLoaded = true;
|
||||
} else {
|
||||
this.emitDesktopState();
|
||||
}
|
||||
}
|
||||
|
||||
async switchDesktopTab(tabId) {
|
||||
const tab = this.tabs.activate(tabId);
|
||||
if (!tab || !this.mainWindow) return this.getDesktopState();
|
||||
|
||||
if (tab.id === 'home' || tab.kind === 'launcher') {
|
||||
await this.showLauncher();
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
if (!tab.target?.url) {
|
||||
throw new Error('This tab does not have a target URL.');
|
||||
}
|
||||
|
||||
await this.showTarget(tab.target, { trackTab: false });
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async reloadActiveTab() {
|
||||
const activeTab = this.tabs.getActiveTab();
|
||||
if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') {
|
||||
this.emitDesktopState();
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
const reloaded = this.viewHost.reloadTab(activeTab.id);
|
||||
if (!reloaded && activeTab.target?.url) {
|
||||
await this.showTarget(activeTab.target, { trackTab: false });
|
||||
}
|
||||
this.emitDesktopState();
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async navigateActiveView(url) {
|
||||
const navigated = await this.viewHost.navigateActiveView(url);
|
||||
this.emitDesktopState();
|
||||
return navigated;
|
||||
}
|
||||
|
||||
async readAuthTokenForTarget(url) {
|
||||
return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
openActiveTabDevTools() {
|
||||
if (this.viewHost.openActiveViewDevTools()) return;
|
||||
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
|
||||
}
|
||||
|
||||
reloadActiveBrowserViewForDiagnostics() {
|
||||
if (this.viewHost.reloadActiveView()) return;
|
||||
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.'));
|
||||
}
|
||||
|
||||
detachActiveBrowserViewForDiagnostics() {
|
||||
if (this.viewHost.detachActiveView()) return;
|
||||
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.'));
|
||||
}
|
||||
|
||||
copyWebContentsDiagnostics() {
|
||||
const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics();
|
||||
const tabViewByContentsId = new Map(
|
||||
tabViewDiagnostics
|
||||
.filter((item) => item.webContentsId != null)
|
||||
.map((item) => [item.webContentsId, item])
|
||||
);
|
||||
|
||||
const rows = electronWebContents.getAllWebContents().map((contents) => {
|
||||
const destroyed = contents.isDestroyed();
|
||||
const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents);
|
||||
const tabView = tabViewByContentsId.get(contents.id);
|
||||
let owner = 'unknown';
|
||||
if (this.mainWindow?.webContents?.id === contents.id) {
|
||||
owner = 'main-window';
|
||||
} else if (this.settingsWindow?.webContents?.id === contents.id) {
|
||||
owner = 'settings-window';
|
||||
} else if (tabView) {
|
||||
owner = `browser-view:${tabView.tabId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: contents.id,
|
||||
owner,
|
||||
osProcessId: processIds.osProcessId,
|
||||
processId: processIds.processId,
|
||||
url: destroyed ? null : contents.getURL(),
|
||||
title: destroyed ? null : contents.getTitle(),
|
||||
destroyed,
|
||||
focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(),
|
||||
attached: tabView ? tabView.attached : null,
|
||||
active: tabView ? tabView.active : null,
|
||||
};
|
||||
});
|
||||
|
||||
const activeTab = this.tabs.getActiveTab();
|
||||
const diagnostics = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeTabId: this.tabs.activeTabId,
|
||||
activeTab: activeTab
|
||||
? {
|
||||
id: activeTab.id,
|
||||
title: activeTab.title,
|
||||
kind: activeTab.kind,
|
||||
targetUrl: activeTab.target?.url || null,
|
||||
}
|
||||
: null,
|
||||
tabViews: tabViewDiagnostics,
|
||||
webContents: rows,
|
||||
};
|
||||
|
||||
clipboard.writeText(JSON.stringify(diagnostics, null, 2));
|
||||
}
|
||||
|
||||
async closeDesktopTab(tabId) {
|
||||
const tab = this.tabs.remove(tabId);
|
||||
if (!tab) return this.getDesktopState();
|
||||
this.destroyTabView(tabId);
|
||||
if (this.tabs.activeTabId === 'home') {
|
||||
await this.showLauncher();
|
||||
} else {
|
||||
this.emitDesktopState();
|
||||
}
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
buildEnvironmentActionsSubmenu(environment) {
|
||||
const items = [];
|
||||
const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||
items.push({
|
||||
label: 'Open Environment',
|
||||
click: () => void this.actions.openEnvironmentInDesktop(environment)
|
||||
.catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open in Browser',
|
||||
click: () => void this.actions.openEnvironmentInBrowser(environment)
|
||||
.catch((error) => this.actions.showError('Could not open environment in browser', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open in VS Code',
|
||||
click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
|
||||
.catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open in Cursor',
|
||||
click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
|
||||
.catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Open SSH Terminal',
|
||||
click: () => void this.actions.openEnvironmentInSsh(environment)
|
||||
.catch((error) => this.actions.showError('Could not open SSH terminal', error)),
|
||||
});
|
||||
items.push({
|
||||
label: 'Copy Mobile/Web URL',
|
||||
click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
|
||||
});
|
||||
if (environment.status !== 'running') {
|
||||
items.unshift({
|
||||
label: environment.status === 'paused' ? 'Resume' : 'Start',
|
||||
click: () => void this.actions.startEnvironment(environment)
|
||||
.catch((error) => this.actions.showError('Could not start environment', error)),
|
||||
});
|
||||
}
|
||||
if (environment.status === 'running') {
|
||||
items.push({
|
||||
label: 'Stop',
|
||||
click: () => void this.actions.stopEnvironment(environment)
|
||||
.catch((error) => this.actions.showError('Could not stop environment', error)),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
buildTrayEnvironmentSection() {
|
||||
const cloudState = this.getCloudState();
|
||||
if (!cloudState.account?.apiKey) {
|
||||
return [
|
||||
{
|
||||
label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
|
||||
click: () => void this.actions.connectCloudAccount()
|
||||
.catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!cloudState.environments.length) {
|
||||
return [{ label: 'No environments found', enabled: false }];
|
||||
}
|
||||
|
||||
return cloudState.environments.map((environment) => ({
|
||||
label: `${environment.name || environment.subdomain} - ${environment.status}`,
|
||||
submenu: this.buildEnvironmentActionsSubmenu(environment),
|
||||
}));
|
||||
}
|
||||
|
||||
buildAppMenu() {
|
||||
if (!this.mainWindow) return;
|
||||
const cloudState = this.getCloudState();
|
||||
const localState = this.getLocalState();
|
||||
const remoteItems = this.getRemoteEnvironmentMenuItems();
|
||||
const cloudAccountLabel = cloudState.account?.apiKey
|
||||
? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
|
||||
: (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: this.appName,
|
||||
submenu: [
|
||||
{ label: `About ${this.appName}`, role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Show Launcher',
|
||||
accelerator: 'CmdOrCtrl+Shift+L',
|
||||
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
|
||||
},
|
||||
{
|
||||
label: 'Switch Environment',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||
},
|
||||
{
|
||||
label: 'Diagnostics',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Copy Diagnostics',
|
||||
click: () => void this.actions.copyDiagnostics(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
|
||||
role: 'hide',
|
||||
visible: process.platform === 'darwin',
|
||||
},
|
||||
{ label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
|
||||
{ label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
|
||||
{ type: 'separator', visible: process.platform === 'darwin' },
|
||||
{ label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Environment',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Show Launcher',
|
||||
accelerator: 'CmdOrCtrl+Shift+L',
|
||||
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
|
||||
},
|
||||
{
|
||||
label: 'Switch Environment',
|
||||
accelerator: 'CmdOrCtrl+Shift+E',
|
||||
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Open Local CloudCLI',
|
||||
accelerator: 'CmdOrCtrl+L',
|
||||
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Open Local Web UI in Browser',
|
||||
accelerator: 'CmdOrCtrl+Shift+W',
|
||||
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Copy Local Web URL',
|
||||
accelerator: 'CmdOrCtrl+Shift+U',
|
||||
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Keep Local Server Running After Quit',
|
||||
type: 'checkbox',
|
||||
checked: localState.desktopSettings.keepLocalServerRunning,
|
||||
click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
|
||||
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
|
||||
},
|
||||
{
|
||||
label: 'Allow LAN Access to Local Server',
|
||||
type: 'checkbox',
|
||||
checked: localState.desktopSettings.exposeLocalServerOnNetwork,
|
||||
click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
|
||||
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cloud',
|
||||
submenu: [
|
||||
{
|
||||
label: cloudAccountLabel,
|
||||
accelerator: 'CmdOrCtrl+Shift+C',
|
||||
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||
},
|
||||
{
|
||||
label: 'Refresh Cloud Environments',
|
||||
click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
|
||||
enabled: Boolean(cloudState.account?.apiKey),
|
||||
},
|
||||
{
|
||||
label: 'Logout CloudCLI Account',
|
||||
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
|
||||
enabled: Boolean(cloudState.account?.apiKey),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Remote Environments',
|
||||
submenu: remoteItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{
|
||||
label: 'Open Active Tab DevTools',
|
||||
click: () => this.openActiveTabDevTools(),
|
||||
},
|
||||
{
|
||||
label: 'Copy WebContents Diagnostics',
|
||||
click: () => this.copyWebContentsDiagnostics(),
|
||||
},
|
||||
{
|
||||
label: 'Reload Active BrowserView',
|
||||
click: () => this.reloadActiveBrowserViewForDiagnostics(),
|
||||
},
|
||||
{
|
||||
label: 'Detach Active BrowserView',
|
||||
click: () => this.detachActiveBrowserViewForDiagnostics(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open cloudcli.ai',
|
||||
click: () => void this.actions.openCloudDashboard(),
|
||||
},
|
||||
{
|
||||
label: 'Copy Diagnostics',
|
||||
click: () => void this.actions.copyDiagnostics(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
this.buildTrayMenu();
|
||||
}
|
||||
|
||||
buildTrayMenu() {
|
||||
if (!this.tray) return;
|
||||
const cloudState = this.getCloudState();
|
||||
const localState = this.getLocalState();
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: 'Local',
|
||||
submenu: [
|
||||
{
|
||||
label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
|
||||
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Open Local in Browser',
|
||||
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
|
||||
},
|
||||
{
|
||||
label: 'Copy Local URL',
|
||||
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cloud Environments',
|
||||
submenu: this.buildTrayEnvironmentSection(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
|
||||
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
|
||||
},
|
||||
{
|
||||
label: 'Logout CloudCLI Account',
|
||||
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
|
||||
enabled: Boolean(cloudState.account?.apiKey),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: `Quit ${this.appName}`,
|
||||
role: 'quit',
|
||||
},
|
||||
];
|
||||
|
||||
this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
|
||||
this.tray.setContextMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
async showDesktopSettings() {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
await this.ensureSettingsWindow('desktop-settings');
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async showLocalSettings() {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
await this.ensureSettingsWindow('local-settings');
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async showActiveEnvironmentActionsMenu() {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
const activeTarget = this.actions.getActiveTarget();
|
||||
if (activeTarget?.kind !== 'remote') return this.getDesktopState();
|
||||
|
||||
const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
|
||||
if (!environment) return this.getDesktopState();
|
||||
|
||||
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
|
||||
menu.popup({ window: this.mainWindow });
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
async showEnvironmentActionsMenu(environmentId) {
|
||||
if (!this.mainWindow) return this.getDesktopState();
|
||||
const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
|
||||
if (!environment) return this.getDesktopState();
|
||||
|
||||
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
|
||||
menu.popup({ window: this.mainWindow });
|
||||
return this.getDesktopState();
|
||||
}
|
||||
|
||||
configurePermissions() {
|
||||
const isAllowedPermission = (webContents, permission) => {
|
||||
const sourceUrl = webContents.getURL();
|
||||
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
|
||||
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
|
||||
};
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
callback(isAllowedPermission(webContents, permission));
|
||||
});
|
||||
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
|
||||
if (!webContents) return false;
|
||||
return isAllowedPermission(webContents, permission);
|
||||
});
|
||||
}
|
||||
|
||||
createTray() {
|
||||
if (this.tray) return;
|
||||
this.tray = new Tray(this.getTrayImage());
|
||||
this.tray.on('click', () => {
|
||||
if (!this.mainWindow) return;
|
||||
if (this.mainWindow.isVisible()) {
|
||||
this.mainWindow.focus();
|
||||
} else {
|
||||
this.mainWindow.show();
|
||||
}
|
||||
});
|
||||
this.buildTrayMenu();
|
||||
}
|
||||
|
||||
async createWindow() {
|
||||
this.mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1024,
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
backgroundColor: '#0f172a',
|
||||
title: this.appName,
|
||||
icon: this.getWindowIconPath(),
|
||||
titleBarStyle: 'hidden',
|
||||
...(process.platform === 'darwin'
|
||||
? { trafficLightPosition: { x: 18, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
|
||||
symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
|
||||
height: 44,
|
||||
},
|
||||
}),
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: this.getPreloadPath(),
|
||||
},
|
||||
});
|
||||
|
||||
this.mainWindow.once('ready-to-show', () => {
|
||||
this.mainWindow?.show();
|
||||
});
|
||||
|
||||
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
this.mainWindow.on('resize', () => {
|
||||
this.viewHost.resizeActiveView();
|
||||
this.syncSettingsWindowBounds();
|
||||
});
|
||||
|
||||
this.mainWindow.on('move', () => {
|
||||
this.syncSettingsWindowBounds();
|
||||
});
|
||||
|
||||
this.mainWindow.on('closed', () => {
|
||||
this.viewHost.clear();
|
||||
this.settingsWindow = null;
|
||||
this.mainWindow = null;
|
||||
this.launcherLoaded = false;
|
||||
});
|
||||
|
||||
this.buildAppMenu();
|
||||
await this.showLauncher();
|
||||
}
|
||||
}
|
||||
14
electron/launcher/index.html
Normal file
14
electron/launcher/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
|
||||
<title>CloudCLI Desktop</title>
|
||||
<link rel="stylesheet" href="./launcher.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./launcher.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
801
electron/launcher/launcher.css
Normal file
801
electron/launcher/launcher.css
Normal file
@@ -0,0 +1,801 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html.cc-modal-window,
|
||||
body.cc-modal-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #111315;
|
||||
--s1: #171a1d;
|
||||
--s2: #1e2328;
|
||||
--s3: #262d34;
|
||||
--b-subtle: #28303a;
|
||||
--b: #313b46;
|
||||
--b-strong: #42505f;
|
||||
--tx: #f5f7fa;
|
||||
--tx2: #adb8c5;
|
||||
--tx3: #7f8b98;
|
||||
--brand: #0a66d9;
|
||||
--brand-2: #5fa5ff;
|
||||
--brand-faint: rgba(10, 102, 217, 0.14);
|
||||
--ok: #2aa775;
|
||||
--warn: #d48b20;
|
||||
--err: #d65252;
|
||||
--tab-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--tab-active-bg: rgba(255, 255, 255, 0.14);
|
||||
--mono: "SF Mono", "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f3f5f8;
|
||||
--s1: #ffffff;
|
||||
--s2: #f7f9fb;
|
||||
--s3: #edf1f5;
|
||||
--b-subtle: #e5eaf0;
|
||||
--b: #d8dee6;
|
||||
--b-strong: #c3ccd6;
|
||||
--tx: #11151a;
|
||||
--tx2: #566171;
|
||||
--tx3: #7f8b98;
|
||||
--brand: #0a66d9;
|
||||
--brand-2: #0f5fc6;
|
||||
--brand-faint: rgba(10, 102, 217, 0.09);
|
||||
--ok: #1f8e61;
|
||||
--warn: #b67515;
|
||||
--err: #c24747;
|
||||
--tab-hover-bg: rgba(15, 23, 42, 0.05);
|
||||
--tab-active-bg: rgba(15, 23, 42, 0.08);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--tx);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--b);
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--tx3);
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--b-subtle);
|
||||
background: color-mix(in srgb, var(--s1) 90%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.titlebar button,
|
||||
.titlebar input,
|
||||
.titlebar .no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand .mk {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 13px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--b);
|
||||
background: var(--s1);
|
||||
color: var(--tx);
|
||||
font-weight: 500;
|
||||
transition: border-color 0.12s, background 0.12s, filter 0.12s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--b-strong);
|
||||
background: var(--s2);
|
||||
}
|
||||
|
||||
.btn.pri {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.pri:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn.sm {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--s2);
|
||||
border-color: var(--b);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--s2);
|
||||
color: var(--tx2);
|
||||
border: 1px solid var(--b-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge.idle {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.cc-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 27px;
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--b-subtle);
|
||||
background: var(--s1);
|
||||
font-size: 11px;
|
||||
color: var(--tx2);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.statusbar .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.status-msg.progress {
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.cc-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 8, 11, 0.28);
|
||||
display: none;
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.cc-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cc-sheet {
|
||||
width: 620px;
|
||||
max-width: min(92vw, 620px);
|
||||
max-height: min(720px, 82vh);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--b);
|
||||
background: color-mix(in srgb, var(--s1) 98%, transparent);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.cc-sheet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
border-bottom: 1px solid var(--b-subtle);
|
||||
}
|
||||
|
||||
.cc-sheet-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cc-sheet-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cc-sheet-subtitle {
|
||||
margin-top: 6px;
|
||||
color: var(--tx2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-sheet-close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cc-sheet-body {
|
||||
overflow: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cc-sheet-footer {
|
||||
padding: 14px 20px 18px;
|
||||
border-top: 1px solid var(--b-subtle);
|
||||
}
|
||||
|
||||
.cc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cc-section-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cc-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.cc-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cc-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--b-subtle);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--s2) 86%, transparent), var(--s1));
|
||||
}
|
||||
|
||||
.cc-row2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cc-meta {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-toggle,
|
||||
.cc-choice {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
color: var(--tx2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-toggle input,
|
||||
.cc-choice input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.cc-toggle b,
|
||||
.cc-choice b {
|
||||
color: var(--tx);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-choice-group {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cc-permissions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--b-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--s1);
|
||||
}
|
||||
|
||||
.cc-note {
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cc-permission-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--b-subtle);
|
||||
}
|
||||
|
||||
.cc-permission-title {
|
||||
color: var(--tx);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-permission-detail {
|
||||
margin-top: 2px;
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cc-permission-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cc-kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
color: var(--tx2);
|
||||
}
|
||||
|
||||
.cc-kv span:last-child {
|
||||
color: var(--tx);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cc-actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.cc-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--b-subtle);
|
||||
background: var(--s2);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-status-badge.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.cc-status-badge.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.cc-status-badge.idle {
|
||||
color: var(--tx3);
|
||||
}
|
||||
|
||||
.v-sidebar {
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 12px;
|
||||
border-right: 1px solid var(--b-subtle);
|
||||
background: var(--s1);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sb-grp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sb-grp .lbl {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.sb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
color: var(--tx2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sb-item > span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sb-item .sb-meta {
|
||||
font-size: 11px;
|
||||
color: var(--tx3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.sb-item:hover {
|
||||
background: var(--s2);
|
||||
}
|
||||
|
||||
.sb-item.active {
|
||||
background: var(--brand-faint);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.sb-item.active svg {
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.sb-main {
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pane-h {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pane-sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--tx2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--b);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--s1) 94%, transparent);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 620px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-t {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
margin-top: 4px;
|
||||
color: var(--tx2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.env {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--b);
|
||||
border-radius: 12px;
|
||||
background: var(--s1);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.env:hover {
|
||||
border-color: var(--b-strong);
|
||||
}
|
||||
|
||||
.env-i {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.env-n {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.env-u {
|
||||
font-size: 12px;
|
||||
color: var(--tx3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--tx2);
|
||||
background: var(--s2);
|
||||
border: 1px solid var(--b-subtle);
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 1px dashed var(--b);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
color: var(--tx2);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
body.mac .titlebar {
|
||||
padding-left: 92px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
body.win .titlebar {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
.titlebar .brand {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tb-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tb-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 112px;
|
||||
max-width: 232px;
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 7px 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--tx2);
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.tb-tab:hover {
|
||||
background: var(--tab-hover-bg);
|
||||
}
|
||||
|
||||
.tb-tab.active {
|
||||
background: var(--tab-active-bg);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.tb-tab span:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 20ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tb-close {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 8px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--tx3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tb-close:hover {
|
||||
background: var(--tab-hover-bg);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.tb-action {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.v-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sb {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.env-tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cc-sheet {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cc-row2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
687
electron/launcher/launcher.js
Normal file
687
electron/launcher/launcher.js
Normal file
@@ -0,0 +1,687 @@
|
||||
window.__APP_VERSION__ = '1.34.0';
|
||||
window.__MOCK_STATE__ = {
|
||||
account: { connected: true, email: 'you@cloudcli.ai' },
|
||||
activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
|
||||
cloudLoading: false,
|
||||
desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false, themeMode: 'system' },
|
||||
localWebUrl: 'http://localhost:3001',
|
||||
shareableWebUrl: 'http://localhost:3001',
|
||||
localServerRunning: false,
|
||||
localStartupLogs: [],
|
||||
environments: [
|
||||
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
|
||||
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
|
||||
{ id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
|
||||
{ id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
|
||||
],
|
||||
};
|
||||
|
||||
(function cloudCliLauncher() {
|
||||
var MOCK = window.__MOCK_STATE__ || {};
|
||||
var VERSION = window.__APP_VERSION__ || '';
|
||||
var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
|
||||
var SEARCH = new URLSearchParams(window.location.search || '');
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
var mockState = clone(MOCK);
|
||||
var mockBridge = {
|
||||
getState: function () { return Promise.resolve(clone(mockState)); },
|
||||
openLocal: function () {
|
||||
mockState.localServerRunning = true;
|
||||
mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
openLocalWebUi: function () {
|
||||
mockState.localServerRunning = true;
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
|
||||
connectCloud: function () {
|
||||
mockState.account = { connected: true, email: 'you@cloudcli.ai' };
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
disconnectCloud: function () {
|
||||
mockState.account = { connected: false, email: null };
|
||||
mockState.environments = [];
|
||||
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; });
|
||||
mockState.activeTabId = 'home';
|
||||
mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null };
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
|
||||
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
|
||||
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
|
||||
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
|
||||
showLauncher: function () { return Promise.resolve(clone(mockState)); },
|
||||
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||
showDesktopSettings: function () { return Promise.resolve(clone(mockState)); },
|
||||
closeSettingsWindow: function () { return Promise.resolve(clone(mockState)); },
|
||||
showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
|
||||
openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
|
||||
runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
|
||||
switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
|
||||
closeTab: function (id) {
|
||||
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
|
||||
if (mockState.activeTabId === id) mockState.activeTabId = 'home';
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
updateSetting: function (key, value) {
|
||||
mockState.desktopSettings = mockState.desktopSettings || {};
|
||||
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
openEnvironment: function (id) {
|
||||
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
|
||||
if (env) {
|
||||
env.status = 'starting';
|
||||
setTimeout(function () {
|
||||
env.status = 'running';
|
||||
mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
|
||||
}, 1700);
|
||||
}
|
||||
return Promise.resolve(clone(mockState));
|
||||
},
|
||||
};
|
||||
|
||||
var bridge = window.cloudcliDesktop || mockBridge;
|
||||
|
||||
var ICONS = {
|
||||
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
||||
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
|
||||
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
|
||||
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
|
||||
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
|
||||
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
|
||||
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
};
|
||||
var FILLED = { play: true };
|
||||
|
||||
function icon(name, size) {
|
||||
size = size || 16;
|
||||
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function statusMeta(status) {
|
||||
var map = {
|
||||
running: { label: 'Running', cls: 'ok', dot: '#10b981', verb: 'Opening', open: 'Open' },
|
||||
starting: { label: 'Starting', cls: 'warn', dot: '#f59e0b', verb: 'Starting', open: 'Open', busy: true },
|
||||
stopped: { label: 'Stopped', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' },
|
||||
paused: { label: 'Paused', cls: 'warn', dot: '#f59e0b', verb: 'Resuming', open: 'Resume' },
|
||||
};
|
||||
return map[status] || { label: status || 'Unknown', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' };
|
||||
}
|
||||
|
||||
function connected(state) {
|
||||
return !!(state && state.account && state.account.connected);
|
||||
}
|
||||
|
||||
function authState(state) {
|
||||
return state && state.account ? (state.account.authState || (state.account.connected ? 'connected' : 'logged_out')) : 'logged_out';
|
||||
}
|
||||
|
||||
function accountLabel(state) {
|
||||
if (authState(state) === 'expired') return 'Reconnect';
|
||||
if (state && state.account && state.account.email) return state.account.email;
|
||||
if (connected(state)) return 'Connected';
|
||||
return 'Log in';
|
||||
}
|
||||
|
||||
function localUrl(state) {
|
||||
return (state && (state.shareableWebUrl || state.localWebUrl)) || '';
|
||||
}
|
||||
|
||||
function envCount(state) {
|
||||
var count = state && state.environments ? state.environments.length : 0;
|
||||
return count + ' environment' + (count === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function errMsg(error) {
|
||||
return error && error.message ? error.message : String(error);
|
||||
}
|
||||
|
||||
function resolveTheme(state) {
|
||||
var settings = state && state.desktopSettings ? state.desktopSettings : {};
|
||||
var mode = settings.themeMode || 'system';
|
||||
if (mode === 'light' || mode === 'dark') return mode;
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
var CC = {
|
||||
icon: icon,
|
||||
esc: esc,
|
||||
statusMeta: statusMeta,
|
||||
connected: connected,
|
||||
authState: authState,
|
||||
accountLabel: accountLabel,
|
||||
localUrl: localUrl,
|
||||
envCount: envCount,
|
||||
version: VERSION,
|
||||
logoUrl: LOGO_URL,
|
||||
platform: 'win',
|
||||
state: clone(MOCK),
|
||||
ui: {},
|
||||
_busyEnv: null,
|
||||
_status: { msg: '', tone: '' },
|
||||
_reg: {},
|
||||
_wired: false,
|
||||
_poll: null,
|
||||
modalMode: SEARCH.get('modal') === '1',
|
||||
};
|
||||
|
||||
window.CC = CC;
|
||||
|
||||
var app;
|
||||
var overlay;
|
||||
|
||||
CC.setState = function (state) {
|
||||
var currentSheet = CC.ui.openSheet || (CC.modalMode ? (CC.ui.initialSheet || 'desktop-settings') : null);
|
||||
var sheetBody = overlay ? overlay.querySelector('.cc-sheet-body') : null;
|
||||
var scrollTop = sheetBody ? sheetBody.scrollTop : 0;
|
||||
if (state && typeof state === 'object') CC.state = state;
|
||||
CC.applyTheme(CC.state);
|
||||
CC.render(CC.state);
|
||||
if (currentSheet) {
|
||||
CC.openSheet(currentSheet, { scrollTop: scrollTop });
|
||||
}
|
||||
};
|
||||
|
||||
CC.applyTheme = function (state) {
|
||||
var settings = state && state.desktopSettings ? state.desktopSettings : {};
|
||||
var themeMode = settings.themeMode || 'system';
|
||||
var resolvedTheme = resolveTheme(state);
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
document.documentElement.setAttribute('data-theme-mode', themeMode);
|
||||
};
|
||||
|
||||
CC.refresh = function () {
|
||||
return Promise.resolve(bridge.getState()).then(function (state) {
|
||||
CC.setState(state);
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
CC.run = function (label, fn) {
|
||||
CC._status = { msg: label, tone: 'progress' };
|
||||
CC.render(CC.state);
|
||||
return Promise.resolve()
|
||||
.then(fn)
|
||||
.then(function (state) {
|
||||
if (state && state.environments) CC.state = state;
|
||||
return CC.refresh();
|
||||
})
|
||||
.then(function () {
|
||||
CC._status = { msg: '', tone: '' };
|
||||
CC.render(CC.state);
|
||||
})
|
||||
.catch(function (error) {
|
||||
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||
CC.render(CC.state);
|
||||
});
|
||||
};
|
||||
|
||||
CC.startPolling = function () {
|
||||
if (CC._poll) return;
|
||||
var ticks = 0;
|
||||
CC._poll = setInterval(function () {
|
||||
ticks += 1;
|
||||
Promise.resolve(bridge.getState()).then(function (state) {
|
||||
CC.setState(state);
|
||||
var anyStarting = (state.environments || []).some(function (environment) { return environment.status === 'starting'; });
|
||||
if (!anyStarting || ticks > 16) {
|
||||
clearInterval(CC._poll);
|
||||
CC._poll = null;
|
||||
if (!anyStarting) {
|
||||
CC._status = { msg: '', tone: '' };
|
||||
CC.render(CC.state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
CC.openEnv = function (id) {
|
||||
var env = (CC.state.environments || []).filter(function (environment) { return environment.id === id; })[0];
|
||||
var meta = statusMeta(env ? env.status : '');
|
||||
CC._busyEnv = id;
|
||||
CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
|
||||
if (env) {
|
||||
var tabId = 'remote:' + env.id;
|
||||
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Launcher', kind: 'launcher', closable: false }];
|
||||
tabs = tabs.map(function (tab) {
|
||||
tab.active = false;
|
||||
return tab;
|
||||
});
|
||||
var existing = tabs.filter(function (tab) { return tab.id === tabId; })[0];
|
||||
if (existing) {
|
||||
existing.active = true;
|
||||
existing.title = env.name || env.subdomain;
|
||||
} else {
|
||||
tabs.push({ id: tabId, title: env.name || env.subdomain, kind: 'remote', closable: true, active: true });
|
||||
}
|
||||
CC.state.tabs = tabs;
|
||||
CC.state.activeTabId = tabId;
|
||||
}
|
||||
if (env && env.status !== 'running') env.status = 'starting';
|
||||
CC.render(CC.state);
|
||||
return Promise.resolve(bridge.openEnvironment(id)).then(function (state) {
|
||||
if (state && state.environments) CC.setState(state);
|
||||
CC.startPolling();
|
||||
}).catch(function (error) {
|
||||
CC._busyEnv = null;
|
||||
if (env) env.status = 'stopped';
|
||||
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||
CC.render(CC.state);
|
||||
});
|
||||
};
|
||||
|
||||
CC.act = function (name, node) {
|
||||
switch (name) {
|
||||
case 'local':
|
||||
return CC.run('Starting Local CloudCLI...', function () { return bridge.openLocal(); });
|
||||
case 'connect':
|
||||
return CC.run('Opening cloudcli.ai to connect your account...', function () { return bridge.connectCloud(); });
|
||||
case 'logout':
|
||||
return CC.run('Logging out...', function () { return bridge.disconnectCloud(); });
|
||||
case 'open-web':
|
||||
return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); });
|
||||
case 'copy-web':
|
||||
return CC.run('Copied local URL to clipboard', function () { return bridge.copyLocalWebUrl(); });
|
||||
case 'diagnostics':
|
||||
return CC.run('Copied diagnostics to clipboard', function () { return bridge.copyDiagnostics(); });
|
||||
case 'set-setting':
|
||||
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
|
||||
case 'set-theme-mode':
|
||||
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
|
||||
case 'settings-toggle':
|
||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||
case 'desktop-settings-toggle':
|
||||
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
|
||||
case 'local-settings-toggle':
|
||||
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
|
||||
case 'settings-close':
|
||||
return CC.closeSheet();
|
||||
case 'dashboard':
|
||||
return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
|
||||
case 'refresh-environments':
|
||||
return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); });
|
||||
case 'refresh-tab':
|
||||
return CC.run('Refreshing tab...', function () { return bridge.refreshActiveTab(); });
|
||||
case 'env-action':
|
||||
return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
|
||||
case 'env-menu':
|
||||
return CC.run('Opening environment actions...', function () { return bridge.showActiveEnvironmentActionsMenu(); });
|
||||
case 'env-row-menu':
|
||||
return CC.run('Opening environment actions...', function () { return bridge.showEnvironmentActionsMenu(node.getAttribute('data-cc-environment-id')); });
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
function renderTabs(state) {
|
||||
var tabs = state.tabs && state.tabs.length ? state.tabs : [{ id: 'home', title: 'Home', closable: false, active: true }];
|
||||
return tabs.map(function (tab) {
|
||||
var title = tab.title || '';
|
||||
var visibleChars = Math.min(title.length, 20);
|
||||
var tabWidth = Math.max(112, Math.min(232, (visibleChars * 8) + (tab.closable ? 56 : 38)));
|
||||
return '<button class="tb-tab no-drag' + (tab.active ? ' active' : '') + '" data-cc-tab="' + esc(tab.id) + '" title="' + esc(title) + '" style="width:' + tabWidth + 'px;flex-basis:' + tabWidth + 'px">' +
|
||||
'<span>' + esc(title) + '</span>' +
|
||||
(tab.closable ? '<span class="tb-close" data-cc-close-tab="' + esc(tab.id) + '" title="Close tab">×</span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
CC.titlebar = function (state) {
|
||||
var conn = connected(state);
|
||||
var activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null;
|
||||
var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null;
|
||||
if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) {
|
||||
activeEnvironmentId = activeTab.id.replace(/^remote:/, '');
|
||||
}
|
||||
var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) ||
|
||||
(activeTab && activeTab.id !== 'home');
|
||||
var envActions = activeEnvironmentId ? '<button class="btn sm tb-action no-drag" data-cc-action="env-row-menu" data-cc-environment-id="' + esc(activeEnvironmentId) + '" title="Open environment actions">Open environment in...</button>' : '';
|
||||
var refreshAction = activeRefreshable ? '<button class="icon-btn tb-action no-drag" data-cc-action="refresh-tab" title="Refresh tab">' + icon('refresh', 16) + '</button>' : '';
|
||||
var logoutAction = (conn || authState(state) === 'expired') ? '<button class="icon-btn tb-action no-drag" data-cc-action="logout" title="Logout">' + icon('logOut', 16) + '</button>' : '';
|
||||
return '<div class="titlebar">' +
|
||||
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
|
||||
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
refreshAction +
|
||||
envActions +
|
||||
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
|
||||
logoutAction +
|
||||
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.statusbar = function (state) {
|
||||
var status = CC._status || {};
|
||||
var running = !!state.localServerRunning;
|
||||
return '<div class="statusbar">' +
|
||||
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
|
||||
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
|
||||
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
|
||||
'<span style="flex:1"></span>' +
|
||||
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
|
||||
'<span>v' + esc(VERSION) + '</span>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.renderSheet = function (title, subtitle, sections, footer) {
|
||||
overlay.innerHTML =
|
||||
'<div class="cc-sheet cc-modal">' +
|
||||
'<div class="cc-sheet-header">' +
|
||||
'<div class="cc-sheet-copy"><div class="cc-sheet-title">' + esc(title) + '</div><div class="cc-sheet-subtitle">' + esc(subtitle || '') + '</div></div>' +
|
||||
'<button class="icon-btn cc-sheet-close" data-cc-action="settings-close" title="Close">' + icon('x', 16) + '</button>' +
|
||||
'</div>' +
|
||||
'<div class="cc-sheet-body">' + sections.join('') + '</div>' +
|
||||
(footer ? '<div class="cc-sheet-footer">' + footer + '</div>' : '') +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
CC.renderSection = function (eyebrow, title, body) {
|
||||
return '<section class="cc-section">' +
|
||||
'<div class="cc-section-head">' +
|
||||
'<div class="lbl">' + esc(eyebrow) + '</div>' +
|
||||
'<div class="cc-section-title">' + esc(title) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="cc-section-body">' + body + '</div>' +
|
||||
'</section>';
|
||||
};
|
||||
|
||||
CC.renderRadioOption = function (name, value, checked, title, description) {
|
||||
return '<label class="cc-choice">' +
|
||||
'<input type="radio" name="' + esc(name) + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + '>' +
|
||||
'<span><b>' + esc(title) + '</b><br>' + esc(description) + '</span>' +
|
||||
'</label>';
|
||||
};
|
||||
|
||||
CC.openSheet = function (sheet, options) {
|
||||
options = options || {};
|
||||
if (sheet === 'desktop-settings') {
|
||||
CC.renderDesktopSettings();
|
||||
} else {
|
||||
CC.renderLocalSettings();
|
||||
}
|
||||
CC.ui.openSheet = sheet;
|
||||
overlay.classList.add('open');
|
||||
if (typeof options.scrollTop === 'number') {
|
||||
var body = overlay.querySelector('.cc-sheet-body');
|
||||
if (body) body.scrollTop = options.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
CC.closeSheet = function () {
|
||||
if (CC.modalMode && bridge.closeSettingsWindow) {
|
||||
CC.ui.openSheet = null;
|
||||
return bridge.closeSettingsWindow();
|
||||
}
|
||||
CC.ui.openSheet = null;
|
||||
overlay.classList.remove('open');
|
||||
};
|
||||
|
||||
CC.buildLocalServerSection = function (state, options) {
|
||||
options = options || {};
|
||||
var settings = state.desktopSettings || {};
|
||||
var url = localUrl(state) || 'starts on demand';
|
||||
var body = '<div class="cc-surface">' +
|
||||
'<div class="cc-meta mono">' + esc(url) + '</div>' +
|
||||
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>';
|
||||
if (options.includePreferences) {
|
||||
body +=
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>';
|
||||
}
|
||||
body += '</div>';
|
||||
return CC.renderSection(
|
||||
options.eyebrow || 'LOCAL SERVER',
|
||||
options.title || 'Run Local CloudCLI on this machine',
|
||||
body
|
||||
);
|
||||
};
|
||||
|
||||
CC.buildThemeSection = function (state) {
|
||||
var settings = state.desktopSettings || {};
|
||||
return CC.renderSection('APPEARANCE', 'Desktop theme', '' +
|
||||
'<div class="cc-surface cc-choice-group">' +
|
||||
CC.renderRadioOption('desktop-theme', 'system', settings.themeMode === 'system', 'System', 'Follow the operating system appearance.') +
|
||||
CC.renderRadioOption('desktop-theme', 'light', settings.themeMode === 'light', 'Light', 'Use the light interface appearance.') +
|
||||
CC.renderRadioOption('desktop-theme', 'dark', settings.themeMode === 'dark', 'Dark', 'Use the dark interface appearance.') +
|
||||
'</div>'
|
||||
);
|
||||
};
|
||||
|
||||
CC.renderLocalSettings = function () {
|
||||
var state = CC.state || {};
|
||||
var sections = [
|
||||
CC.buildLocalServerSection(state, { includePreferences: false }),
|
||||
CC.renderSection('PREFERENCES', 'How the local service behaves', '' +
|
||||
'<div class="cc-surface">' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + ((state.desktopSettings || {}).keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
|
||||
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + ((state.desktopSettings || {}).exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
|
||||
'</div>'
|
||||
),
|
||||
];
|
||||
CC.renderSheet('Local Settings', 'Manage how Local CloudCLI runs on this computer.', sections);
|
||||
};
|
||||
|
||||
CC.renderDesktopSettings = function () {
|
||||
var sections = [
|
||||
CC.buildThemeSection(CC.state || {}),
|
||||
];
|
||||
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
|
||||
};
|
||||
|
||||
CC.render = function (state) {
|
||||
state = state || CC.state;
|
||||
var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
|
||||
var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
|
||||
var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
|
||||
if (CC.modalMode) {
|
||||
app.innerHTML = '';
|
||||
} else {
|
||||
app.innerHTML = titlebar + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + statusbar;
|
||||
}
|
||||
if (CC._reg.afterRender) CC._reg.afterRender(state);
|
||||
};
|
||||
|
||||
function wireEvents() {
|
||||
if (CC._wired) return;
|
||||
CC._wired = true;
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (CC._reg.onClick && CC._reg.onClick(event)) return;
|
||||
var closeTab = event.target.closest('[data-cc-close-tab]');
|
||||
if (closeTab) {
|
||||
event.stopPropagation();
|
||||
CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
|
||||
return;
|
||||
}
|
||||
var tab = event.target.closest('[data-cc-tab]');
|
||||
if (tab) {
|
||||
CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
|
||||
return;
|
||||
}
|
||||
var action = event.target.closest('[data-cc-action]');
|
||||
if (action) {
|
||||
CC.act(action.getAttribute('data-cc-action'), action);
|
||||
return;
|
||||
}
|
||||
var env = event.target.closest('[data-cc-env]');
|
||||
if (env) {
|
||||
CC.openEnv(env.getAttribute('data-cc-env'));
|
||||
return;
|
||||
}
|
||||
if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
|
||||
CC.closeSheet();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var setting = event.target.closest('[data-cc-setting]');
|
||||
if (setting) {
|
||||
CC.act('set-setting', {
|
||||
key: setting.getAttribute('data-cc-setting'),
|
||||
value: setting.checked,
|
||||
});
|
||||
return;
|
||||
}
|
||||
var theme = event.target.closest('[name="desktop-theme"]');
|
||||
if (theme) {
|
||||
CC.act('set-theme-mode', { value: theme.value });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && overlay.classList.contains('open')) {
|
||||
CC.closeSheet();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
|
||||
event.preventDefault();
|
||||
CC.act('settings-toggle');
|
||||
return;
|
||||
}
|
||||
if (overlay.classList.contains('open')) return;
|
||||
if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
app = document.getElementById('app');
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'cc-overlay';
|
||||
overlay.className = 'cc-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
|
||||
var isWin = /Win/i.test(navigator.platform);
|
||||
CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
|
||||
document.body.classList.add(CC.platform);
|
||||
CC.ui.initialSheet = SEARCH.get('sheet') || 'desktop-settings';
|
||||
if (CC.modalMode) {
|
||||
document.documentElement.classList.add('cc-modal-window');
|
||||
document.body.classList.add('cc-modal-window');
|
||||
}
|
||||
|
||||
wireEvents();
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
CC.applyTheme(CC.state);
|
||||
});
|
||||
}
|
||||
if (bridge.onStateUpdated) {
|
||||
bridge.onStateUpdated(function (state) { CC.setState(state); });
|
||||
}
|
||||
if (bridge.onLauncherCommand) {
|
||||
bridge.onLauncherCommand(function (command) {
|
||||
if (command && command.type === 'open-sheet') {
|
||||
CC.ui.initialSheet = command.sheet || CC.ui.initialSheet || 'desktop-settings';
|
||||
CC.openSheet(command.sheet);
|
||||
}
|
||||
});
|
||||
}
|
||||
CC.refresh().catch(function (error) {
|
||||
CC._status = { msg: errMsg(error), tone: 'error' };
|
||||
CC.render(CC.state);
|
||||
});
|
||||
}
|
||||
|
||||
CC.register = function (registry) {
|
||||
CC._reg = registry || {};
|
||||
};
|
||||
|
||||
CC.start = function () {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(function sidebarApp() {
|
||||
var CC = window.CC;
|
||||
|
||||
function navItem(id, iconName, label, meta, selected) {
|
||||
return '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
|
||||
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
|
||||
}
|
||||
|
||||
function localPane(state) {
|
||||
return '<div class="pane-h"><div><h2 class="pane-title">Local servers</h2><p class="pane-sub">Manage Local CloudCLI on this machine. No account required.</p></div></div>' +
|
||||
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
|
||||
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>';
|
||||
}
|
||||
|
||||
function envRow(environment) {
|
||||
var meta = CC.statusMeta(environment.status);
|
||||
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
|
||||
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
|
||||
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
|
||||
'<div class="env-tags">' + tags + '</div>' +
|
||||
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
|
||||
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
|
||||
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
|
||||
}
|
||||
|
||||
function cloudPane(state) {
|
||||
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
|
||||
if (CC.authState(state) === 'expired') {
|
||||
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
|
||||
}
|
||||
if (!CC.connected(state)) {
|
||||
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
|
||||
}
|
||||
if (state.cloudLoading && !(state.environments || []).length) {
|
||||
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
|
||||
}
|
||||
|
||||
var list = (state.environments || []).map(envRow).join('');
|
||||
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
|
||||
return header + list;
|
||||
}
|
||||
|
||||
function renderBody(state) {
|
||||
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
|
||||
CC.ui.section = section;
|
||||
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Launcher</div>' +
|
||||
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
|
||||
navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
|
||||
'</div></div>';
|
||||
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var nav = event.target.closest('[data-cc-nav]');
|
||||
if (!nav) return false;
|
||||
CC.ui.section = nav.getAttribute('data-cc-nav');
|
||||
CC.render(CC.state);
|
||||
return true;
|
||||
}
|
||||
|
||||
CC.register({
|
||||
bodyClass: 'v-sidebar',
|
||||
renderBody: renderBody,
|
||||
onClick: onClick,
|
||||
});
|
||||
CC.start();
|
||||
})();
|
||||
550
electron/localServer.js
Normal file
550
electron/localServer.js
Normal file
@@ -0,0 +1,550 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ServerInstaller } from './serverInstaller.js';
|
||||
|
||||
const DEFAULT_PORT = 3001;
|
||||
const HOST = '127.0.0.1';
|
||||
const DISPLAY_HOST = 'localhost';
|
||||
const HEALTH_TIMEOUT_MS = 1000;
|
||||
const SERVER_START_TIMEOUT_MS = 30000;
|
||||
const MAX_STARTUP_LOG_LINES = 300;
|
||||
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
||||
const LOCAL_SERVER_URL_ENV_KEYS = [
|
||||
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
|
||||
'CLOUDCLI_LOCAL_SERVER_URL',
|
||||
'ELECTRON_LOCAL_SERVER_URL',
|
||||
];
|
||||
const LOCAL_SERVER_PORT_ENV_KEYS = [
|
||||
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
|
||||
'CLOUDCLI_SERVER_PORT',
|
||||
'SERVER_PORT',
|
||||
'PORT',
|
||||
];
|
||||
|
||||
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
||||
let body = '';
|
||||
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
json: JSON.parse(body),
|
||||
});
|
||||
} catch {
|
||||
resolve({ ok: false, json: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, json: null });
|
||||
});
|
||||
req.on('error', () => resolve({ ok: false, json: null }));
|
||||
});
|
||||
}
|
||||
|
||||
async function isCloudCliServer(baseUrl) {
|
||||
const response = await requestJson(`${baseUrl}/health`);
|
||||
return response.ok
|
||||
&& response.json?.status === 'ok'
|
||||
&& typeof response.json?.installMode === 'string';
|
||||
}
|
||||
|
||||
function isPortAvailable(port, host = HOST) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', reject);
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : DEFAULT_PORT;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.listen(0, HOST);
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseServerPort(host) {
|
||||
if (await isPortAvailable(DEFAULT_PORT, host)) {
|
||||
return DEFAULT_PORT;
|
||||
}
|
||||
|
||||
return getFreePort();
|
||||
}
|
||||
|
||||
function getDesktopPath() {
|
||||
const currentPath = process.env.PATH || '';
|
||||
const commonPaths = process.platform === 'win32'
|
||||
? []
|
||||
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
||||
|
||||
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
|
||||
}
|
||||
|
||||
function getNodeRuntime(usePackagedElectronRuntime) {
|
||||
if (process.env.ELECTRON_NODE_PATH) {
|
||||
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
|
||||
}
|
||||
|
||||
if (usePackagedElectronRuntime && process.versions.electron) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.npm_node_execpath) {
|
||||
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
|
||||
}
|
||||
|
||||
return { command: 'node', env: {}, label: 'PATH node' };
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function addCandidateUrl(urls, rawUrl) {
|
||||
if (!rawUrl) return;
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl));
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
const normalized = stripTrailingSlash(parsed.toString());
|
||||
if (!urls.includes(normalized)) urls.push(normalized);
|
||||
} catch {
|
||||
// Ignore invalid user-provided discovery values.
|
||||
}
|
||||
}
|
||||
|
||||
function addCandidatePort(urls, rawPort) {
|
||||
const port = Number.parseInt(String(rawPort || ''), 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
|
||||
addCandidateUrl(urls, `http://${HOST}:${port}`);
|
||||
}
|
||||
|
||||
function getPortFromUrl(baseUrl) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.port) return Number.parseInt(parsed.port, 10);
|
||||
return parsed.protocol === 'https:' ? 443 : 80;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayUrl(baseUrl) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.hostname === HOST) {
|
||||
parsed.hostname = DISPLAY_HOST;
|
||||
}
|
||||
return stripTrailingSlash(parsed.toString());
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readServerBundleConfig(appRoot) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(appRoot, 'electron', 'server-bundle-config.json'), 'utf8');
|
||||
const config = JSON.parse(raw);
|
||||
return {
|
||||
releaseTag: typeof config.releaseTag === 'string' && config.releaseTag.trim()
|
||||
? config.releaseTag.trim()
|
||||
: '',
|
||||
};
|
||||
} catch {
|
||||
return { releaseTag: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function getServerCwd(appRoot, serverEntry) {
|
||||
const normalizedEntry = path.resolve(serverEntry);
|
||||
const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js');
|
||||
if (normalizedEntry === bundledEntry) {
|
||||
return appRoot;
|
||||
}
|
||||
|
||||
// Installed server entries are laid out as <root>/dist-server/server/index.js.
|
||||
return path.resolve(path.dirname(normalizedEntry), '..', '..');
|
||||
}
|
||||
|
||||
async function readServerMarkerUrl() {
|
||||
try {
|
||||
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
|
||||
const marker = JSON.parse(raw);
|
||||
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingServerCandidateUrls(defaultUrl) {
|
||||
const urls = [];
|
||||
|
||||
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
|
||||
addCandidateUrl(urls, process.env[key]);
|
||||
}
|
||||
|
||||
addCandidateUrl(urls, await readServerMarkerUrl());
|
||||
|
||||
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
|
||||
addCandidatePort(urls, process.env[key]);
|
||||
}
|
||||
|
||||
addCandidateUrl(urls, defaultUrl);
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function waitForCloudCliServer(baseUrl, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isCloudCliServer(baseUrl)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export class LocalServerController {
|
||||
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
|
||||
this.appRoot = appRoot;
|
||||
this.settingsPath = settingsPath;
|
||||
this.isPackaged = isPackaged;
|
||||
this.appVersion = appVersion;
|
||||
this.onChange = onChange;
|
||||
this.localServerUrl = null;
|
||||
this.localServerPort = null;
|
||||
this.ownedServerProcess = null;
|
||||
this.startupLogs = [];
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: false,
|
||||
exposeLocalServerOnNetwork: false,
|
||||
themeMode: 'system',
|
||||
};
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
return this.desktopSettings;
|
||||
}
|
||||
|
||||
getLocalServerUrl() {
|
||||
return this.localServerUrl;
|
||||
}
|
||||
|
||||
getHealthCheckUrl() {
|
||||
if (!this.localServerPort) return this.localServerUrl;
|
||||
return `http://${HOST}:${this.localServerPort}`;
|
||||
}
|
||||
|
||||
appendStartupLog(line) {
|
||||
const text = String(line || '').trimEnd();
|
||||
if (!text) return;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
this.startupLogs.push(`[${timestamp}] ${text}`);
|
||||
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
|
||||
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
|
||||
}
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
getStartupLogs() {
|
||||
return [...this.startupLogs];
|
||||
}
|
||||
|
||||
getPendingTarget() {
|
||||
return {
|
||||
kind: 'local',
|
||||
name: 'Local CloudCLI',
|
||||
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
|
||||
};
|
||||
}
|
||||
|
||||
getLanAddress() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
return entry.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getShareableWebUrl() {
|
||||
if (!this.localServerUrl || !this.localServerPort) return null;
|
||||
if (this.desktopSettings.exposeLocalServerOnNetwork) {
|
||||
const lanAddress = this.getLanAddress();
|
||||
if (lanAddress) {
|
||||
return `http://${lanAddress}:${this.localServerPort}`;
|
||||
}
|
||||
}
|
||||
return this.getLocalServerUrl();
|
||||
}
|
||||
|
||||
getServerBindHost() {
|
||||
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
|
||||
}
|
||||
|
||||
async loadDesktopSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
||||
const stored = JSON.parse(raw);
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
|
||||
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
|
||||
themeMode: stored.themeMode === 'light' || stored.themeMode === 'dark' ? stored.themeMode : 'system',
|
||||
};
|
||||
} catch {
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: false,
|
||||
exposeLocalServerOnNetwork: false,
|
||||
themeMode: 'system',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async saveDesktopSettings(nextSettings = this.desktopSettings) {
|
||||
this.desktopSettings = {
|
||||
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
|
||||
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
|
||||
themeMode: nextSettings.themeMode === 'light' || nextSettings.themeMode === 'dark' ? nextSettings.themeMode : 'system',
|
||||
};
|
||||
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
||||
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
async updateDesktopSetting(key, value) {
|
||||
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
|
||||
throw new Error(`Unknown desktop setting: ${key}`);
|
||||
}
|
||||
|
||||
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
|
||||
const wasLocalRunning = Boolean(this.localServerUrl);
|
||||
const nextValue = key === 'themeMode' ? value : Boolean(value);
|
||||
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: nextValue });
|
||||
|
||||
return {
|
||||
desktopSettings: this.desktopSettings,
|
||||
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves the local server entry, installing the matching runtime if needed. */
|
||||
async resolveServerEntry() {
|
||||
if (process.env.ELECTRON_SERVER_ENTRY) {
|
||||
return process.env.ELECTRON_SERVER_ENTRY;
|
||||
}
|
||||
|
||||
const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js');
|
||||
if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) {
|
||||
return bundledEntry;
|
||||
}
|
||||
|
||||
if (!this.appVersion) {
|
||||
throw new Error('Cannot install local server: app version is unknown.');
|
||||
}
|
||||
const bundleConfig = await readServerBundleConfig(this.appRoot);
|
||||
const installer = new ServerInstaller({
|
||||
version: this.appVersion,
|
||||
bundleReleaseTag: bundleConfig.releaseTag,
|
||||
onLog: (line) => this.appendStartupLog(line),
|
||||
});
|
||||
return installer.ensureInstalled();
|
||||
}
|
||||
|
||||
startBundledServer(port, serverEntry) {
|
||||
const bindHost = this.getServerBindHost();
|
||||
const runtime = getNodeRuntime(this.isPackaged);
|
||||
const serverCwd = getServerCwd(this.appRoot, serverEntry);
|
||||
|
||||
const command = `${runtime.command} ${serverEntry}`;
|
||||
this.appendStartupLog(`$ ${command}`);
|
||||
this.appendStartupLog(`runtime: ${runtime.label}`);
|
||||
this.appendStartupLog(`cwd: ${serverCwd}`);
|
||||
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
|
||||
|
||||
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
|
||||
cwd: serverCwd,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...runtime.env,
|
||||
HOST: bindHost,
|
||||
SERVER_PORT: String(port),
|
||||
NODE_ENV: 'production',
|
||||
PATH: getDesktopPath(),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.ownedServerProcess.once('error', (error) => {
|
||||
this.appendStartupLog(`failed to start process: ${error.message}`);
|
||||
this.ownedServerProcess = null;
|
||||
});
|
||||
|
||||
this.ownedServerProcess.stdout?.on('data', (chunk) => {
|
||||
for (const line of String(chunk).split(/\r?\n/)) {
|
||||
this.appendStartupLog(line);
|
||||
}
|
||||
});
|
||||
|
||||
this.ownedServerProcess.stderr?.on('data', (chunk) => {
|
||||
for (const line of String(chunk).split(/\r?\n/)) {
|
||||
this.appendStartupLog(`stderr: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.ownedServerProcess.once('exit', (code, signal) => {
|
||||
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
||||
if (this.ownedServerProcess) {
|
||||
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
||||
}
|
||||
this.ownedServerProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
async resolveLocalServerUrl() {
|
||||
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
|
||||
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
|
||||
const devUrl = process.env.ELECTRON_DEV_URL;
|
||||
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
|
||||
|
||||
if (devUrl) {
|
||||
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
|
||||
if (!ready) {
|
||||
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
|
||||
}
|
||||
this.localServerPort = DEFAULT_PORT;
|
||||
return devUrl;
|
||||
}
|
||||
|
||||
if (!forceOwnServer) {
|
||||
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
|
||||
for (const candidateUrl of candidateUrls) {
|
||||
if (await isCloudCliServer(candidateUrl)) {
|
||||
const displayUrl = getDisplayUrl(candidateUrl);
|
||||
this.localServerPort = getPortFromUrl(candidateUrl);
|
||||
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
|
||||
return displayUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serverEntry = await this.resolveServerEntry();
|
||||
|
||||
const port = await chooseServerPort(this.getServerBindHost());
|
||||
const serverUrl = `http://${HOST}:${port}`;
|
||||
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
|
||||
this.localServerPort = port;
|
||||
this.startBundledServer(port, serverEntry);
|
||||
|
||||
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
|
||||
if (!ready) {
|
||||
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
|
||||
await this.shutdownOwnedServer();
|
||||
this.localServerPort = null;
|
||||
throw new Error([
|
||||
`Bundled backend did not become ready at ${displayUrl}.`,
|
||||
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
|
||||
].join('\n\n'));
|
||||
}
|
||||
|
||||
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
|
||||
this.localServerUrl = displayUrl;
|
||||
return displayUrl;
|
||||
}
|
||||
|
||||
async ensureLocalServer() {
|
||||
if (!this.localServerUrl) {
|
||||
this.localServerUrl = await this.resolveLocalServerUrl();
|
||||
}
|
||||
return this.localServerUrl;
|
||||
}
|
||||
|
||||
async getResolvedTarget() {
|
||||
await this.ensureLocalServer();
|
||||
return {
|
||||
kind: 'local',
|
||||
name: 'Local CloudCLI',
|
||||
url: this.localServerUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async loadLocalTarget() {
|
||||
return {
|
||||
pendingTarget: this.getPendingTarget(),
|
||||
target: await this.getResolvedTarget(),
|
||||
};
|
||||
}
|
||||
|
||||
hasOwnedServer() {
|
||||
return Boolean(this.ownedServerProcess);
|
||||
}
|
||||
|
||||
detachOwnedServer() {
|
||||
if (!this.ownedServerProcess) return;
|
||||
this.ownedServerProcess.unref();
|
||||
this.ownedServerProcess = null;
|
||||
}
|
||||
|
||||
async shutdownOwnedServer() {
|
||||
if (!this.ownedServerProcess) return;
|
||||
|
||||
const child = this.ownedServerProcess;
|
||||
this.ownedServerProcess = null;
|
||||
child.kill('SIGTERM');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_PORT, HOST };
|
||||
944
electron/main.js
Normal file
944
electron/main.js
Normal file
@@ -0,0 +1,944 @@
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell } from 'electron';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { CloudController } from './cloud.js';
|
||||
import { DesktopWindowManager } from './desktopWindow.js';
|
||||
import { DesktopNotificationsController } from './desktopNotifications.js';
|
||||
import { LocalServerController } from './localServer.js';
|
||||
import { TabsController } from './tabs.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const APP_NAME = 'CloudCLI';
|
||||
const APP_USER_MODEL_ID = 'ai.cloudcli.desktop';
|
||||
const CALLBACK_PROTOCOL = 'cloudcli';
|
||||
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
|
||||
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
|
||||
const REMOTE_START_TIMEOUT_MS = 30000;
|
||||
const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const tabs = new TabsController();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(APP_USER_MODEL_ID);
|
||||
}
|
||||
|
||||
let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
|
||||
let desktopWindow = null;
|
||||
let localServer = null;
|
||||
let cloud = null;
|
||||
let desktopNotifications = null;
|
||||
let isQuitting = false;
|
||||
let isRefreshingCloud = false;
|
||||
let pendingCloudConnectStartedAt = 0;
|
||||
|
||||
function getAppRoot() {
|
||||
return app.isPackaged ? app.getAppPath() : path.resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
function getLauncherPath() {
|
||||
return path.join(__dirname, 'launcher', 'index.html');
|
||||
}
|
||||
|
||||
function getPreloadPath() {
|
||||
return path.join(__dirname, 'preload.cjs');
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(getAppRoot(), 'electron', 'assets', 'logo-macos.png');
|
||||
}
|
||||
return path.join(getAppRoot(), 'public', 'logo-512.png');
|
||||
}
|
||||
|
||||
function getStorePath() {
|
||||
return path.join(app.getPath('userData'), 'cloud-account.json');
|
||||
}
|
||||
|
||||
function getSettingsPath() {
|
||||
return path.join(app.getPath('userData'), 'desktop-settings.json');
|
||||
}
|
||||
|
||||
function getDesktopNotificationsSettingsPath() {
|
||||
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
|
||||
}
|
||||
|
||||
function getRunningEnvironmentUrls() {
|
||||
return cloud.getEnvironments()
|
||||
.filter((environment) => environment.status === 'running')
|
||||
.map((environment) => cloud.getEnvironmentUrl(environment))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getDisplayTargetName() {
|
||||
return activeTarget?.name || APP_NAME;
|
||||
}
|
||||
|
||||
function getCloudState() {
|
||||
return {
|
||||
account: cloud.getAccount(),
|
||||
environments: cloud.getEnvironments(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalState() {
|
||||
return {
|
||||
desktopSettings: localServer.getSettings(),
|
||||
localServerRunning: Boolean(localServer.getLocalServerUrl()),
|
||||
localWebUrl: localServer.getLocalServerUrl(),
|
||||
shareableWebUrl: localServer.getShareableWebUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEnvironment(environment) {
|
||||
return {
|
||||
id: environment.id,
|
||||
name: environment.name,
|
||||
subdomain: environment.subdomain,
|
||||
access_url: cloud.getEnvironmentUrl(environment),
|
||||
status: environment.status,
|
||||
created_at: environment.created_at,
|
||||
github_url: environment.github_url || null,
|
||||
region: environment.region || null,
|
||||
agent: environment.agent || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getDesktopState() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const localState = getLocalState();
|
||||
const authState = cloud.getAuthState();
|
||||
return {
|
||||
account: {
|
||||
connected: authState === 'connected',
|
||||
email: cloudAccount?.email || null,
|
||||
authState,
|
||||
requiresReconnect: authState === 'expired',
|
||||
},
|
||||
activeTarget,
|
||||
desktopSettings: localState.desktopSettings,
|
||||
localWebUrl: localState.localWebUrl,
|
||||
shareableWebUrl: localState.shareableWebUrl,
|
||||
localServerRunning: localState.localServerRunning,
|
||||
localStartupLogs: localServer.getStartupLogs(),
|
||||
cloudLoading: isRefreshingCloud,
|
||||
tabs: tabs.getSerializableTabs(),
|
||||
activeTabId: tabs.activeTabId,
|
||||
environments: cloud.getEnvironments().map(serializeEnvironment),
|
||||
desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async function openExternalUrl(url) {
|
||||
if (String(url).startsWith(CALLBACK_PROTOCOL + "://")) {
|
||||
await handleDeepLink(url);
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
async function showError(title, error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`${title}: ${message}`);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'error',
|
||||
title,
|
||||
message: title,
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
|
||||
function isExpectedNavigationAbort(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return error?.code === 'ERR_ABORTED' || message.includes('ERR_ABORTED') || message.includes('(-3)');
|
||||
}
|
||||
|
||||
function syncDesktopState() {
|
||||
if (!desktopWindow) return;
|
||||
desktopWindow.buildAppMenu();
|
||||
desktopWindow.emitDesktopState();
|
||||
if (activeTarget?.kind === 'local' && !localServer?.getLocalServerUrl()) {
|
||||
void desktopWindow.showLocalStartupTarget(localServer.getPendingTarget(), localServer.getStartupLogs())
|
||||
.catch((error) => {
|
||||
if (isExpectedNavigationAbort(error)) return;
|
||||
void showError('Could not update local startup log', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveTarget(target) {
|
||||
activeTarget = target;
|
||||
}
|
||||
|
||||
function getEnvironmentTarget(environment) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
id: environment.id,
|
||||
name: environment.name || environment.subdomain,
|
||||
url: cloud.getEnvironmentUrl(environment),
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnvironmentLaunchTarget(environment) {
|
||||
const environmentUrl = cloud.getEnvironmentUrl(environment);
|
||||
return {
|
||||
...getEnvironmentTarget(environment),
|
||||
url: environmentUrl,
|
||||
loadUrl: await cloud.getEnvironmentLaunchUrl(environment),
|
||||
};
|
||||
}
|
||||
|
||||
async function hasCloudWebSession() {
|
||||
const cookies = await session.defaultSession.cookies.get({});
|
||||
return cookies.some((cookie) => {
|
||||
const cookieDomain = String(cookie.domain || '');
|
||||
return cookieDomain.includes('cloudcli.ai')
|
||||
&& /-auth-token(?:\.\d+)?$/.test(cookie.name)
|
||||
&& Boolean(cookie.value);
|
||||
});
|
||||
}
|
||||
|
||||
function isCloudAuthRedirect(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const controlPlane = new URL(CLOUDCLI_CONTROL_PLANE_URL);
|
||||
return parsed.origin === controlPlane.origin
|
||||
&& (parsed.pathname === '/login' || parsed.pathname.startsWith('/auth/'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiagnosticsText() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const localState = getLocalState();
|
||||
return JSON.stringify({
|
||||
app: APP_NAME,
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
appPath: getAppRoot(),
|
||||
userDataPath: app.getPath('userData'),
|
||||
activeTarget,
|
||||
localServerUrl: localState.localWebUrl,
|
||||
localServerPort: localServer.localServerPort,
|
||||
localWebUrl: localState.localWebUrl,
|
||||
shareableWebUrl: localState.shareableWebUrl,
|
||||
desktopSettings: localState.desktopSettings,
|
||||
cloudConnected: Boolean(cloudAccount?.apiKey),
|
||||
cloudEmail: cloudAccount?.email || null,
|
||||
cloudEnvironmentCount: cloud.getEnvironments().length,
|
||||
cloudRunningEnvironmentCount: getRunningEnvironmentUrls().length,
|
||||
cloudAuthState: cloud.getAuthState(),
|
||||
cloudAccountPath: getStorePath(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
async function copyDiagnostics() {
|
||||
clipboard.writeText(getDiagnosticsText());
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Diagnostics copied',
|
||||
message: 'CloudCLI desktop diagnostics were copied to the clipboard.',
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
||||
isRefreshingCloud = true;
|
||||
syncDesktopState();
|
||||
try {
|
||||
return await cloud.refreshCloudEnvironments();
|
||||
} catch (error) {
|
||||
const authState = cloud.getAuthState();
|
||||
if (authState === 'expired') {
|
||||
const expiredError = new Error('Your CloudCLI session expired. Reconnect your account.');
|
||||
if (showErrors) {
|
||||
await showError('CloudCLI login required', expiredError);
|
||||
return [];
|
||||
}
|
||||
throw expiredError;
|
||||
}
|
||||
if (showErrors) {
|
||||
await showError('Could not load CloudCLI environments', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
isRefreshingCloud = false;
|
||||
void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error));
|
||||
syncDesktopState();
|
||||
}
|
||||
}
|
||||
|
||||
async function connectCloudAccount() {
|
||||
const connectUrl = cloud.buildConnectUrl();
|
||||
pendingCloudConnectStartedAt = Date.now();
|
||||
clipboard.writeText(connectUrl);
|
||||
await openExternalUrl(connectUrl);
|
||||
return connectUrl;
|
||||
}
|
||||
|
||||
async function handleDeepLink(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== `${CALLBACK_PROTOCOL}:` || parsed.hostname !== 'auth') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingCloudConnectStartedAt || Date.now() - pendingCloudConnectStartedAt > AUTH_CALLBACK_TTL_MS) {
|
||||
await showError('CloudCLI account connection failed', new Error('No recent CloudCLI account connection was started from this app.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = parsed.searchParams.get('api_key');
|
||||
if (!apiKey) {
|
||||
await showError('CloudCLI account connection failed', new Error('The callback did not include an API key.'));
|
||||
return;
|
||||
}
|
||||
|
||||
await cloud.saveFromCallback({
|
||||
apiKey,
|
||||
email: parsed.searchParams.get('email'),
|
||||
});
|
||||
pendingCloudConnectStartedAt = 0;
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
|
||||
dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'CloudCLI account connected',
|
||||
message: cloud.getAccount()?.email ? `Connected as ${cloud.getAccount().email}.` : 'CloudCLI account connected.',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function copyLocalWebUrl() {
|
||||
await localServer.ensureLocalServer();
|
||||
const shareableUrl = localServer.getShareableWebUrl();
|
||||
const localUrl = localServer.getLocalServerUrl();
|
||||
|
||||
if (!shareableUrl) {
|
||||
throw new Error('Local CloudCLI URL is not available yet.');
|
||||
}
|
||||
|
||||
clipboard.writeText(shareableUrl);
|
||||
const isLanUrl = shareableUrl !== localUrl;
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Web URL copied',
|
||||
message: isLanUrl ? 'LAN web URL copied.' : 'Local web URL copied.',
|
||||
detail: isLanUrl
|
||||
? `${shareableUrl}\n\nUse this URL from another device on the same network.`
|
||||
: `${shareableUrl}\n\nThis URL works on this computer. Enable LAN access before starting Local CloudCLI to copy a phone-accessible URL.`,
|
||||
});
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openLocalWebUi() {
|
||||
await localServer.ensureLocalServer();
|
||||
const url = localServer.getShareableWebUrl() || localServer.getLocalServerUrl();
|
||||
if (!url) {
|
||||
throw new Error('Local CloudCLI URL is not available yet.');
|
||||
}
|
||||
|
||||
await openExternalUrl(url);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function updateDesktopSetting(key, value) {
|
||||
const result = await localServer.updateDesktopSetting(key, value);
|
||||
syncDesktopState();
|
||||
|
||||
if (result.requiresRestartNotice) {
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Restart local server to apply',
|
||||
message: 'LAN access changes apply the next time the local server starts.',
|
||||
detail: 'Quit CloudCLI and stop the local server, then open Local CloudCLI again.',
|
||||
});
|
||||
}
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function showEnvironmentPicker() {
|
||||
let environments = cloud.getEnvironments();
|
||||
let refreshError = null;
|
||||
|
||||
if (cloud.getAccount()?.apiKey) {
|
||||
try {
|
||||
environments = await refreshCloudEnvironments({ showErrors: false });
|
||||
} catch (error) {
|
||||
refreshError = error;
|
||||
console.warn('[Cloud] Could not refresh environments before showing picker:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
const choices = ['Local CloudCLI', ...environments.map((environment) => {
|
||||
const status = environment.status === 'running' ? '' : ` (${environment.status})`;
|
||||
return `${environment.name || environment.subdomain}${status}`;
|
||||
})];
|
||||
|
||||
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||
type: 'question',
|
||||
buttons: [...choices, 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: choices.length,
|
||||
title: 'Switch CloudCLI Environment',
|
||||
message: 'Choose where this desktop window should connect.',
|
||||
detail: refreshError ? `Cloud environments could not be refreshed. Showing cached environments.\n\n${refreshError.message || refreshError}` : undefined,
|
||||
});
|
||||
|
||||
if (response.response === choices.length) return getDesktopState();
|
||||
if (response.response === 0) return openLocalInDesktop();
|
||||
return openEnvironmentInDesktop(environments[response.response - 1]);
|
||||
}
|
||||
|
||||
async function startEnvironment(environment) {
|
||||
await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function stopEnvironment(environment) {
|
||||
await cloud.stopEnvironment(environment);
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInBrowser(environment) {
|
||||
await openExternalUrl(await cloud.getEnvironmentLaunchUrl(environment));
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getProjectFolder(environment) {
|
||||
return String(environment.name || environment.subdomain || 'workspace').replace(/[^a-zA-Z0-9-]/g, '');
|
||||
}
|
||||
|
||||
function getSshTarget(credentials) {
|
||||
if (credentials.ssh_command) {
|
||||
const parts = String(credentials.ssh_command).split(/\s+/);
|
||||
if (parts.length >= 2) return parts[1];
|
||||
}
|
||||
return `${credentials.username}@ssh.cloudcli.ai`;
|
||||
}
|
||||
|
||||
function getSshHost(credentials) {
|
||||
const target = getSshTarget(credentials);
|
||||
const atIndex = target.indexOf('@');
|
||||
return atIndex >= 0 ? target.slice(atIndex + 1) : 'ssh.cloudcli.ai';
|
||||
}
|
||||
|
||||
function getSafeSshUsername(credentials) {
|
||||
const username = String(credentials.username || '');
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||
throw new Error('Cloud environment returned an invalid SSH username.');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function getSafeSshHost(credentials) {
|
||||
const host = getSshHost(credentials);
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
|
||||
throw new Error('Cloud environment returned an invalid SSH host.');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function getEnvironmentCredentials(environment) {
|
||||
const credentials = await cloud.getEnvironmentCredentials(environment);
|
||||
if (credentials.password) {
|
||||
clipboard.writeText(credentials.password);
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function openEnvironmentInIde(environment, ide) {
|
||||
const credentials = await getEnvironmentCredentials(environment);
|
||||
const scheme = ide === 'cursor' ? 'cursor' : 'vscode';
|
||||
const remoteUri = `${scheme}://vscode-remote/ssh-remote+${getSafeSshUsername(credentials)}@${getSafeSshHost(credentials)}/workspace/${getProjectFolder(environment)}?windowId=_blank`;
|
||||
await shell.openExternal(remoteUri);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInSsh(environment) {
|
||||
const credentials = await getEnvironmentCredentials(environment);
|
||||
const remoteCommand = `cd /workspace/${getProjectFolder(environment)} && exec $SHELL -l`;
|
||||
const sshCommand = `ssh -t ${shellQuote(getSshTarget(credentials))} ${shellQuote(remoteCommand)}`;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const escaped = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
spawn('osascript', ['-e', `tell application "Terminal" to do script "${escaped}"`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}).unref();
|
||||
} else {
|
||||
clipboard.writeText(sshCommand);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'SSH command copied',
|
||||
message: 'The SSH command was copied to the clipboard.',
|
||||
detail: sshCommand,
|
||||
});
|
||||
}
|
||||
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function copyEnvironmentMobileUrl(environment) {
|
||||
const url = cloud.getEnvironmentUrl(environment);
|
||||
clipboard.writeText(url);
|
||||
await dialog.showMessageBox(desktopWindow?.getMainWindow() || undefined, {
|
||||
type: 'info',
|
||||
title: 'Environment URL copied',
|
||||
message: 'Use this URL from your mobile browser.',
|
||||
detail: url,
|
||||
});
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openCloudDashboard() {
|
||||
await openExternalUrl(CLOUDCLI_CONTROL_PLANE_URL);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getActiveRemoteEnvironment() {
|
||||
if (activeTarget?.kind !== 'remote') return null;
|
||||
return cloud.findEnvironment(activeTarget.id);
|
||||
}
|
||||
|
||||
async function runActiveEnvironmentAction(action) {
|
||||
const environment = getActiveRemoteEnvironment();
|
||||
if (!environment) {
|
||||
throw new Error('Open a cloud environment first.');
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'web':
|
||||
return openEnvironmentInBrowser(environment);
|
||||
case 'vscode':
|
||||
return openEnvironmentInIde(environment, 'vscode');
|
||||
case 'cursor':
|
||||
return openEnvironmentInIde(environment, 'cursor');
|
||||
case 'ssh':
|
||||
return openEnvironmentInSsh(environment);
|
||||
case 'mobile':
|
||||
return copyEnvironmentMobileUrl(environment);
|
||||
default:
|
||||
throw new Error(`Unknown environment action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openLocalInDesktop() {
|
||||
const existingTab = tabs.getTab('local');
|
||||
if (existingTab && localServer.getLocalServerUrl()) {
|
||||
await desktopWindow.showTarget(await localServer.getResolvedTarget());
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
const pendingTarget = localServer.getPendingTarget();
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
setActiveTarget(pendingTarget);
|
||||
await desktopWindow.showLocalStartupTarget(pendingTarget, localServer.getStartupLogs());
|
||||
desktopWindow.emitDesktopState();
|
||||
|
||||
const target = await localServer.getResolvedTarget();
|
||||
await desktopWindow.showTarget(target);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function openEnvironmentInDesktop(environment) {
|
||||
const pendingTarget = getEnvironmentTarget(environment);
|
||||
const tabId = tabs.getTabIdForTarget(pendingTarget);
|
||||
const hadTab = Boolean(tabs.getTab(tabId));
|
||||
const previousTabId = tabs.activeTabId;
|
||||
|
||||
if (!hadTab) {
|
||||
await desktopWindow.showTabPlaceholder(
|
||||
pendingTarget,
|
||||
`${environment.status === 'running' ? 'Opening' : 'Starting'} ${pendingTarget.name}...`,
|
||||
);
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
desktopWindow.emitDesktopState();
|
||||
}
|
||||
|
||||
let nextEnvironment = environment;
|
||||
|
||||
if (environment.status !== 'running') {
|
||||
const response = await dialog.showMessageBox(desktopWindow?.getMainWindow(), {
|
||||
type: 'question',
|
||||
buttons: ['Start Environment', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Start environment?',
|
||||
message: `${pendingTarget.name} is ${environment.status}.`,
|
||||
detail: 'CloudCLI can start it before opening the remote app.',
|
||||
});
|
||||
|
||||
if (response.response !== 0) {
|
||||
if (!hadTab) {
|
||||
tabs.remove(tabId);
|
||||
desktopWindow.destroyTabView(tabId);
|
||||
if (previousTabId && previousTabId !== tabId) {
|
||||
await desktopWindow.switchDesktopTab(previousTabId);
|
||||
} else {
|
||||
await desktopWindow.showLauncher();
|
||||
}
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
if (hadTab) {
|
||||
await desktopWindow.showTabPlaceholder(pendingTarget, `Starting ${pendingTarget.name}...`);
|
||||
tabs.upsertTarget(pendingTarget);
|
||||
desktopWindow.emitDesktopState();
|
||||
}
|
||||
|
||||
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
let target = getEnvironmentTarget(nextEnvironment);
|
||||
if (!(await hasCloudWebSession())) {
|
||||
target = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||
}
|
||||
|
||||
const usedBootstrap = Boolean(target.loadUrl);
|
||||
const finalUrl = await desktopWindow.showTarget(target);
|
||||
if (!usedBootstrap && isCloudAuthRedirect(finalUrl)) {
|
||||
const bootstrapTarget = await getEnvironmentLaunchTarget(nextEnvironment);
|
||||
bootstrapTarget.forceLoad = true;
|
||||
await desktopWindow.showTarget(bootstrapTarget);
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function findEnvironmentByUrl(environmentUrl) {
|
||||
const targetOrigin = (() => {
|
||||
try {
|
||||
return new URL(environmentUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!targetOrigin) return null;
|
||||
|
||||
return cloud.getEnvironments().find((environment) => {
|
||||
try {
|
||||
return new URL(cloud.getEnvironmentUrl(environment)).origin === targetOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function openNotificationTarget({ environmentUrl, sessionId = null }) {
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
const environment = findEnvironmentByUrl(environmentUrl);
|
||||
if (environment) {
|
||||
await openEnvironmentInDesktop(environment);
|
||||
} else {
|
||||
const parsed = new URL(environmentUrl);
|
||||
await desktopWindow.showTarget({
|
||||
kind: 'remote',
|
||||
name: parsed.hostname,
|
||||
url: parsed.origin,
|
||||
});
|
||||
}
|
||||
|
||||
const targetUrl = new URL(sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/', environmentUrl).toString();
|
||||
await desktopWindow.navigateActiveView(targetUrl);
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
async function getEnvironmentAuthToken(environmentUrl) {
|
||||
return (await desktopWindow?.readAuthTokenForTarget(environmentUrl)) || null;
|
||||
}
|
||||
|
||||
async function clearCloudAccount() {
|
||||
await cloud.clearCloudAccount();
|
||||
desktopNotifications?.stop();
|
||||
const removedTabs = tabs.removeByKind('remote');
|
||||
for (const tab of removedTabs) {
|
||||
desktopWindow?.destroyTabView(tab.id);
|
||||
}
|
||||
if (activeTarget?.kind === 'remote') {
|
||||
await desktopWindow?.showLauncher();
|
||||
} else {
|
||||
syncDesktopState();
|
||||
}
|
||||
return getDesktopState();
|
||||
}
|
||||
|
||||
function getRemoteEnvironmentMenuItems() {
|
||||
const cloudAccount = cloud.getAccount();
|
||||
const environments = cloud.getEnvironments();
|
||||
|
||||
if (!cloudAccount?.apiKey) {
|
||||
return [{ label: 'Connect CloudCLI Account...', click: () => void connectCloudAccount() }];
|
||||
}
|
||||
|
||||
if (!environments.length) {
|
||||
return [{ label: 'No environments found', enabled: false }];
|
||||
}
|
||||
|
||||
return environments.map((environment) => ({
|
||||
label: `${environment.name || environment.subdomain}${environment.status === 'running' ? '' : ` (${environment.status})`}`,
|
||||
click: () => void openEnvironmentInDesktop(environment)
|
||||
.catch((error) => showError('Could not open environment', error)),
|
||||
}));
|
||||
}
|
||||
|
||||
function registerProtocolHandler() {
|
||||
const appEntry = path.join(getAppRoot(), 'electron', 'main.js');
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL, process.execPath, [appEntry]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(CALLBACK_PROTOCOL);
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpcHandlers() {
|
||||
ipcMain.handle('cloudcli-desktop:connect-cloud', async () => ({
|
||||
...getDesktopState(),
|
||||
connectUrl: await connectCloudAccount(),
|
||||
}));
|
||||
|
||||
ipcMain.handle('cloudcli-desktop:copy-diagnostics', async () => {
|
||||
await copyDiagnostics();
|
||||
return getDesktopState();
|
||||
});
|
||||
|
||||
ipcMain.handle('cloudcli-desktop:copy-local-web-url', async () => copyLocalWebUrl());
|
||||
ipcMain.handle('cloudcli-desktop:get-state', () => getDesktopState());
|
||||
ipcMain.handle('cloudcli-desktop:open-cloud-dashboard', async () => openCloudDashboard());
|
||||
ipcMain.handle('cloudcli-desktop:run-active-environment-action', async (_event, action) => runActiveEnvironmentAction(action));
|
||||
ipcMain.handle('cloudcli-desktop:open-environment', async (_event, environmentId) => {
|
||||
const environment = cloud.findEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error('Environment not found. Refresh and try again.');
|
||||
}
|
||||
return openEnvironmentInDesktop(environment);
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:open-local', async () => openLocalInDesktop());
|
||||
ipcMain.handle('cloudcli-desktop:open-local-web-ui', async () => openLocalWebUi());
|
||||
ipcMain.handle('cloudcli-desktop:refresh-environments', async () => {
|
||||
await refreshCloudEnvironments({ showErrors: true });
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:disconnect-cloud', async () => clearCloudAccount());
|
||||
ipcMain.handle('cloudcli-desktop:reload-active-tab', async () => desktopWindow.reloadActiveTab());
|
||||
ipcMain.handle('cloudcli-desktop:show-environment-picker', async () => showEnvironmentPicker());
|
||||
ipcMain.handle('cloudcli-desktop:show-launcher', async () => {
|
||||
await desktopWindow.showLauncher();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => {
|
||||
await desktopNotifications?.saveSettings(settings);
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
||||
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
||||
ipcMain.handle('cloudcli-desktop:close-settings-window', async () => {
|
||||
desktopWindow.closeSettingsWindow();
|
||||
return getDesktopState();
|
||||
});
|
||||
ipcMain.handle('cloudcli-desktop:show-active-environment-actions-menu', async () => desktopWindow.showActiveEnvironmentActionsMenu());
|
||||
ipcMain.handle('cloudcli-desktop:show-environment-actions-menu', async (_event, environmentId) => desktopWindow.showEnvironmentActionsMenu(environmentId));
|
||||
ipcMain.handle('cloudcli-desktop:switch-tab', async (_event, tabId) => desktopWindow.switchDesktopTab(tabId));
|
||||
ipcMain.handle('cloudcli-desktop:close-tab', async (_event, tabId) => desktopWindow.closeDesktopTab(tabId));
|
||||
ipcMain.handle('cloudcli-desktop:update-setting', async (_event, key, value) => updateDesktopSetting(key, value));
|
||||
}
|
||||
|
||||
function registerAppEvents() {
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
void handleDeepLink(url);
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (desktopWindow) {
|
||||
void desktopWindow.createWindow();
|
||||
} else {
|
||||
void createDesktopWindow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
desktopNotifications?.stop();
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (isQuitting || !localServer?.hasOwnedServer()) return;
|
||||
if (localServer.getSettings().keepLocalServerRunning) {
|
||||
localServer.detachOwnedServer();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
isQuitting = true;
|
||||
void localServer.shutdownOwnedServer().finally(() => app.quit());
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDesktopWindow() {
|
||||
desktopWindow = new DesktopWindowManager({
|
||||
appName: APP_NAME,
|
||||
getWindowIconPath,
|
||||
getLauncherPath,
|
||||
getPreloadPath,
|
||||
openExternalUrl,
|
||||
getDesktopState,
|
||||
getDisplayTargetName,
|
||||
getRemoteEnvironmentMenuItems,
|
||||
getCloudState,
|
||||
getLocalState,
|
||||
tabs,
|
||||
actions: {
|
||||
copyDiagnostics,
|
||||
copyText: (text) => clipboard.writeText(text),
|
||||
clearCloudAccount,
|
||||
connectCloudAccount,
|
||||
getActiveTarget: () => activeTarget,
|
||||
getEnvironmentUrl: (environment) => cloud.getEnvironmentUrl(environment),
|
||||
openEnvironmentInBrowser,
|
||||
openEnvironmentInDesktop,
|
||||
openEnvironmentInIde,
|
||||
openEnvironmentInSsh,
|
||||
openLocalInDesktop,
|
||||
openLocalWebUi,
|
||||
openCloudDashboard,
|
||||
refreshCloudEnvironments: () => refreshCloudEnvironments({ showErrors: true }),
|
||||
setActiveTarget,
|
||||
showEnvironmentPicker,
|
||||
showError,
|
||||
startEnvironment,
|
||||
stopEnvironment,
|
||||
updateDesktopSetting,
|
||||
copyLocalWebUrl,
|
||||
openNotificationTarget,
|
||||
},
|
||||
});
|
||||
|
||||
desktopWindow.createTray();
|
||||
desktopWindow.configurePermissions();
|
||||
await desktopWindow.createWindow();
|
||||
}
|
||||
|
||||
function registerSingleInstance() {
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
const deepLink = argv.find((arg) => arg.startsWith(`${CALLBACK_PROTOCOL}://`));
|
||||
if (deepLink) {
|
||||
void handleDeepLink(deepLink);
|
||||
}
|
||||
|
||||
const window = desktopWindow?.getMainWindow();
|
||||
if (window) {
|
||||
if (window.isMinimized()) window.restore();
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
app.name = APP_NAME;
|
||||
app.setName(APP_NAME);
|
||||
process.title = APP_NAME;
|
||||
|
||||
await app.whenReady();
|
||||
app.setName(APP_NAME);
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: APP_NAME,
|
||||
applicationVersion: app.getVersion(),
|
||||
copyright: 'CloudCLI',
|
||||
});
|
||||
|
||||
localServer = new LocalServerController({
|
||||
appRoot: getAppRoot(),
|
||||
settingsPath: getSettingsPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
appVersion: app.getVersion(),
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
cloud = new CloudController({
|
||||
storePath: getStorePath(),
|
||||
controlPlaneUrl: CLOUDCLI_CONTROL_PLANE_URL,
|
||||
callbackUrl: CALLBACK_URL,
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
desktopNotifications = new DesktopNotificationsController({
|
||||
settingsPath: getDesktopNotificationsSettingsPath(),
|
||||
appVersion: app.getVersion(),
|
||||
appName: APP_NAME,
|
||||
getDeviceId: () => cloud.getAccount()?.deviceId || '',
|
||||
getAccountEmail: () => cloud.getAccount()?.email || null,
|
||||
getRunningEnvironmentUrls,
|
||||
getApiKey: () => cloud.getAccount()?.apiKey || '',
|
||||
getAuthToken: getEnvironmentAuthToken,
|
||||
getIconPath: getWindowIconPath,
|
||||
openNotificationTarget,
|
||||
onChange: syncDesktopState,
|
||||
});
|
||||
|
||||
await localServer.loadDesktopSettings();
|
||||
await cloud.loadCloudAccount();
|
||||
await desktopNotifications.loadSettings();
|
||||
|
||||
registerProtocolHandler();
|
||||
registerIpcHandlers();
|
||||
registerAppEvents();
|
||||
await createDesktopWindow();
|
||||
void refreshCloudEnvironments({ showErrors: false });
|
||||
}
|
||||
|
||||
if (registerSingleInstance()) {
|
||||
bootstrap().catch(async (error) => {
|
||||
await showError('CloudCLI failed to start', error);
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
60
electron/preload.cjs
Normal file
60
electron/preload.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
function isCloudCliAppOrigin(location) {
|
||||
if (location.protocol === 'file:') return true;
|
||||
|
||||
if (location.protocol === 'http:') {
|
||||
return location.hostname === '127.0.0.1' || location.hostname === 'localhost';
|
||||
}
|
||||
|
||||
return location.protocol === 'https:' && (
|
||||
location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai')
|
||||
);
|
||||
}
|
||||
|
||||
function onDesktopStateUpdated(callback) {
|
||||
const listener = (_event, state) => callback(state);
|
||||
ipcRenderer.on('cloudcli-desktop:state-updated', listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener);
|
||||
};
|
||||
}
|
||||
|
||||
if (isCloudCliAppOrigin(window.location)) {
|
||||
contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', {
|
||||
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
|
||||
update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings),
|
||||
onStateUpdated: onDesktopStateUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
if (window.location.protocol === 'file:') {
|
||||
contextBridge.exposeInMainWorld('cloudcliDesktop', {
|
||||
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
|
||||
disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-cloud'),
|
||||
copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
|
||||
copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
|
||||
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
|
||||
openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
|
||||
openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
|
||||
runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
|
||||
openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
|
||||
openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
|
||||
refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
|
||||
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
|
||||
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
|
||||
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
|
||||
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
|
||||
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
|
||||
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
|
||||
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
|
||||
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
|
||||
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
|
||||
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
|
||||
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
|
||||
onStateUpdated: onDesktopStateUpdated,
|
||||
onLauncherCommand: (callback) => {
|
||||
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
|
||||
},
|
||||
});
|
||||
}
|
||||
62
electron/scripts/generate-macos-icon.js
Normal file
62
electron/scripts/generate-macos-icon.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const size = 1024;
|
||||
const assetsDir = 'electron/assets';
|
||||
const iconPath = 'electron/assets/logo-macos.png';
|
||||
const icnsPath = 'electron/assets/logo-macos.icns';
|
||||
|
||||
function renderSvg(entrySize) {
|
||||
const scale = entrySize / 32;
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
|
||||
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
|
||||
<path
|
||||
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
|
||||
stroke="white"
|
||||
stroke-width="${2 * scale}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
async function renderPng(entrySize) {
|
||||
return sharp(Buffer.from(renderSvg(entrySize)))
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
await fs.writeFile(iconPath, await renderPng(size));
|
||||
|
||||
const icnsEntries = [
|
||||
['icp4', 16],
|
||||
['icp5', 32],
|
||||
['icp6', 64],
|
||||
['ic07', 128],
|
||||
['ic08', 256],
|
||||
['ic09', 512],
|
||||
['ic10', 1024],
|
||||
['ic11', 32],
|
||||
['ic12', 64],
|
||||
['ic13', 256],
|
||||
['ic14', 512],
|
||||
];
|
||||
|
||||
const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
|
||||
const png = await renderPng(entrySize);
|
||||
const block = Buffer.alloc(8 + png.length);
|
||||
block.write(type, 0, 4, 'ascii');
|
||||
block.writeUInt32BE(block.length, 4);
|
||||
png.copy(block, 8);
|
||||
return block;
|
||||
}));
|
||||
|
||||
const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
|
||||
const header = Buffer.alloc(8);
|
||||
header.write('icns', 0, 4, 'ascii');
|
||||
header.writeUInt32BE(totalLength, 4);
|
||||
|
||||
await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));
|
||||
277
electron/serverInstaller.js
Normal file
277
electron/serverInstaller.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Installs the versioned local server runtime used by CloudCLI Desktop.
|
||||
*
|
||||
* Server bundles are cached under:
|
||||
* ~/.cloudcli/server/<version>/dist-server/server/index.js
|
||||
*/
|
||||
|
||||
const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
|
||||
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
|
||||
const MAX_REDIRECTS = 5;
|
||||
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
|
||||
function mapArch(arch = process.arch) {
|
||||
return arch === 'arm64' ? 'arm64' : 'x64';
|
||||
}
|
||||
|
||||
function mapPlatform(platform = process.platform) {
|
||||
if (platform === 'darwin') return 'mac';
|
||||
if (platform === 'win32') return 'win';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
export class ServerInstaller {
|
||||
constructor({
|
||||
version,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
|
||||
bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
|
||||
bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
|
||||
onLog,
|
||||
} = {}) {
|
||||
if (!version) throw new Error('ServerInstaller requires the app version');
|
||||
this.version = version;
|
||||
this.platform = mapPlatform(platform);
|
||||
this.arch = mapArch(arch);
|
||||
this.installRoot = installRoot;
|
||||
this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
|
||||
this.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
|
||||
this.onLog = typeof onLog === 'function' ? onLog : () => {};
|
||||
}
|
||||
|
||||
/** Directory the current version's server is (or will be) installed in. */
|
||||
getVersionDir() {
|
||||
return path.join(this.installRoot, this.version);
|
||||
}
|
||||
|
||||
/** Absolute path to the server entry once installed. */
|
||||
getServerEntry() {
|
||||
return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
|
||||
}
|
||||
|
||||
getBundleName() {
|
||||
return `cloudcli-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
|
||||
}
|
||||
|
||||
getBundleUrl() {
|
||||
const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${this.getBundleName()}`);
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
|
||||
throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
log(line) {
|
||||
this.onLog(String(line));
|
||||
}
|
||||
|
||||
async isInstalled() {
|
||||
try {
|
||||
const marker = JSON.parse(
|
||||
await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
|
||||
);
|
||||
if (marker.version !== this.version) return false;
|
||||
await fs.access(this.getServerEntry());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the server for this version is installed, downloading + extracting
|
||||
* it if needed. Returns the resolved server entry path.
|
||||
*/
|
||||
async ensureInstalled() {
|
||||
if (await this.isInstalled()) {
|
||||
this.log(`Local server ${this.version} already installed.`);
|
||||
return this.getServerEntry();
|
||||
}
|
||||
|
||||
const versionDir = this.getVersionDir();
|
||||
const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
|
||||
const archivePath = path.join(tmpDir, this.getBundleName());
|
||||
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
const url = this.getBundleUrl();
|
||||
this.log(`Downloading local server bundle…`);
|
||||
this.log(url);
|
||||
await this.#download(url, archivePath);
|
||||
await this.#verifyChecksum(url, archivePath);
|
||||
|
||||
this.log('Extracting local server…');
|
||||
await fs.rm(versionDir, { recursive: true, force: true });
|
||||
await fs.mkdir(versionDir, { recursive: true });
|
||||
await this.#validateArchive(archivePath);
|
||||
await this.#extract(archivePath, versionDir);
|
||||
|
||||
const entry = this.getServerEntry();
|
||||
await fs.access(entry);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(versionDir, '.installed.json'),
|
||||
JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
this.log(`Local server ${this.version} installed.`);
|
||||
return entry;
|
||||
} catch (error) {
|
||||
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
|
||||
throw new Error(`Failed to install local server: ${error.message}`);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
#download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (res) => {
|
||||
const { statusCode, headers } = res;
|
||||
|
||||
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||
res.resume();
|
||||
if (redirectsLeft <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const next = new URL(headers.location, url).toString();
|
||||
resolve(this.#download(next, destPath, redirectsLeft - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`Download failed with HTTP ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const total = Number(headers['content-length']) || 0;
|
||||
let received = 0;
|
||||
let lastPct = -1;
|
||||
const out = createWriteStream(destPath);
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
received += chunk.length;
|
||||
if (total) {
|
||||
const pct = Math.floor((received / total) * 100);
|
||||
if (pct !== lastPct && pct % 10 === 0) {
|
||||
lastPct = pct;
|
||||
this.log(`Downloading… ${pct}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
res.pipe(out);
|
||||
out.on('finish', () => out.close(resolve));
|
||||
out.on('error', reject);
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async #verifyChecksum(url, archivePath) {
|
||||
let expected;
|
||||
try {
|
||||
expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
|
||||
} catch (error) {
|
||||
throw new Error(`Could not verify server bundle checksum: ${error.message}`);
|
||||
}
|
||||
const actual = await this.#sha256(archivePath);
|
||||
if (expected.toLowerCase() !== actual.toLowerCase()) {
|
||||
throw new Error('Checksum mismatch — refusing to install');
|
||||
}
|
||||
this.log('Checksum verified.');
|
||||
}
|
||||
|
||||
#fetchText(url, redirectsLeft = MAX_REDIRECTS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (res) => {
|
||||
const { statusCode, headers } = res;
|
||||
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
||||
res.resume();
|
||||
if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
|
||||
return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
|
||||
}
|
||||
if (statusCode !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`HTTP ${statusCode}`));
|
||||
}
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (c) => (body += c));
|
||||
res.on('end', () => resolve(body));
|
||||
res.on('error', reject);
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
#sha256(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (c) => hash.update(c));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
#extract(archivePath, destDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (c) => (stderr += c));
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#validateArchive(archivePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('tar', ['-tzf', archivePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (c) => { stdout += c; });
|
||||
child.stderr?.on('data', (c) => { stderr += c; });
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
|
||||
return;
|
||||
}
|
||||
for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
|
||||
const normalized = entry.replace(/\\/g, '/');
|
||||
if (
|
||||
path.isAbsolute(normalized)
|
||||
|| /^[a-zA-Z]:\//.test(normalized)
|
||||
|| normalized.split('/').includes('..')
|
||||
) {
|
||||
reject(new Error(`Refusing unsafe archive entry: ${entry}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
87
electron/tabs.js
Normal file
87
electron/tabs.js
Normal file
@@ -0,0 +1,87 @@
|
||||
export class TabsController {
|
||||
constructor() {
|
||||
this.activeTabId = 'home';
|
||||
this.tabs = [
|
||||
{
|
||||
id: 'home',
|
||||
title: 'Launcher',
|
||||
kind: 'launcher',
|
||||
closable: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getTabIdForTarget(target) {
|
||||
if (target.kind === 'launcher') return 'home';
|
||||
if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
|
||||
return target.kind;
|
||||
}
|
||||
|
||||
upsertTarget(target) {
|
||||
const tabId = this.getTabIdForTarget(target);
|
||||
const existingTab = this.tabs.find((tab) => tab.id === tabId);
|
||||
const nextTab = {
|
||||
id: tabId,
|
||||
title: target.kind === 'launcher' ? 'Launcher' : target.name,
|
||||
kind: target.kind,
|
||||
target,
|
||||
closable: tabId !== 'home',
|
||||
};
|
||||
|
||||
if (existingTab) {
|
||||
Object.assign(existingTab, nextTab);
|
||||
} else {
|
||||
this.tabs.push(nextTab);
|
||||
}
|
||||
|
||||
this.activeTabId = tabId;
|
||||
return nextTab;
|
||||
}
|
||||
|
||||
activate(tabId) {
|
||||
const tab = this.tabs.find((item) => item.id === tabId);
|
||||
if (!tab) return null;
|
||||
this.activeTabId = tab.id;
|
||||
return tab;
|
||||
}
|
||||
|
||||
remove(tabId) {
|
||||
const tab = this.tabs.find((item) => item.id === tabId);
|
||||
if (!tab || !tab.closable) return null;
|
||||
this.tabs = this.tabs.filter((item) => item.id !== tabId);
|
||||
if (this.activeTabId === tabId) {
|
||||
this.activeTabId = 'home';
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
removeByKind(kind) {
|
||||
const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable);
|
||||
if (!removed.length) return [];
|
||||
|
||||
const removedIds = new Set(removed.map((tab) => tab.id));
|
||||
this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id));
|
||||
if (removedIds.has(this.activeTabId)) {
|
||||
this.activeTabId = 'home';
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
getActiveTab() {
|
||||
return this.getTab(this.activeTabId);
|
||||
}
|
||||
|
||||
getTab(tabId) {
|
||||
return this.tabs.find((item) => item.id === tabId) || null;
|
||||
}
|
||||
|
||||
getSerializableTabs() {
|
||||
return this.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
title: tab.title,
|
||||
kind: tab.kind,
|
||||
closable: tab.closable,
|
||||
active: tab.id === this.activeTabId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
331
electron/viewHost.js
Normal file
331
electron/viewHost.js
Normal file
@@ -0,0 +1,331 @@
|
||||
import { BrowserView } from 'electron';
|
||||
|
||||
const TARGET_LOAD_TIMEOUT_MS = 20000;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildPlaceholderHtml(title, message, logs = []) {
|
||||
const logHtml = logs.length
|
||||
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
|
||||
: '<pre>Waiting for process output...</pre>';
|
||||
return [
|
||||
'<!doctype html><meta charset="utf-8">',
|
||||
'<style>',
|
||||
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
|
||||
'body{padding:28px;overflow:hidden}',
|
||||
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
|
||||
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
|
||||
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
|
||||
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
|
||||
'</style>',
|
||||
'<div class="shell">',
|
||||
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
|
||||
logHtml,
|
||||
'</div>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function isHttpUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) {
|
||||
let timedOut = false;
|
||||
let timeout = null;
|
||||
const loadPromise = webContents.loadURL(url);
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
webContents.stop();
|
||||
} catch {
|
||||
// Ignore teardown races while reporting the original timeout.
|
||||
}
|
||||
reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([loadPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
loadPromise.catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewHost {
|
||||
constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) {
|
||||
this.appName = appName;
|
||||
this.getMainWindow = getMainWindow;
|
||||
this.getContentViewBounds = getContentViewBounds;
|
||||
this.getPreloadPath = getPreloadPath;
|
||||
this.openExternalUrl = openExternalUrl;
|
||||
this.showError = showError;
|
||||
this.activeContentView = null;
|
||||
this.tabViews = new Map();
|
||||
}
|
||||
|
||||
configureChildWebContents(webContents) {
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error));
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
detachAll() {
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
try {
|
||||
for (const view of mainWindow.getBrowserViews()) {
|
||||
mainWindow.removeBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
// BrowserViews may already be gone during BrowserWindow teardown.
|
||||
}
|
||||
this.activeContentView = null;
|
||||
}
|
||||
|
||||
detachActiveView() {
|
||||
const mainWindow = this.getMainWindow();
|
||||
const view = this.activeContentView;
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !view) return false;
|
||||
try {
|
||||
if (mainWindow.getBrowserViews().includes(view)) {
|
||||
mainWindow.removeBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
this.activeContentView = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
getActiveView() {
|
||||
const view = this.activeContentView;
|
||||
if (!view || view.webContents.isDestroyed()) return null;
|
||||
return view;
|
||||
}
|
||||
|
||||
openActiveViewDevTools() {
|
||||
const view = this.getActiveView();
|
||||
if (!view) return false;
|
||||
view.webContents.openDevTools({ mode: 'detach' });
|
||||
return true;
|
||||
}
|
||||
|
||||
reloadActiveView() {
|
||||
const view = this.getActiveView();
|
||||
if (!view) return false;
|
||||
view.webContents.reloadIgnoringCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
async readLocalStorageValueForOrigin(originUrl, key) {
|
||||
let targetOrigin;
|
||||
try {
|
||||
targetOrigin = new URL(originUrl).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const view of this.tabViews.values()) {
|
||||
if (!view || view.webContents.isDestroyed()) continue;
|
||||
let viewOrigin;
|
||||
try {
|
||||
viewOrigin = new URL(view.webContents.getURL()).origin;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (viewOrigin !== targetOrigin) continue;
|
||||
|
||||
try {
|
||||
const value = await view.webContents.executeJavaScript(
|
||||
`window.localStorage.getItem(${JSON.stringify(key)})`,
|
||||
true
|
||||
);
|
||||
return typeof value === 'string' && value ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTabViewDiagnostics() {
|
||||
const mainWindow = this.getMainWindow();
|
||||
const attachedViews = new Set();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
try {
|
||||
for (const view of mainWindow.getBrowserViews()) {
|
||||
attachedViews.add(view);
|
||||
}
|
||||
} catch {
|
||||
// Ignore teardown races while gathering best-effort diagnostics.
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(this.tabViews.entries()).map(([tabId, view]) => {
|
||||
const { webContents } = view;
|
||||
const destroyed = webContents.isDestroyed();
|
||||
return {
|
||||
tabId,
|
||||
webContentsId: destroyed ? null : webContents.id,
|
||||
url: destroyed ? null : webContents.getURL(),
|
||||
title: destroyed ? null : webContents.getTitle(),
|
||||
osProcessId: destroyed || typeof webContents.getOSProcessId !== 'function' ? null : webContents.getOSProcessId(),
|
||||
processId: destroyed || typeof webContents.getProcessId !== 'function' ? null : webContents.getProcessId(),
|
||||
attached: attachedViews.has(view),
|
||||
active: this.activeContentView === view,
|
||||
destroyed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getOrCreateTabView(tabId) {
|
||||
let view = this.tabViews.get(tabId);
|
||||
if (view) return view;
|
||||
|
||||
view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: this.getPreloadPath(),
|
||||
},
|
||||
});
|
||||
this.configureChildWebContents(view.webContents);
|
||||
this.tabViews.set(tabId, view);
|
||||
return view;
|
||||
}
|
||||
|
||||
attach(view) {
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (this.activeContentView && this.activeContentView !== view) {
|
||||
this.detachAll();
|
||||
}
|
||||
this.activeContentView = view;
|
||||
try {
|
||||
if (!mainWindow.getBrowserViews().includes(view)) {
|
||||
mainWindow.addBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
view.setBounds(this.getContentViewBounds());
|
||||
view.setAutoResize({ width: true, height: true });
|
||||
}
|
||||
|
||||
resizeActiveView() {
|
||||
if (this.activeContentView) {
|
||||
this.activeContentView.setBounds(this.getContentViewBounds());
|
||||
}
|
||||
}
|
||||
|
||||
async showTabPlaceholder(tabId, target, message) {
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
this.attach(view);
|
||||
const html = buildPlaceholderHtml(target.name || this.appName, message);
|
||||
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
view.__cloudcliStartupHtml = html;
|
||||
view.__cloudcliLoadedUrl = null;
|
||||
}
|
||||
|
||||
async showLocalStartupTarget(tabId, target, logs) {
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
if (view.__cloudcliLoadingUrl) return;
|
||||
this.attach(view);
|
||||
const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs);
|
||||
if (view.__cloudcliStartupHtml === html) return;
|
||||
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
view.__cloudcliStartupHtml = html;
|
||||
view.__cloudcliLoadedUrl = null;
|
||||
}
|
||||
|
||||
async showContentTarget(tabId, target) {
|
||||
const loadUrl = target.loadUrl || target.url;
|
||||
if (!isHttpUrl(loadUrl)) {
|
||||
throw new Error(`Refusing to load unsupported app URL: ${loadUrl}`);
|
||||
}
|
||||
const view = this.getOrCreateTabView(tabId);
|
||||
this.attach(view);
|
||||
if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) {
|
||||
view.__cloudcliLoadingUrl = loadUrl;
|
||||
try {
|
||||
await loadUrlWithTimeout(view.webContents, loadUrl);
|
||||
view.__cloudcliLoadedUrl = target.url;
|
||||
view.__cloudcliStartupHtml = null;
|
||||
delete target.loadUrl;
|
||||
delete target.forceLoad;
|
||||
} finally {
|
||||
if (view.__cloudcliLoadingUrl === loadUrl) {
|
||||
view.__cloudcliLoadingUrl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return view.webContents.getURL();
|
||||
}
|
||||
|
||||
reloadTab(tabId) {
|
||||
const view = this.tabViews.get(tabId);
|
||||
if (!view || view.webContents.isDestroyed()) return false;
|
||||
view.webContents.reloadIgnoringCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
async navigateActiveView(url) {
|
||||
const view = this.getActiveView();
|
||||
if (!view) return false;
|
||||
await loadUrlWithTimeout(view.webContents, url);
|
||||
view.__cloudcliLoadedUrl = url;
|
||||
view.__cloudcliStartupHtml = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
destroyTabView(tabId) {
|
||||
const view = this.tabViews.get(tabId);
|
||||
if (!view) return;
|
||||
const mainWindow = this.getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
try {
|
||||
if (mainWindow.getBrowserViews().includes(view)) {
|
||||
mainWindow.removeBrowserView(view);
|
||||
}
|
||||
} catch {
|
||||
// Ignore teardown races; Electron owns final destruction during quit.
|
||||
}
|
||||
}
|
||||
if (this.activeContentView === view) {
|
||||
this.activeContentView = null;
|
||||
}
|
||||
try {
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
view.webContents.destroy();
|
||||
}
|
||||
} catch {
|
||||
// The view may already be destroyed by its parent BrowserWindow.
|
||||
}
|
||||
this.tabViews.delete(tabId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.tabViews.clear();
|
||||
this.activeContentView = null;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<title>CloudCLI UI</title>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
|
||||
4692
package-lock.json
generated
4692
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
84
package.json
84
package.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.0",
|
||||
"productName": "CloudCLI",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -8,10 +9,10 @@
|
||||
"cloudcli": "dist-server/server/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"electron/",
|
||||
"server/",
|
||||
"shared/",
|
||||
"public/api-docs.html",
|
||||
"public/modelConstants.js",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"scripts/",
|
||||
@@ -31,6 +32,14 @@
|
||||
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
||||
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
||||
"client": "vite",
|
||||
"desktop": "electron electron/main.js",
|
||||
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
|
||||
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
|
||||
"desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg",
|
||||
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
|
||||
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
|
||||
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:client": "vite build",
|
||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||
@@ -46,6 +55,66 @@
|
||||
"prepare": "husky",
|
||||
"update:platform": "./update-platform.sh"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.cloudcli.desktop",
|
||||
"productName": "CloudCLI",
|
||||
"asar": false,
|
||||
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release/desktop"
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
},
|
||||
"files": [
|
||||
"electron/",
|
||||
"public/",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"shared/",
|
||||
"server/",
|
||||
"package.json",
|
||||
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
|
||||
],
|
||||
"protocols": [
|
||||
{
|
||||
"name": "CloudCLI",
|
||||
"schemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "electron/assets/logo-macos.icns",
|
||||
"notarize": true,
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"extendInfo": {
|
||||
"CFBundleName": "CloudCLI",
|
||||
"CFBundleDisplayName": "CloudCLI",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLName": "CloudCLI",
|
||||
"CFBundleURLSchemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"icon": "electron/assets/logo-windows.ico",
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"installerIcon": "electron/assets/logo-windows.ico",
|
||||
"uninstallerIcon": "electron/assets/logo-windows.ico"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
"claude-code",
|
||||
@@ -67,7 +136,7 @@
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -78,7 +147,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@openai/codex-sdk": "^0.125.0",
|
||||
"@openai/codex-sdk": "^0.141.0",
|
||||
"@replit/codemirror-minimap": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
@@ -142,6 +211,9 @@
|
||||
"auto-changelog": "^2.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
@@ -168,5 +240,9 @@
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx,js,jsx}": "eslint",
|
||||
"server/**/*.{js,ts}": "eslint"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||
"screenshot-desktop": "^1.15.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +524,7 @@
|
||||
<td><code>provider</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="badge badge-optional">Optional</span></td>
|
||||
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
|
||||
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, <code>opencode</code>, or <code>hermes</code> (default: <code>claude</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>stream</code></td>
|
||||
@@ -820,31 +820,50 @@ data: {"type":"done"}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Import model constants
|
||||
import { PROVIDERS } from './modelConstants.js';
|
||||
|
||||
<script>
|
||||
// Dynamic URL replacement
|
||||
const apiUrl = window.location.origin;
|
||||
document.querySelectorAll('.api-url').forEach(el => {
|
||||
el.textContent = apiUrl;
|
||||
});
|
||||
|
||||
// Dynamically populate model documentation
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (modelCell) {
|
||||
const providerModels = PROVIDERS.map(provider => {
|
||||
const models = provider.models.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
return `<strong>${provider.name}:</strong> ${models} (default: <code>${provider.models.DEFAULT}</code>)`;
|
||||
}).join('<br><br>');
|
||||
// Populate model documentation from the live provider API
|
||||
const PROVIDER_ORDER = [
|
||||
{ id: 'claude', name: 'Anthropic' },
|
||||
{ id: 'codex', name: 'OpenAI' },
|
||||
{ id: 'gemini', name: 'Google' },
|
||||
{ id: 'cursor', name: 'Cursor' },
|
||||
{ id: 'opencode', name: 'OpenCode' },
|
||||
{ id: 'hermes', name: 'Nous Research' },
|
||||
];
|
||||
|
||||
modelCell.innerHTML = `
|
||||
Model identifier for the AI provider:<br><br>
|
||||
${providerModels}
|
||||
`;
|
||||
}
|
||||
});
|
||||
async function populateModels() {
|
||||
const modelCell = document.getElementById('model-options-cell');
|
||||
if (!modelCell) return;
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
PROVIDER_ORDER.map(({ id }) =>
|
||||
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json())
|
||||
)
|
||||
);
|
||||
|
||||
const providerModels = results.map((result, i) => {
|
||||
const { name } = PROVIDER_ORDER[i];
|
||||
if (result.status === 'rejected' || !result.value?.data?.models) {
|
||||
return `<strong>${name}:</strong> <em>unavailable</em>`;
|
||||
}
|
||||
const { OPTIONS, DEFAULT } = result.value.data.models;
|
||||
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
|
||||
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
|
||||
}).join('<br><br>');
|
||||
|
||||
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', populateModels);
|
||||
|
||||
// Tab switching
|
||||
window.showTab = function(tabName) {
|
||||
|
||||
BIN
public/icons/hermes-agent.png
Normal file
BIN
public/icons/hermes-agent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,841 +0,0 @@
|
||||
/**
|
||||
* Documentation Model Definitions
|
||||
* Used by README links and the public API docs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude (Anthropic) Models
|
||||
*/
|
||||
export const CLAUDE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
label: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
label: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "default",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cursor Models
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "auto", label: "auto", description: "Auto" },
|
||||
{
|
||||
value: "composer-2-fast",
|
||||
label: "composer-2-fast",
|
||||
description: "Composer 2 Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2",
|
||||
label: "composer-2",
|
||||
description: "Composer 2",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-low",
|
||||
label: "gpt-5.3-codex-low",
|
||||
description: "Codex 5.3 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-low-fast",
|
||||
label: "gpt-5.3-codex-low-fast",
|
||||
description: "Codex 5.3 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex",
|
||||
label: "gpt-5.3-codex",
|
||||
description: "Codex 5.3",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-fast",
|
||||
label: "gpt-5.3-codex-fast",
|
||||
description: "Codex 5.3 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-high",
|
||||
label: "gpt-5.3-codex-high",
|
||||
description: "Codex 5.3 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-high-fast",
|
||||
label: "gpt-5.3-codex-high-fast",
|
||||
description: "Codex 5.3 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-xhigh",
|
||||
label: "gpt-5.3-codex-xhigh",
|
||||
description: "Codex 5.3 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.3-codex-xhigh-fast",
|
||||
label: "gpt-5.3-codex-xhigh-fast",
|
||||
description: "Codex 5.3 Extra High Fast",
|
||||
},
|
||||
{ value: "gpt-5.2", label: "gpt-5.2", description: "GPT-5.2" },
|
||||
{
|
||||
value: "gpt-5.2-codex-low",
|
||||
label: "gpt-5.2-codex-low",
|
||||
description: "Codex 5.2 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-low-fast",
|
||||
label: "gpt-5.2-codex-low-fast",
|
||||
description: "Codex 5.2 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex",
|
||||
label: "gpt-5.2-codex",
|
||||
description: "Codex 5.2",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-fast",
|
||||
label: "gpt-5.2-codex-fast",
|
||||
description: "Codex 5.2 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-high",
|
||||
label: "gpt-5.2-codex-high",
|
||||
description: "Codex 5.2 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-high-fast",
|
||||
label: "gpt-5.2-codex-high-fast",
|
||||
description: "Codex 5.2 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-xhigh",
|
||||
label: "gpt-5.2-codex-xhigh",
|
||||
description: "Codex 5.2 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-codex-xhigh-fast",
|
||||
label: "gpt-5.2-codex-xhigh-fast",
|
||||
description: "Codex 5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-low",
|
||||
label: "gpt-5.1-codex-max-low",
|
||||
description: "Codex 5.1 Max Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-low-fast",
|
||||
label: "gpt-5.1-codex-max-low-fast",
|
||||
description: "Codex 5.1 Max Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-medium",
|
||||
label: "gpt-5.1-codex-max-medium",
|
||||
description: "Codex 5.1 Max",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-medium-fast",
|
||||
label: "gpt-5.1-codex-max-medium-fast",
|
||||
description: "Codex 5.1 Max Medium Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-high",
|
||||
label: "gpt-5.1-codex-max-high",
|
||||
description: "Codex 5.1 Max High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-high-fast",
|
||||
label: "gpt-5.1-codex-max-high-fast",
|
||||
description: "Codex 5.1 Max High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-xhigh",
|
||||
label: "gpt-5.1-codex-max-xhigh",
|
||||
description: "Codex 5.1 Max Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-max-xhigh-fast",
|
||||
label: "gpt-5.1-codex-max-xhigh-fast",
|
||||
description: "Codex 5.1 Max Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2.5",
|
||||
label: "composer-2.5",
|
||||
description: "Composer 2.5",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-high",
|
||||
label: "gpt-5.5-high",
|
||||
description: "GPT-5.5 1M High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-high-fast",
|
||||
label: "gpt-5.5-high-fast",
|
||||
description: "GPT-5.5 High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-high",
|
||||
label: "claude-opus-4-7-thinking-high",
|
||||
description: "Opus 4.7 1M High Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-high",
|
||||
label: "gpt-5.4-high",
|
||||
description: "GPT-5.4 1M High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-high-fast",
|
||||
label: "gpt-5.4-high-fast",
|
||||
description: "GPT-5.4 High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high-thinking",
|
||||
label: "claude-4.6-opus-high-thinking",
|
||||
description: "Opus 4.6 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high-thinking-fast",
|
||||
label: "claude-4.6-opus-high-thinking-fast",
|
||||
description: "Opus 4.6 1M Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "composer-2.5-fast",
|
||||
label: "composer-2.5-fast",
|
||||
description: "Composer 2.5 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-none",
|
||||
label: "gpt-5.5-none",
|
||||
description: "GPT-5.5 1M None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-none-fast",
|
||||
label: "gpt-5.5-none-fast",
|
||||
description: "GPT-5.5 None Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-low",
|
||||
label: "gpt-5.5-low",
|
||||
description: "GPT-5.5 1M Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-low-fast",
|
||||
label: "gpt-5.5-low-fast",
|
||||
description: "GPT-5.5 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-medium",
|
||||
label: "gpt-5.5-medium",
|
||||
description: "GPT-5.5 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-medium-fast",
|
||||
label: "gpt-5.5-medium-fast",
|
||||
description: "GPT-5.5 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-extra-high",
|
||||
label: "gpt-5.5-extra-high",
|
||||
description: "GPT-5.5 1M Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.5-extra-high-fast",
|
||||
label: "gpt-5.5-extra-high-fast",
|
||||
description: "GPT-5.5 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-sonnet-medium",
|
||||
label: "claude-4.6-sonnet-medium",
|
||||
description: "Sonnet 4.6 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-sonnet-medium-thinking",
|
||||
label: "claude-4.6-sonnet-medium-thinking",
|
||||
description: "Sonnet 4.6 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-low",
|
||||
label: "claude-opus-4-7-low",
|
||||
description: "Opus 4.7 1M Low",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-low-fast",
|
||||
label: "claude-opus-4-7-low-fast",
|
||||
description: "Opus 4.7 1M Low Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-medium",
|
||||
label: "claude-opus-4-7-medium",
|
||||
description: "Opus 4.7 1M Medium",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-medium-fast",
|
||||
label: "claude-opus-4-7-medium-fast",
|
||||
description: "Opus 4.7 1M Medium Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-high",
|
||||
label: "claude-opus-4-7-high",
|
||||
description: "Opus 4.7 1M High",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-high-fast",
|
||||
label: "claude-opus-4-7-high-fast",
|
||||
description: "Opus 4.7 1M High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-xhigh",
|
||||
label: "claude-opus-4-7-xhigh",
|
||||
description: "Opus 4.7 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-xhigh-fast",
|
||||
label: "claude-opus-4-7-xhigh-fast",
|
||||
description: "Opus 4.7 1M Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-max",
|
||||
label: "claude-opus-4-7-max",
|
||||
description: "Opus 4.7 1M Max",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-max-fast",
|
||||
label: "claude-opus-4-7-max-fast",
|
||||
description: "Opus 4.7 1M Max Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-low",
|
||||
label: "claude-opus-4-7-thinking-low",
|
||||
description: "Opus 4.7 1M Low Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-low-fast",
|
||||
label: "claude-opus-4-7-thinking-low-fast",
|
||||
description: "Opus 4.7 1M Low Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-medium",
|
||||
label: "claude-opus-4-7-thinking-medium",
|
||||
description: "Opus 4.7 1M Medium Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-medium-fast",
|
||||
label: "claude-opus-4-7-thinking-medium-fast",
|
||||
description: "Opus 4.7 1M Medium Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-high-fast",
|
||||
label: "claude-opus-4-7-thinking-high-fast",
|
||||
description: "Opus 4.7 1M High Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-xhigh",
|
||||
label: "claude-opus-4-7-thinking-xhigh",
|
||||
description: "Opus 4.7 1M Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-xhigh-fast",
|
||||
label: "claude-opus-4-7-thinking-xhigh-fast",
|
||||
description: "Opus 4.7 1M Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-max",
|
||||
label: "claude-opus-4-7-thinking-max",
|
||||
description: "Opus 4.7 1M Max Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-7-thinking-max-fast",
|
||||
label: "claude-opus-4-7-thinking-max-fast",
|
||||
description: "Opus 4.7 1M Max Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "grok-build-0.1",
|
||||
label: "grok-build-0.1",
|
||||
description: "Grok Build 0.1 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-low",
|
||||
label: "gpt-5.4-low",
|
||||
description: "GPT-5.4 1M Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-medium",
|
||||
label: "gpt-5.4-medium",
|
||||
description: "GPT-5.4 1M",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-medium-fast",
|
||||
label: "gpt-5.4-medium-fast",
|
||||
description: "GPT-5.4 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-xhigh",
|
||||
label: "gpt-5.4-xhigh",
|
||||
description: "GPT-5.4 1M Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-xhigh-fast",
|
||||
label: "gpt-5.4-xhigh-fast",
|
||||
description: "GPT-5.4 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-high",
|
||||
label: "claude-4.6-opus-high",
|
||||
description: "Opus 4.6 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max",
|
||||
label: "claude-4.6-opus-max",
|
||||
description: "Opus 4.6 1M Max",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max-thinking",
|
||||
label: "claude-4.6-opus-max-thinking",
|
||||
description: "Opus 4.6 1M Max Thinking",
|
||||
},
|
||||
{
|
||||
value: "claude-4.6-opus-max-thinking-fast",
|
||||
label: "claude-4.6-opus-max-thinking-fast",
|
||||
description: "Opus 4.6 1M Max Thinking Fast",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-opus-high",
|
||||
label: "claude-4.5-opus-high",
|
||||
description: "Opus 4.5",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-opus-high-thinking",
|
||||
label: "claude-4.5-opus-high-thinking",
|
||||
description: "Opus 4.5 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-low",
|
||||
label: "gpt-5.2-low",
|
||||
description: "GPT-5.2 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-low-fast",
|
||||
label: "gpt-5.2-low-fast",
|
||||
description: "GPT-5.2 Low Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-fast",
|
||||
label: "gpt-5.2-fast",
|
||||
description: "GPT-5.2 Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-high",
|
||||
label: "gpt-5.2-high",
|
||||
description: "GPT-5.2 High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-high-fast",
|
||||
label: "gpt-5.2-high-fast",
|
||||
description: "GPT-5.2 High Fast",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-xhigh",
|
||||
label: "gpt-5.2-xhigh",
|
||||
description: "GPT-5.2 Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-xhigh-fast",
|
||||
label: "gpt-5.2-xhigh-fast",
|
||||
description: "GPT-5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-pro",
|
||||
label: "gemini-3.1-pro",
|
||||
description: "Gemini 3.1 Pro",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-none",
|
||||
label: "gpt-5.4-mini-none",
|
||||
description: "GPT-5.4 Mini None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-low",
|
||||
label: "gpt-5.4-mini-low",
|
||||
description: "GPT-5.4 Mini Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-medium",
|
||||
label: "gpt-5.4-mini-medium",
|
||||
description: "GPT-5.4 Mini",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-high",
|
||||
label: "gpt-5.4-mini-high",
|
||||
description: "GPT-5.4 Mini High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-xhigh",
|
||||
label: "gpt-5.4-mini-xhigh",
|
||||
description: "GPT-5.4 Mini Extra High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-none",
|
||||
label: "gpt-5.4-nano-none",
|
||||
description: "GPT-5.4 Nano None",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-low",
|
||||
label: "gpt-5.4-nano-low",
|
||||
description: "GPT-5.4 Nano Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-medium",
|
||||
label: "gpt-5.4-nano-medium",
|
||||
description: "GPT-5.4 Nano",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-high",
|
||||
label: "gpt-5.4-nano-high",
|
||||
description: "GPT-5.4 Nano High",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-nano-xhigh",
|
||||
label: "gpt-5.4-nano-xhigh",
|
||||
description: "GPT-5.4 Nano Extra High",
|
||||
},
|
||||
{
|
||||
value: "grok-4.3",
|
||||
label: "grok-4.3",
|
||||
description: "Grok 4.3 1M",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-sonnet",
|
||||
label: "claude-4.5-sonnet",
|
||||
description: "Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
value: "claude-4.5-sonnet-thinking",
|
||||
label: "claude-4.5-sonnet-thinking",
|
||||
description: "Sonnet 4.5 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-low",
|
||||
label: "gpt-5.1-low",
|
||||
description: "GPT-5.1 Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1",
|
||||
label: "gpt-5.1",
|
||||
description: "GPT-5.1",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-high",
|
||||
label: "gpt-5.1-high",
|
||||
description: "GPT-5.1 High",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-flash",
|
||||
label: "gemini-3-flash",
|
||||
description: "Gemini 3 Flash",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.5-flash",
|
||||
label: "gemini-3.5-flash",
|
||||
description: "Gemini 3.5 Flash",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-low",
|
||||
label: "gpt-5.1-codex-mini-low",
|
||||
description: "Codex 5.1 Mini Low",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini",
|
||||
label: "gpt-5.1-codex-mini",
|
||||
description: "Codex 5.1 Mini",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-high",
|
||||
label: "gpt-5.1-codex-mini-high",
|
||||
description: "Codex 5.1 Mini High",
|
||||
},
|
||||
{
|
||||
value: "claude-4-sonnet",
|
||||
label: "claude-4-sonnet",
|
||||
description: "Sonnet 4",
|
||||
},
|
||||
{
|
||||
value: "claude-4-sonnet-thinking",
|
||||
label: "claude-4-sonnet-thinking",
|
||||
description: "Sonnet 4 Thinking",
|
||||
},
|
||||
{
|
||||
value: "gpt-5-mini",
|
||||
label: "gpt-5-mini",
|
||||
description: "GPT-5 Mini",
|
||||
},
|
||||
{
|
||||
value: "kimi-k2.5",
|
||||
label: "kimi-k2.5",
|
||||
description: "Kimi K2.5",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "composer-2.5-fast",
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Models
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gpt-5.5", label: "gpt-5.5" },
|
||||
{ value: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ value: "gpt-5.4-mini", label: "gpt-5.4-mini" },
|
||||
{ value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
|
||||
{ value: "gpt-5.2", label: "gpt-5.2" },
|
||||
],
|
||||
|
||||
DEFAULT: "gpt-5.4",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini Models
|
||||
*/
|
||||
export const GEMINI_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||
{
|
||||
value: "gemini-2.0-flash-thinking-exp",
|
||||
label: "Gemini 2.0 Flash Thinking",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "gemini-3.1-pro-preview",
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenCode Models
|
||||
*
|
||||
* OpenCode model ids include the upstream provider prefix.
|
||||
*/
|
||||
export const OPENCODE_MODELS = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: "opencode/big-pickle",
|
||||
label: "Big Pickle",
|
||||
description: "opencode - opencode/big-pickle",
|
||||
},
|
||||
{
|
||||
value: "opencode/deepseek-v4-flash-free",
|
||||
label: "Deepseek V4 Flash Free",
|
||||
description: "opencode - opencode/deepseek-v4-flash-free",
|
||||
},
|
||||
{
|
||||
value: "opencode/nemotron-3-super-free",
|
||||
label: "Nemotron 3 Super Free",
|
||||
description: "opencode - opencode/nemotron-3-super-free",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-20241022",
|
||||
label: "Claude 3.5 Haiku (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-haiku-latest",
|
||||
label: "Claude 3.5 Haiku Latest",
|
||||
description: "anthropic - anthropic/claude-3-5-haiku-latest",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20240620",
|
||||
label: "Claude 3.5 Sonnet (2024-06-20)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20240620",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-5-sonnet-20241022",
|
||||
label: "Claude 3.5 Sonnet (2024-10-22)",
|
||||
description: "anthropic - anthropic/claude-3-5-sonnet-20241022",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-7-sonnet-20250219",
|
||||
label: "Claude 3.7 Sonnet (2025-02-19)",
|
||||
description: "anthropic - anthropic/claude-3-7-sonnet-20250219",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-haiku-20240307",
|
||||
label: "Claude 3 Haiku (2024-03-07)",
|
||||
description: "anthropic - anthropic/claude-3-haiku-20240307",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-opus-20240229",
|
||||
label: "Claude 3 Opus (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-opus-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-3-sonnet-20240229",
|
||||
label: "Claude 3 Sonnet (2024-02-29)",
|
||||
description: "anthropic - anthropic/claude-3-sonnet-20240229",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5",
|
||||
label: "Claude Haiku 4.5",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-haiku-4-5-20251001",
|
||||
label: "Claude Haiku 4.5 (2025-10-01)",
|
||||
description: "anthropic - anthropic/claude-haiku-4-5-20251001",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-0",
|
||||
label: "Claude Opus 4.0",
|
||||
description: "anthropic - anthropic/claude-opus-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1",
|
||||
label: "Claude Opus 4.1",
|
||||
description: "anthropic - anthropic/claude-opus-4-1",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-1-20250805",
|
||||
label: "Claude Opus 4.1 (2025-08-05)",
|
||||
description: "anthropic - anthropic/claude-opus-4-1-20250805",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-20250514",
|
||||
label: "Claude Opus 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-opus-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5",
|
||||
label: "Claude Opus 4.5",
|
||||
description: "anthropic - anthropic/claude-opus-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-5-20251101",
|
||||
label: "Claude Opus 4.5 (2025-11-01)",
|
||||
description: "anthropic - anthropic/claude-opus-4-5-20251101",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6",
|
||||
label: "Claude Opus 4.6",
|
||||
description: "anthropic - anthropic/claude-opus-4-6",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-6-fast",
|
||||
label: "Claude Opus 4.6 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-6-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7",
|
||||
label: "Claude Opus 4.7",
|
||||
description: "anthropic - anthropic/claude-opus-4-7",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-opus-4-7-fast",
|
||||
label: "Claude Opus 4.7 Fast",
|
||||
description: "anthropic - anthropic/claude-opus-4-7-fast",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-0",
|
||||
label: "Claude Sonnet 4.0",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-0",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-20250514",
|
||||
label: "Claude Sonnet 4 (2025-05-14)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-20250514",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5",
|
||||
label: "Claude Sonnet 4.5",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5-20250929",
|
||||
label: "Claude Sonnet 4.5 (2025-09-29)",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-5-20250929",
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-6",
|
||||
label: "Claude Sonnet 4.6",
|
||||
description: "anthropic - anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.2",
|
||||
label: "GPT-5.2",
|
||||
description: "openai - openai/gpt-5.2",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex",
|
||||
label: "GPT-5.3 Codex",
|
||||
description: "openai - openai/gpt-5.3-codex",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.3-codex-spark",
|
||||
label: "GPT-5.3 Codex Spark",
|
||||
description: "openai - openai/gpt-5.3-codex-spark",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
description: "openai - openai/gpt-5.4",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-fast",
|
||||
label: "GPT-5.4 Fast",
|
||||
description: "openai - openai/gpt-5.4-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini",
|
||||
label: "GPT-5.4 Mini",
|
||||
description: "openai - openai/gpt-5.4-mini",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.4-mini-fast",
|
||||
label: "GPT-5.4 Mini Fast",
|
||||
description: "openai - openai/gpt-5.4-mini-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5",
|
||||
label: "GPT-5.5",
|
||||
description: "openai - openai/gpt-5.5",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-fast",
|
||||
label: "GPT-5.5 Fast",
|
||||
description: "openai - openai/gpt-5.5-fast",
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-5.5-pro",
|
||||
label: "GPT-5.5 Pro",
|
||||
description: "openai - openai/gpt-5.5-pro",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: "anthropic/claude-sonnet-4-5",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordered provider registry. Display order in documentation.
|
||||
*/
|
||||
export const PROVIDERS = [
|
||||
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
|
||||
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
|
||||
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
|
||||
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
|
||||
{ id: "opencode", name: "OpenCode", models: OPENCODE_MODELS },
|
||||
];
|
||||
@@ -75,7 +75,7 @@
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`public/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/public/modelConstants.js) for the full list of supported models)
|
||||
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
176
scripts/release/build-server-bundle.js
Normal file
176
scripts/release/build-server-bundle.js
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from 'node:crypto';
|
||||
import { createReadStream, readFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
function getElectronVersion() {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
|
||||
).version;
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
|
||||
).packages['node_modules/electron'].version;
|
||||
} catch {
|
||||
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapArch(arch = process.arch) {
|
||||
return arch === 'arm64' ? 'arm64' : 'x64';
|
||||
}
|
||||
|
||||
function mapPlatform(platform = process.platform) {
|
||||
if (platform === 'darwin') return 'mac';
|
||||
if (platform === 'win32') return 'win';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
...options,
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRequired(stageDir, relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
if (!(await pathExists(from))) {
|
||||
throw new Error(`Required server bundle input is missing: ${relativePath}`);
|
||||
}
|
||||
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function copyIfExists(stageDir, relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
if (!(await pathExists(from))) return;
|
||||
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function writeServerPackageJson(stageDir) {
|
||||
const stagedPackageJson = {
|
||||
...packageJson,
|
||||
scripts: {
|
||||
...(packageJson.scripts || {}),
|
||||
},
|
||||
};
|
||||
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
|
||||
// scripts such as Husky prepare must not run there. Dependency install scripts
|
||||
// still run; native modules need them before the Electron ABI rebuild below.
|
||||
delete stagedPackageJson.scripts.postinstall;
|
||||
delete stagedPackageJson.scripts.prepare;
|
||||
delete stagedPackageJson.scripts.prepublishOnly;
|
||||
await fs.writeFile(
|
||||
path.join(stageDir, 'package.json'),
|
||||
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function sha256(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
|
||||
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
|
||||
const version = packageJson.version;
|
||||
const bundleName = `cloudcli-local-server-${version}-${platform}-${arch}.tar.gz`;
|
||||
const bundleRoot = path.join(rootDir, 'release', 'local-server');
|
||||
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
|
||||
const archivePath = path.join(bundleRoot, bundleName);
|
||||
|
||||
await fs.rm(stageDir, { recursive: true, force: true });
|
||||
await fs.mkdir(stageDir, { recursive: true });
|
||||
await fs.mkdir(bundleRoot, { recursive: true });
|
||||
|
||||
await copyRequired(stageDir, 'dist');
|
||||
await copyRequired(stageDir, 'dist-server');
|
||||
await copyRequired(stageDir, 'public');
|
||||
await copyRequired(stageDir, 'shared');
|
||||
await copyRequired(stageDir, 'package-lock.json');
|
||||
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
|
||||
await writeServerPackageJson(stageDir);
|
||||
|
||||
console.log('Installing production server dependencies into bundle stage...');
|
||||
await run('npm', ['ci', '--omit=dev'], {
|
||||
cwd: stageDir,
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
const electronVersion = getElectronVersion();
|
||||
const electronRebuild = process.platform === 'win32'
|
||||
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
|
||||
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
|
||||
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
|
||||
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
|
||||
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(stageDir, '.installed.json'),
|
||||
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.rm(archivePath, { force: true });
|
||||
const tarArgs = process.platform === 'win32'
|
||||
? ['-czf', archivePath, '-C', stageDir, '.']
|
||||
: ['-czf', archivePath, '-C', stageDir, '.'];
|
||||
await run('tar', tarArgs);
|
||||
|
||||
const digest = await sha256(archivePath);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
|
||||
await fs.rm(stageDir, { recursive: true, force: true });
|
||||
|
||||
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
|
||||
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
|
||||
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);
|
||||
152
scripts/release/prepare-desktop-app.js
Normal file
152
scripts/release/prepare-desktop-app.js
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
function getElectronVersion() {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
|
||||
).version;
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
|
||||
).packages['node_modules/electron'].version;
|
||||
} catch {
|
||||
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRequired(relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
const to = path.join(stageDir, relativePath);
|
||||
if (!(await pathExists(from))) {
|
||||
throw new Error(`Required desktop build input is missing: ${relativePath}`);
|
||||
}
|
||||
await fs.cp(from, to, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyIfExists(relativePath) {
|
||||
const from = path.join(rootDir, relativePath);
|
||||
if (!(await pathExists(from))) return false;
|
||||
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyNodeModule(packageName) {
|
||||
const parts = packageName.split('/');
|
||||
const source = path.join(rootDir, 'node_modules', ...parts);
|
||||
if (!(await pathExists(source))) return false;
|
||||
|
||||
const target = path.join(stageDir, 'node_modules', ...parts);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.cp(source, target, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDesktopPackageJson(copiedOptionalDependencies) {
|
||||
return {
|
||||
name: `${packageJson.name}-desktop`,
|
||||
version: packageJson.version,
|
||||
productName: packageJson.productName,
|
||||
description: `${packageJson.productName} desktop shell`,
|
||||
author: packageJson.author,
|
||||
license: packageJson.license,
|
||||
type: 'module',
|
||||
main: 'electron/main.js',
|
||||
dependencies: {
|
||||
ws: packageJson.dependencies.ws,
|
||||
},
|
||||
optionalDependencies: copiedOptionalDependencies,
|
||||
build: {
|
||||
appId: packageJson.build.appId,
|
||||
productName: packageJson.build.productName,
|
||||
asar: packageJson.build.asar,
|
||||
artifactName: packageJson.build.artifactName,
|
||||
electronVersion: getElectronVersion(),
|
||||
directories: {
|
||||
output: '../../release/desktop',
|
||||
},
|
||||
extraMetadata: {
|
||||
main: 'electron/main.js',
|
||||
},
|
||||
files: [
|
||||
'electron/**',
|
||||
'public/**',
|
||||
'dist/**',
|
||||
'dist-server/**',
|
||||
'node_modules/**',
|
||||
'package.json',
|
||||
],
|
||||
protocols: packageJson.build.protocols,
|
||||
mac: packageJson.build.mac,
|
||||
win: packageJson.build.win,
|
||||
nsis: packageJson.build.nsis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await fs.rm(stageDir, { recursive: true, force: true });
|
||||
await fs.mkdir(stageDir, { recursive: true });
|
||||
|
||||
await copyRequired('electron');
|
||||
await copyRequired('dist');
|
||||
await copyRequired('public');
|
||||
|
||||
const copiedRuntimeDependencies = [];
|
||||
if (await copyNodeModule('ws')) {
|
||||
copiedRuntimeDependencies.push('ws');
|
||||
} else {
|
||||
throw new Error('Required desktop dependency is missing from node_modules: ws');
|
||||
}
|
||||
|
||||
const copiedOptionalDependencies = {};
|
||||
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
|
||||
if (await copyNodeModule(name)) {
|
||||
copiedOptionalDependencies[name] = version;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of [
|
||||
'@nut-tree-fork/default-clipboard-provider',
|
||||
'@nut-tree-fork/libnut',
|
||||
'@nut-tree-fork/provider-interfaces',
|
||||
'@nut-tree-fork/shared',
|
||||
'jimp',
|
||||
'node-abort-controller',
|
||||
'temp',
|
||||
]) {
|
||||
await copyNodeModule(name);
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(stageDir, 'package.json'),
|
||||
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
|
||||
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
|
||||
if (Object.keys(copiedOptionalDependencies).length) {
|
||||
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
|
||||
}
|
||||
384
server/browser-use-mcp.ts
Normal file
384
server/browser-use-mcp.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env node
|
||||
import './load-env.js';
|
||||
|
||||
type JsonRpcRequest = {
|
||||
jsonrpc: '2.0';
|
||||
id?: string | number | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const textResponse = (text: string) => ({
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
|
||||
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
|
||||
|
||||
const readString = (value: unknown, name: string): string => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const readOptionalString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
|
||||
const readNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
|
||||
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
|
||||
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
|
||||
|
||||
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
|
||||
if (!apiToken) {
|
||||
throw new Error('CLOUDCLI_BROWSER_USE_MCP_TOKEN is not configured.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
||||
});
|
||||
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `Browser API request failed (${response.status})`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
const sessionIdSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session id.' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
};
|
||||
|
||||
const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'browser_create_session',
|
||||
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_list_sessions',
|
||||
description: 'List Browser sessions currently available to agents.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'browser_snapshot',
|
||||
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
{
|
||||
name: 'browser_take_screenshot',
|
||||
description: 'Capture the latest screenshot for a Browser session.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
{
|
||||
name: 'browser_navigate',
|
||||
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId', 'url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_click',
|
||||
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
selector: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_type',
|
||||
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
selector: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
submit: { type: 'boolean' },
|
||||
},
|
||||
required: ['sessionId', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_fill_form',
|
||||
description: 'Fill multiple form fields using CSS selectors.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selector: { type: 'string' },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['selector', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['sessionId', 'fields'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_press_key',
|
||||
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId', 'key'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_select_option',
|
||||
description: 'Select option values in a select element found by CSS selector.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
selector: { type: 'string' },
|
||||
values: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['sessionId', 'selector', 'values'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_wait_for',
|
||||
description: 'Wait for visible text, a URL pattern, or a short timeout.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
timeoutMs: { type: 'number' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_tabs',
|
||||
description: 'List, open, select, or close tabs in a Browser session.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string' },
|
||||
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
|
||||
index: { type: 'number' },
|
||||
url: { type: 'string' },
|
||||
},
|
||||
required: ['sessionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser_close_session',
|
||||
description: 'Stop a Browser session controlled by agents.',
|
||||
inputSchema: sessionIdSchema,
|
||||
},
|
||||
];
|
||||
|
||||
async function callTool(name: string, args: Record<string, unknown>) {
|
||||
switch (name) {
|
||||
case 'browser_create_session':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
profileName: readOptionalString(args.profileName),
|
||||
}));
|
||||
case 'browser_list_sessions':
|
||||
return jsonResponse(await callBrowserUseApi(name, {}));
|
||||
case 'browser_snapshot':
|
||||
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||
case 'browser_take_screenshot': {
|
||||
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||
}
|
||||
case 'browser_navigate':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
url: readString(args.url, 'url'),
|
||||
}));
|
||||
case 'browser_click':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
selector: readOptionalString(args.selector),
|
||||
text: readOptionalString(args.text),
|
||||
x: readNumber(args.x),
|
||||
y: readNumber(args.y),
|
||||
}));
|
||||
case 'browser_type':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
selector: readOptionalString(args.selector),
|
||||
text: readString(args.text, 'text'),
|
||||
submit: args.submit === true,
|
||||
}));
|
||||
case 'browser_fill_form': {
|
||||
const fields = Array.isArray(args.fields)
|
||||
? args.fields.map((field) => {
|
||||
const record = field as Record<string, unknown>;
|
||||
return {
|
||||
selector: readString(record.selector, 'field.selector'),
|
||||
value: readString(record.value, 'field.value'),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
fields,
|
||||
}));
|
||||
}
|
||||
case 'browser_press_key':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
key: readString(args.key, 'key'),
|
||||
}));
|
||||
case 'browser_select_option':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
selector: readString(args.selector, 'selector'),
|
||||
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
|
||||
}));
|
||||
case 'browser_wait_for':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
text: readOptionalString(args.text),
|
||||
url: readOptionalString(args.url),
|
||||
timeoutMs: readNumber(args.timeoutMs),
|
||||
}));
|
||||
case 'browser_tabs':
|
||||
return jsonResponse(await callBrowserUseApi(name, {
|
||||
sessionId: readString(args.sessionId, 'sessionId'),
|
||||
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
|
||||
? args.action
|
||||
: undefined,
|
||||
index: readNumber(args.index),
|
||||
url: readOptionalString(args.url),
|
||||
}));
|
||||
case 'browser_close_session':
|
||||
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(message: JsonRpcRequest) {
|
||||
if (message.method === 'initialize') {
|
||||
return {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
|
||||
};
|
||||
}
|
||||
|
||||
if (message.method === 'tools/list') {
|
||||
return { tools };
|
||||
}
|
||||
|
||||
if (message.method === 'tools/call') {
|
||||
const params = message.params || {};
|
||||
const name = readString(params.name, 'name');
|
||||
const args = (params.arguments && typeof params.arguments === 'object'
|
||||
? params.arguments
|
||||
: {}) as Record<string, unknown>;
|
||||
return callTool(name, args);
|
||||
}
|
||||
|
||||
if (message.method.startsWith('notifications/')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported method: ${message.method}`);
|
||||
}
|
||||
|
||||
function writeMessage(message: Record<string, unknown>) {
|
||||
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
|
||||
// no embedded newlines). This is NOT the LSP Content-Length framing.
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function sendResult(id: string | number | null | undefined, result: unknown) {
|
||||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
writeMessage({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function sendError(id: string | number | null | undefined, error: unknown) {
|
||||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const rawMessage = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!rawMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let request: JsonRpcRequest;
|
||||
try {
|
||||
request = JSON.parse(rawMessage) as JsonRpcRequest;
|
||||
} catch (error) {
|
||||
sendError(null, error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handleMessage(request);
|
||||
sendResult(request.id, result);
|
||||
} catch (error) {
|
||||
sendError(request.id, error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
@@ -28,10 +28,19 @@ import {
|
||||
} from './services/notification-orchestrator.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
import {
|
||||
getPendingApprovalsForSession,
|
||||
registerApproval,
|
||||
resolveToolApproval,
|
||||
unregisterApproval,
|
||||
} from './shared/tool-approval-registry.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
// Sessions cancelled via abort-session. The abort handler already sent the
|
||||
// terminal `complete` (aborted: true) to the client, so the run loop must not
|
||||
// emit a second one when its generator winds down.
|
||||
const abortedSessionIds = new Set();
|
||||
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||
|
||||
@@ -60,7 +69,7 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
let timeout;
|
||||
|
||||
const cleanup = () => {
|
||||
pendingToolApprovals.delete(requestId);
|
||||
unregisterApproval(requestId);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
@@ -92,21 +101,15 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
const resolver = (decision) => {
|
||||
finalize(decision);
|
||||
};
|
||||
// Attach metadata for getPendingApprovalsForSession lookup
|
||||
if (metadata) {
|
||||
Object.assign(resolver, metadata);
|
||||
}
|
||||
pendingToolApprovals.set(requestId, resolver);
|
||||
registerApproval(requestId, {
|
||||
resolver,
|
||||
sessionId: metadata?._sessionId ?? null,
|
||||
provider: 'claude',
|
||||
meta: metadata ?? {},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveToolApproval(requestId, decision) {
|
||||
const resolver = pendingToolApprovals.get(requestId);
|
||||
if (resolver) {
|
||||
resolver(decision);
|
||||
}
|
||||
}
|
||||
|
||||
// Match stored permission entries against a tool + input combo.
|
||||
// This only supports exact tool names and the Bash(command:*) shorthand
|
||||
// used by the UI; it intentionally does not implement full glob semantics,
|
||||
@@ -204,7 +207,7 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||
|
||||
// Map model (default to sonnet)
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||
// Model logged at query start below
|
||||
|
||||
@@ -304,7 +307,11 @@ function extractTokenBudget(sdkMessage) {
|
||||
|
||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||
if (messageUsage && typeof messageUsage === 'object') {
|
||||
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
|
||||
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
|
||||
const cacheTokens = cacheCreationTokens + cacheReadTokens;
|
||||
const inputTokens = directInputTokens + cacheTokens;
|
||||
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||
@@ -314,6 +321,9 @@ function extractTokenBudget(sdkMessage) {
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cacheTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
@@ -724,14 +734,18 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Clean up temporary image files
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Send completion event
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
|
||||
// Send the terminal completion event — skipped for aborted runs, whose
|
||||
// terminal `complete` (aborted: true) was already sent by abort-session.
|
||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||
if (!wasAborted) {
|
||||
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
|
||||
}
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
sessionName: sessionSummary,
|
||||
stopReason: 'completed'
|
||||
stopReason: wasAborted ? 'aborted' : 'completed'
|
||||
});
|
||||
// Complete
|
||||
|
||||
@@ -746,14 +760,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
// Clean up temporary image files on error
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
|
||||
if (wasAborted) {
|
||||
// The abort already produced the terminal complete; a generator throw
|
||||
// caused by interrupt() is expected noise, not a user-facing error.
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Claude CLI is installed for a clearer error message
|
||||
const installed = await providerAuthService.isProviderInstalled('claude');
|
||||
const errorContent = !installed
|
||||
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
||||
: error.message;
|
||||
|
||||
// Send error to WebSocket
|
||||
// Send error to WebSocket, then the terminal complete
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: 'claude',
|
||||
@@ -780,6 +802,10 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
try {
|
||||
console.log(`Aborting SDK session: ${sessionId}`);
|
||||
|
||||
// Mark before interrupting so the run loop knows not to emit its own
|
||||
// terminal complete (the abort handler sends the aborted one).
|
||||
abortedSessionIds.add(sessionId);
|
||||
|
||||
// Call interrupt() on the query instance
|
||||
await session.instance.interrupt();
|
||||
|
||||
@@ -795,6 +821,8 @@ async function abortClaudeSDKSession(sessionId) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error aborting session ${sessionId}:`, error);
|
||||
// The run keeps going; let it emit its own terminal complete.
|
||||
abortedSessionIds.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -817,28 +845,6 @@ function getActiveClaudeSDKSessions() {
|
||||
return getAllSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tool approvals for a specific session.
|
||||
* @param {string} sessionId - The session ID
|
||||
* @returns {Array} Array of pending permission request objects
|
||||
*/
|
||||
function getPendingApprovalsForSession(sessionId) {
|
||||
const pending = [];
|
||||
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
|
||||
if (resolver._sessionId === sessionId) {
|
||||
pending.push({
|
||||
requestId,
|
||||
toolName: resolver._toolName || 'UnknownTool',
|
||||
input: resolver._input,
|
||||
context: resolver._context,
|
||||
sessionId,
|
||||
receivedAt: resolver._receivedAt || new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
||||
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* (no args) - Start the server (default)
|
||||
* start - Start the server
|
||||
* sandbox - Manage Docker sandbox environments
|
||||
* browser-use-mcp - Run Browser MCP stdio server
|
||||
* status - Show configuration and data locations
|
||||
* help - Show help information
|
||||
* version - Show version information
|
||||
@@ -154,12 +155,13 @@ Usage:
|
||||
cloudcli [command] [options]
|
||||
|
||||
Commands:
|
||||
start Start the CloudCLI server (default)
|
||||
sandbox Manage Docker sandbox environments
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
version Show version information
|
||||
start Start the CloudCLI server (default)
|
||||
sandbox Manage Docker sandbox environments
|
||||
browser-use-mcp Run the Browser MCP stdio server
|
||||
status Show configuration and data locations
|
||||
update Update to the latest version
|
||||
help Show this help information
|
||||
version Show version information
|
||||
|
||||
Options:
|
||||
-p, --port <port> Set server port (default: 3001)
|
||||
@@ -455,7 +457,7 @@ async function sandboxCommand(args) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||
|
||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||
try {
|
||||
@@ -554,7 +556,7 @@ async function sandboxCommand(args) {
|
||||
|
||||
// Step 3: Start CloudCLI inside the sandbox
|
||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||
|
||||
// Step 4: Forward port
|
||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||
@@ -605,6 +607,10 @@ async function startServer() {
|
||||
await import('./index.js');
|
||||
}
|
||||
|
||||
async function startBrowserUseMcp() {
|
||||
await import('./browser-use-mcp.js');
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
function parseArgs(args) {
|
||||
const parsed = { command: 'start', options: {} };
|
||||
@@ -658,6 +664,9 @@ async function main() {
|
||||
case 'sandbox':
|
||||
await sandboxCommand(remainingArgs || []);
|
||||
break;
|
||||
case 'browser-use-mcp':
|
||||
await startBrowserUseMcp();
|
||||
break;
|
||||
case 'status':
|
||||
case 'info':
|
||||
showStatus();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -34,6 +34,10 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let hasRetriedWithTrust = false;
|
||||
let settled = false;
|
||||
// The unified lifecycle contract requires exactly one terminal `complete`
|
||||
// per run. Cursor surfaces completion twice (the `result` JSON line and
|
||||
// the process close), so the first emission wins.
|
||||
let completeSent = false;
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
@@ -197,15 +201,15 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
break;
|
||||
|
||||
case 'result': {
|
||||
// Session complete — send stream end + lifecycle complete with result payload
|
||||
const resultText = typeof response.result === 'string' ? response.result : '';
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||
resultText,
|
||||
isError: response.subtype !== 'success',
|
||||
sessionId: capturedSessionId || sessionId, provider: 'cursor',
|
||||
}));
|
||||
// Session complete — terminal lifecycle event for this run
|
||||
if (!completeSent) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({
|
||||
provider: 'cursor',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
exitCode: response.subtype === 'success' ? 0 : 1,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -271,7 +275,12 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
|
||||
// Terminal complete — unless the `result` line already sent it, or the
|
||||
// run was aborted (abort-session sent the aborted complete).
|
||||
if (!completeSent && !cursorProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
notifyTerminalState({ code });
|
||||
@@ -297,6 +306,10 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
: error.message;
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
|
||||
if (!completeSent && !cursorProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
|
||||
settleOnce(() => reject(error));
|
||||
@@ -314,6 +327,9 @@ function abortCursorSession(sessionId) {
|
||||
const process = activeCursorProcesses.get(sessionId);
|
||||
if (process) {
|
||||
console.log(`Aborting Cursor session: ${sessionId}`);
|
||||
// The abort handler sends the terminal complete (aborted: true); flag the
|
||||
// process so its close handler does not emit a second one.
|
||||
process.aborted = true;
|
||||
process.kill('SIGTERM');
|
||||
activeCursorProcesses.delete(sessionId);
|
||||
return true;
|
||||
|
||||
@@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -129,6 +129,9 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||
// Unified lifecycle contract: exactly one terminal `complete` per run
|
||||
// (close and error handlers can both fire for spawn failures).
|
||||
let completeSent = false;
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
@@ -486,7 +489,12 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||
}
|
||||
|
||||
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
|
||||
// Terminal complete — skipped for aborted runs (abort-session
|
||||
// already sent the aborted complete on this run's behalf).
|
||||
if (!completeSent && !geminiProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
|
||||
}
|
||||
|
||||
// Clean up temporary image files if any
|
||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||
@@ -566,6 +574,10 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
|
||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
|
||||
if (!completeSent && !geminiProcess.aborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
|
||||
reject(error);
|
||||
@@ -590,6 +602,9 @@ function abortGeminiSession(sessionId) {
|
||||
|
||||
if (geminiProc) {
|
||||
try {
|
||||
// The abort handler sends the terminal complete (aborted: true);
|
||||
// flag the process so its close handler does not emit a second one.
|
||||
geminiProc.aborted = true;
|
||||
geminiProc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (activeGeminiProcesses.has(processKey)) {
|
||||
|
||||
407
server/hermes-cli.js
Normal file
407
server/hermes-cli.js
Normal file
@@ -0,0 +1,407 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
import {
|
||||
clearApprovalsForSession,
|
||||
getPendingApprovalsForSession,
|
||||
registerApproval,
|
||||
resolveToolApproval,
|
||||
unregisterApproval,
|
||||
} from './shared/tool-approval-registry.js';
|
||||
import { hermesConnectionManager } from './hermes/acp-client.js';
|
||||
|
||||
const PROVIDER = 'hermes';
|
||||
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||
const activeHermesSessions = new Map();
|
||||
// Session ids whose run was aborted; the terminal `complete` is emitted by
|
||||
// handleChatAbort, so the runtime must not also emit a "completed" one.
|
||||
const abortedSessionIds = new Set();
|
||||
|
||||
function createRequestId() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function readSessionId(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return result.sessionId
|
||||
|| result.session_id
|
||||
|| result.id
|
||||
|| result.session?.id
|
||||
|| result.session?.sessionId
|
||||
|| result.session?.session_id
|
||||
|| null;
|
||||
}
|
||||
|
||||
function readStopReason(result) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return result.stopReason || result.stop_reason || result.reason || null;
|
||||
}
|
||||
|
||||
function buildPromptParams(sessionId, command) {
|
||||
return {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: command }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionSetupParams(sessionId, workingDir) {
|
||||
return {
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
cwd: workingDir,
|
||||
mcpServers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function canLoadSession(connection) {
|
||||
return connection?.initializeResult?.agentCapabilities?.loadSession === true;
|
||||
}
|
||||
|
||||
function findPermissionOption(options, kinds, fallbackOptionIds = []) {
|
||||
if (!Array.isArray(options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const kind of kinds) {
|
||||
const match = options.find((option) => option?.kind === kind);
|
||||
if (match?.optionId) {
|
||||
return match.optionId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const optionId of fallbackOptionIds) {
|
||||
const match = options.find((option) => option?.optionId === optionId);
|
||||
if (match?.optionId) {
|
||||
return match.optionId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createPermissionDecision(decision, options = []) {
|
||||
if (!decision) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
if (decision.cancelled) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
if (decision.allow) {
|
||||
const optionId = decision.rememberEntry
|
||||
? findPermissionOption(options, ['allow_always', 'allow_session'], ['allow_always', 'allow_session'])
|
||||
: findPermissionOption(options, ['allow_once'], ['allow_once']);
|
||||
|
||||
if (!optionId) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const denyOptionId = findPermissionOption(options, ['reject_once', 'deny', 'reject_always'], ['deny', 'reject_once', 'reject_always']);
|
||||
if (denyOptionId) {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: denyOptionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: { outcome: 'cancelled' },
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForPermission(ws, params, capturedSessionId, sessionSummary) {
|
||||
const requestId = createRequestId();
|
||||
const toolCall = params?.toolCall || params?.tool_call || {};
|
||||
const toolName = params?.toolName
|
||||
|| params?.tool_name
|
||||
|| params?.name
|
||||
|| params?.tool?.name
|
||||
|| toolCall.title
|
||||
|| 'HermesTool';
|
||||
const input = params?.input
|
||||
?? params?.arguments
|
||||
?? params?.toolInput
|
||||
?? params?.tool_input
|
||||
?? toolCall.rawInput
|
||||
?? toolCall.raw_input
|
||||
?? toolCall;
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'permission_request',
|
||||
requestId,
|
||||
toolName,
|
||||
input,
|
||||
sessionId: capturedSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
registerApproval(requestId, {
|
||||
sessionId: capturedSessionId,
|
||||
provider: PROVIDER,
|
||||
meta: {
|
||||
toolName,
|
||||
input,
|
||||
context: params,
|
||||
sessionName: sessionSummary,
|
||||
receivedAt: new Date(),
|
||||
},
|
||||
resolver: (decision) => {
|
||||
unregisterApproval(requestId);
|
||||
resolve(createPermissionDecision(decision, params?.options));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function spawnHermes(command, options = {}, ws) {
|
||||
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||
const workingDir = cwd || projectPath || process.cwd();
|
||||
const requestedModel = model === HERMES_CONFIGURED_MODEL ? undefined : model;
|
||||
let capturedSessionId = sessionId || null;
|
||||
let sessionCreatedSent = false;
|
||||
let completeSent = false;
|
||||
let activeKey = capturedSessionId || `pending-${createRequestId()}`;
|
||||
|
||||
const notifyTerminalState = ({ error = null, stopReason = 'completed' } = {}) => {
|
||||
const finalSessionId = capturedSessionId || sessionId || activeKey;
|
||||
if (!error) {
|
||||
notifyRunStopped({
|
||||
userId: ws?.userId || null,
|
||||
provider: PROVIDER,
|
||||
sessionId: finalSessionId,
|
||||
sessionName: sessionSummary,
|
||||
stopReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notifyRunFailed({
|
||||
userId: ws?.userId || null,
|
||||
provider: PROVIDER,
|
||||
sessionId: finalSessionId,
|
||||
sessionName: sessionSummary,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
const registerSession = (nextSessionId, connection) => {
|
||||
if (!nextSessionId || capturedSessionId === nextSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeHermesSessions.has(activeKey)) {
|
||||
activeHermesSessions.delete(activeKey);
|
||||
}
|
||||
activeKey = nextSessionId;
|
||||
capturedSessionId = nextSessionId;
|
||||
activeHermesSessions.set(activeKey, {
|
||||
connection,
|
||||
sessionId: capturedSessionId,
|
||||
status: 'active',
|
||||
aborted: false,
|
||||
ws,
|
||||
sessionSummary,
|
||||
});
|
||||
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
ws.setSessionId(capturedSessionId);
|
||||
}
|
||||
|
||||
if (!sessionId && !sessionCreatedSent) {
|
||||
sessionCreatedSent = true;
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'session_created',
|
||||
newSessionId: capturedSessionId,
|
||||
sessionId: capturedSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
|
||||
const connection = await hermesConnectionManager.getConnection(workingDir);
|
||||
activeHermesSessions.set(activeKey, {
|
||||
connection,
|
||||
sessionId: capturedSessionId,
|
||||
status: 'active',
|
||||
aborted: false,
|
||||
ws,
|
||||
sessionSummary,
|
||||
});
|
||||
|
||||
const unregisterPermissionHandler = connection.registerRequestHandler('session/request_permission', (params) => {
|
||||
const permissionSessionId = params?.sessionId || params?.session_id || null;
|
||||
const active = permissionSessionId
|
||||
? activeHermesSessions.get(permissionSessionId)
|
||||
: activeHermesSessions.get(activeKey);
|
||||
if (!active) {
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
return waitForPermission(
|
||||
active.ws,
|
||||
params,
|
||||
active.sessionId || permissionSessionId || capturedSessionId,
|
||||
active.sessionSummary || sessionSummary,
|
||||
);
|
||||
});
|
||||
|
||||
const updateHandler = (params) => {
|
||||
const updateSessionId = params?.sessionId || params?.session_id || null;
|
||||
if (capturedSessionId && updateSessionId && updateSessionId !== capturedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerSession(updateSessionId, connection);
|
||||
const normalized = sessionsService.normalizeMessage(PROVIDER, params, capturedSessionId || updateSessionId || null);
|
||||
for (const msg of normalized) {
|
||||
ws.send(msg);
|
||||
}
|
||||
};
|
||||
|
||||
connection.on('session/update', updateHandler);
|
||||
|
||||
try {
|
||||
let sessionResult;
|
||||
if (sessionId && canLoadSession(connection)) {
|
||||
try {
|
||||
sessionResult = await connection.request('session/load', buildSessionSetupParams(sessionId, workingDir));
|
||||
} catch {
|
||||
sessionResult = { sessionId };
|
||||
}
|
||||
} else {
|
||||
sessionResult = await connection.request('session/new', buildSessionSetupParams(null, workingDir));
|
||||
}
|
||||
|
||||
registerSession(readSessionId(sessionResult) || sessionId, connection);
|
||||
if (!capturedSessionId) {
|
||||
throw new Error('Hermes ACP did not return a session id.');
|
||||
}
|
||||
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command));
|
||||
const finalSessionId = capturedSessionId || readSessionId(promptResult) || sessionId || activeKey;
|
||||
const stopReason = readStopReason(promptResult) || 'completed';
|
||||
const active = activeHermesSessions.get(finalSessionId) || activeHermesSessions.get(activeKey);
|
||||
|
||||
if (promptResult?.usage || promptResult?.tokenUsage || promptResult?.token_usage) {
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'status',
|
||||
text: 'token_budget',
|
||||
tokenBudget: promptResult.usage || promptResult.tokenUsage || promptResult.token_usage,
|
||||
sessionId: finalSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
}
|
||||
|
||||
const abortedById = abortedSessionIds.delete(finalSessionId);
|
||||
const abortedByKey = abortedSessionIds.delete(activeKey);
|
||||
const wasAborted = Boolean(active?.aborted || abortedById || abortedByKey);
|
||||
|
||||
if (!completeSent && !wasAborted) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 0 }));
|
||||
}
|
||||
activeHermesSessions.delete(finalSessionId);
|
||||
activeHermesSessions.delete(activeKey);
|
||||
clearApprovalsForSession(finalSessionId);
|
||||
notifyTerminalState({ stopReason: wasAborted ? 'aborted' : stopReason });
|
||||
} finally {
|
||||
connection.off('session/update', updateHandler);
|
||||
unregisterPermissionHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
const finalSessionId = capturedSessionId || sessionId || activeKey;
|
||||
const abortedById = abortedSessionIds.delete(finalSessionId);
|
||||
const abortedByKey = abortedSessionIds.delete(activeKey);
|
||||
activeHermesSessions.delete(finalSessionId);
|
||||
activeHermesSessions.delete(activeKey);
|
||||
clearApprovalsForSession(finalSessionId);
|
||||
|
||||
// A cancelled session/prompt rejects here; its aborted terminal `complete`
|
||||
// is sent by handleChatAbort, so don't surface the cancellation as an error.
|
||||
if (abortedById || abortedByKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installed = await providerAuthService.isProviderInstalled(PROVIDER);
|
||||
const errorContent = !installed
|
||||
? 'Hermes ACP is not installed. Install Hermes and ensure hermes-acp is on PATH.'
|
||||
: error instanceof Error ? error.message : String(error);
|
||||
|
||||
ws.send(createNormalizedMessage({
|
||||
kind: 'error',
|
||||
content: errorContent,
|
||||
sessionId: finalSessionId,
|
||||
provider: PROVIDER,
|
||||
}));
|
||||
if (!completeSent) {
|
||||
completeSent = true;
|
||||
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 1 }));
|
||||
}
|
||||
notifyTerminalState({ error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function abortHermesSession(providerSessionId) {
|
||||
const active = activeHermesSessions.get(providerSessionId);
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
active.aborted = true;
|
||||
active.status = 'aborted';
|
||||
abortedSessionIds.add(providerSessionId);
|
||||
if (active.sessionId) {
|
||||
abortedSessionIds.add(active.sessionId);
|
||||
}
|
||||
for (const approval of getPendingApprovalsForSession(active.sessionId || providerSessionId)) {
|
||||
resolveToolApproval(approval.requestId, { cancelled: true });
|
||||
}
|
||||
|
||||
try {
|
||||
active.connection.notify('session/cancel', { sessionId: active.sessionId || providerSessionId });
|
||||
} catch {
|
||||
// If Hermes already finished, the caller still sees the run as aborted.
|
||||
}
|
||||
activeHermesSessions.delete(providerSessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isHermesSessionActive(sessionId) {
|
||||
return activeHermesSessions.has(sessionId);
|
||||
}
|
||||
|
||||
function getActiveHermesSessions() {
|
||||
return Array.from(activeHermesSessions.keys());
|
||||
}
|
||||
|
||||
export {
|
||||
spawnHermes,
|
||||
abortHermesSession,
|
||||
isHermesSessionActive,
|
||||
getActiveHermesSessions,
|
||||
createPermissionDecision,
|
||||
};
|
||||
287
server/hermes/acp-client.js
Normal file
287
server/hermes/acp-client.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
|
||||
class AcpClient extends EventEmitter {
|
||||
constructor({ command = process.env.HERMES_CLI_PATH || 'hermes acp', cwd = process.cwd(), env = process.env } = {}) {
|
||||
super();
|
||||
const commandParts = command.trim().split(/\s+/);
|
||||
this.command = commandParts.shift() || 'hermes';
|
||||
this.args = commandParts;
|
||||
this.cwd = cwd;
|
||||
this.env = env;
|
||||
this.process = null;
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.buffer = '';
|
||||
this.requestHandlers = new Map();
|
||||
this.initialized = false;
|
||||
this.initializeResult = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.process) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process = spawnFunction(this.command, this.args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...this.env },
|
||||
});
|
||||
|
||||
this.process.stdout.on('data', (chunk) => this.handleData(chunk));
|
||||
this.process.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
if (text.trim()) {
|
||||
this.emit('stderr', text);
|
||||
}
|
||||
});
|
||||
this.process.on('error', (error) => this.rejectAll(error));
|
||||
this.process.on('close', (code, signal) => {
|
||||
this.rejectAll(new Error(`hermes-acp exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`));
|
||||
this.emit('close', { code, signal });
|
||||
this.process = null;
|
||||
this.initialized = false;
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.start();
|
||||
this.initializeResult = await this.request('initialize', {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: false,
|
||||
writeTextFile: false,
|
||||
},
|
||||
terminal: false,
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'CloudCLI',
|
||||
title: 'CloudCLI',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
this.initialized = true;
|
||||
this.notify('initialized', {});
|
||||
}
|
||||
|
||||
onRequest(method, handler) {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
registerRequestHandler(method, handler) {
|
||||
const handlers = this.requestHandlers.get(method) || new Set();
|
||||
handlers.add(handler);
|
||||
this.requestHandlers.set(method, handlers);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.requestHandlers.delete(method);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
request(method, params) {
|
||||
this.start();
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
|
||||
const payload = { jsonrpc: '2.0', id, method, params };
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject, method, params });
|
||||
this.writeMessage(payload);
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
this.start();
|
||||
this.writeMessage({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
writeMessage(payload) {
|
||||
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) {
|
||||
throw new Error('hermes-acp process is not running');
|
||||
}
|
||||
const line = `${JSON.stringify(payload)}\n`;
|
||||
this.process.stdin.write(line);
|
||||
}
|
||||
|
||||
handleData(chunk) {
|
||||
this.buffer += chunk.toString();
|
||||
|
||||
while (this.buffer.length > 0) {
|
||||
if (this.buffer.startsWith('Content-Length:')) {
|
||||
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
return;
|
||||
}
|
||||
const header = this.buffer.slice(0, headerEnd);
|
||||
const match = header.match(/Content-Length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
const length = Number(match[1]);
|
||||
const messageStart = headerEnd + 4;
|
||||
if (this.buffer.length < messageStart + length) {
|
||||
return;
|
||||
}
|
||||
const raw = this.buffer.slice(messageStart, messageStart + length);
|
||||
this.buffer = this.buffer.slice(messageStart + length);
|
||||
this.dispatchRaw(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newlineIndex = this.buffer.indexOf('\n');
|
||||
if (newlineIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const raw = this.buffer.slice(0, newlineIndex).trim();
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||
if (raw) {
|
||||
this.dispatchRaw(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchRaw(raw) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
this.emit('error', error);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.dispatchMessage(message);
|
||||
}
|
||||
|
||||
async dispatchMessage(message) {
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'id') && (message.result !== undefined || message.error !== undefined)) {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
const messageText = message.error.message || JSON.stringify(message.error);
|
||||
const error = new Error(`ACP ${pending.method} failed: ${messageText}`);
|
||||
error.code = message.error.code;
|
||||
error.data = message.error.data;
|
||||
error.method = pending.method;
|
||||
error.params = pending.params;
|
||||
pending.reject(error);
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'id') && message.method) {
|
||||
const handler = this.requestHandlers.get(message.method);
|
||||
if (!handler) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: { code: -32601, message: `No handler for ${message.method}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = handler instanceof Set
|
||||
? await this.dispatchRequestHandlers(handler, message.params)
|
||||
: await handler(message.params);
|
||||
this.writeMessage({ jsonrpc: '2.0', id: message.id, result });
|
||||
} catch (error) {
|
||||
this.writeMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method) {
|
||||
this.emit(message.method, message.params);
|
||||
this.emit('notification', { method: message.method, params: message.params });
|
||||
}
|
||||
}
|
||||
|
||||
rejectAll(error) {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
async dispatchRequestHandlers(handlers, params) {
|
||||
let fallbackResult = null;
|
||||
let sawHandler = false;
|
||||
for (const handler of Array.from(handlers).reverse()) {
|
||||
sawHandler = true;
|
||||
const result = await handler(params);
|
||||
const outcome = result?.outcome?.outcome;
|
||||
if (outcome !== 'cancelled') {
|
||||
return result;
|
||||
}
|
||||
fallbackResult = result;
|
||||
}
|
||||
if (sawHandler && fallbackResult) {
|
||||
return fallbackResult;
|
||||
}
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.process) {
|
||||
return;
|
||||
}
|
||||
this.process.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
class HermesConnectionManager {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
async getConnection(cwd) {
|
||||
const key = cwd || process.cwd();
|
||||
let connection = this.connections.get(key);
|
||||
if (!connection) {
|
||||
connection = new AcpClient({ cwd: key });
|
||||
connection.on('close', () => {
|
||||
this.connections.delete(key);
|
||||
});
|
||||
this.connections.set(key, connection);
|
||||
}
|
||||
await connection.initialize();
|
||||
return connection;
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.close();
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const hermesConnectionManager = new HermesConnectionManager();
|
||||
|
||||
export {
|
||||
AcpClient,
|
||||
HermesConnectionManager,
|
||||
hermesConnectionManager,
|
||||
};
|
||||
403
server/index.js
403
server/index.js
@@ -22,36 +22,29 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
||||
import {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval,
|
||||
getPendingApprovalsForSession,
|
||||
reconnectSessionWriter,
|
||||
} from './claude-sdk.js';
|
||||
import {
|
||||
spawnCursor,
|
||||
abortCursorSession,
|
||||
isCursorSessionActive,
|
||||
getActiveCursorSessions,
|
||||
} from './cursor-cli.js';
|
||||
import {
|
||||
queryCodex,
|
||||
abortCodexSession,
|
||||
isCodexSessionActive,
|
||||
getActiveCodexSessions,
|
||||
} from './openai-codex.js';
|
||||
import {
|
||||
spawnGemini,
|
||||
abortGeminiSession,
|
||||
isGeminiSessionActive,
|
||||
getActiveGeminiSessions,
|
||||
} from './gemini-cli.js';
|
||||
import {
|
||||
spawnOpenCode,
|
||||
abortOpenCodeSession,
|
||||
isOpenCodeSessionActive,
|
||||
getActiveOpenCodeSessions,
|
||||
} from './opencode-cli.js';
|
||||
import {
|
||||
spawnHermes,
|
||||
abortHermesSession,
|
||||
} from './hermes-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import {
|
||||
stripAnsiSequences,
|
||||
@@ -68,10 +61,15 @@ import commandsRoutes from './routes/commands.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import agentRoutes from './routes/agent.js';
|
||||
import projectModuleRoutes from './modules/projects/projects.routes.js';
|
||||
import notificationRoutes from './modules/notifications/notifications.routes.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.js';
|
||||
import voiceRoutes from './voice-proxy.js';
|
||||
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
|
||||
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
@@ -84,9 +82,30 @@ const __dirname = getModuleDir(import.meta.url);
|
||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||
const APP_ROOT = findAppRoot(__dirname);
|
||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||
// Version of the code that is actually running, captured once at process
|
||||
// startup. This intentionally does NOT re-read package.json per request: after
|
||||
// an update replaces the files on disk, package.json reflects the NEW version
|
||||
// while this long-lived process still runs the OLD code. The frontend bundle is
|
||||
// rebuilt on update, so a mismatch between this value and the frontend's
|
||||
// build-time version means the server was updated but not restarted.
|
||||
const RUNNING_VERSION = (() => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||
|
||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||
|
||||
function readUsageNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
@@ -97,32 +116,37 @@ const wss = createWebSocketServer(server, {
|
||||
authenticateWebSocket,
|
||||
},
|
||||
chat: {
|
||||
queryClaudeSDK,
|
||||
spawnCursor,
|
||||
queryCodex,
|
||||
spawnGemini,
|
||||
spawnOpenCode,
|
||||
abortClaudeSDKSession,
|
||||
abortCursorSession,
|
||||
abortCodexSession,
|
||||
abortGeminiSession,
|
||||
abortOpenCodeSession,
|
||||
spawnFns: {
|
||||
claude: queryClaudeSDK,
|
||||
cursor: spawnCursor,
|
||||
codex: queryCodex,
|
||||
gemini: spawnGemini,
|
||||
opencode: spawnOpenCode,
|
||||
hermes: spawnHermes,
|
||||
},
|
||||
abortFns: {
|
||||
claude: abortClaudeSDKSession,
|
||||
cursor: abortCursorSession,
|
||||
codex: abortCodexSession,
|
||||
gemini: abortGeminiSession,
|
||||
opencode: abortOpenCodeSession,
|
||||
hermes: abortHermesSession,
|
||||
},
|
||||
resolveToolApproval,
|
||||
isClaudeSDKSessionActive,
|
||||
isCursorSessionActive,
|
||||
isCodexSessionActive,
|
||||
isGeminiSessionActive,
|
||||
isOpenCodeSessionActive,
|
||||
reconnectSessionWriter,
|
||||
getPendingApprovalsForSession,
|
||||
getActiveClaudeSDKSessions,
|
||||
getActiveCursorSessions,
|
||||
getActiveCodexSessions,
|
||||
getActiveGeminiSessions,
|
||||
getActiveOpenCodeSessions,
|
||||
},
|
||||
shell: {
|
||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
||||
resolveProviderSessionId: (sessionId, provider) => {
|
||||
const dbSession = sessionsDb.getSessionById(sessionId);
|
||||
const legacyGeminiSession =
|
||||
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
|
||||
|
||||
if (dbSession) {
|
||||
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
|
||||
}
|
||||
|
||||
return legacyGeminiSession?.cliSessionId;
|
||||
},
|
||||
stripAnsiSequences,
|
||||
normalizeDetectedUrl,
|
||||
extractUrlsFromText,
|
||||
@@ -153,7 +177,8 @@ app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
installMode
|
||||
installMode,
|
||||
version: RUNNING_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +209,8 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
|
||||
// Settings API Routes (protected)
|
||||
app.use('/api/settings', authenticateToken, settingsRoutes);
|
||||
|
||||
app.use('/api/notifications', authenticateToken, notificationRoutes);
|
||||
|
||||
// User API Routes (protected)
|
||||
app.use('/api/user', authenticateToken, userRoutes);
|
||||
|
||||
@@ -193,12 +220,20 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||
// Plugins API Routes (protected)
|
||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
|
||||
// Browser MCP bridge API (local token protected)
|
||||
app.use('/api/browser-use-mcp', browserUseMcpRoutes);
|
||||
|
||||
// Browser API Routes (protected)
|
||||
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
|
||||
|
||||
// Unified provider MCP routes (protected)
|
||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
app.use('/api/voice', authenticateToken, voiceRoutes);
|
||||
|
||||
// Serve public files (like api-docs.html)
|
||||
app.use(express.static(path.join(APP_ROOT, 'public')));
|
||||
|
||||
@@ -892,27 +927,27 @@ const uploadFilesHandler = async (req, res) => {
|
||||
}
|
||||
}),
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
||||
files: 20 // Max 20 files at once
|
||||
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||
files: MAX_FILE_UPLOAD_COUNT
|
||||
}
|
||||
});
|
||||
|
||||
// Use multer middleware
|
||||
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
||||
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
||||
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
||||
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { targetPath, relativePaths } = req.body;
|
||||
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
|
||||
|
||||
// Parse relative paths if provided (for folder uploads)
|
||||
let filePaths = [];
|
||||
@@ -936,6 +971,11 @@ const uploadFilesHandler = async (req, res) => {
|
||||
return res.status(400).json({ error: 'No files provided' });
|
||||
}
|
||||
|
||||
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
|
||||
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
|
||||
? parsedRequestedFileCount
|
||||
: req.files.length;
|
||||
|
||||
// Resolve the project directory through the DB using the new projectId.
|
||||
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
||||
if (!projectRoot) {
|
||||
@@ -1014,8 +1054,10 @@ const uploadFilesHandler = async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
files: uploadedFiles,
|
||||
uploadedCount: uploadedFiles.length,
|
||||
requestedFileCount,
|
||||
targetPath: resolvedTargetDir,
|
||||
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
||||
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
@@ -1128,7 +1170,6 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req
|
||||
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectId, sessionId } = req.params;
|
||||
const { provider = 'claude' } = req.query;
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
@@ -1137,6 +1178,18 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
|
||||
// are keyed by the provider-native session id, while the caller sends
|
||||
// the app-facing id. Resolve provider and id mapping from the indexed
|
||||
// session row so the frontend does not choose provider-specific paths.
|
||||
const sessionRow = sessionsDb.getSessionById(safeSessionId);
|
||||
if (!sessionRow) {
|
||||
return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId });
|
||||
}
|
||||
|
||||
const provider = sessionRow.provider || 'claude';
|
||||
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
|
||||
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
if (provider === 'cursor') {
|
||||
return res.json({
|
||||
@@ -1237,7 +1290,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
tokens_cache_write AS cacheWriteTokens
|
||||
FROM session
|
||||
WHERE id = ?
|
||||
`).get(safeSessionId);
|
||||
`).get(providerNativeSessionId);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
||||
@@ -1278,7 +1331,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
} else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
@@ -1362,12 +1415,19 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
// Prefer the indexed transcript path (already produced by the trusted
|
||||
// session synchronizer); fall back to the conventional location
|
||||
// derived from the provider-native session id.
|
||||
let jsonlPath = sessionRow?.jsonl_path;
|
||||
if (!jsonlPath) {
|
||||
jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`);
|
||||
|
||||
// Constrain to projectDir
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
// Constrain the constructed path to projectDir (the id is
|
||||
// caller-influenced in this fallback branch).
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
}
|
||||
|
||||
// Read and parse the JSONL file
|
||||
@@ -1386,6 +1446,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
@@ -1397,8 +1459,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
const usage = entry.message.usage;
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
outputTokens = usage.output_tokens || 0;
|
||||
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
|
||||
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
|
||||
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
|
||||
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
@@ -1409,12 +1474,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
}
|
||||
|
||||
const totalUsed = inputTokens + outputTokens;
|
||||
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
cacheTokens,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
output: outputTokens
|
||||
@@ -1483,74 +1552,133 @@ function permToRwx(perm) {
|
||||
return r + w + x;
|
||||
}
|
||||
|
||||
// Directories that are almost never interesting for a project tree but can
|
||||
// contain tens of thousands of files. Skipping them before recursion keeps
|
||||
// traversal time bounded on large monorepos and high-latency filesystems
|
||||
// (NFS / SMB).
|
||||
const IGNORED_DIRS = new Set([
|
||||
// JS / TS toolchains
|
||||
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
|
||||
// VCS
|
||||
'.git', '.svn', '.hg',
|
||||
// Python
|
||||
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
|
||||
// Rust / Go / Java / Ruby
|
||||
'target', 'vendor',
|
||||
// Build output / IDE
|
||||
'.gradle', '.idea', 'coverage', '.nyc_output'
|
||||
]);
|
||||
|
||||
const DEFAULT_FS_CONCURRENCY = 64;
|
||||
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
|
||||
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
|
||||
? parsedFsConcurrency
|
||||
: DEFAULT_FS_CONCURRENCY;
|
||||
let activeFsOperations = 0;
|
||||
const pendingFsOperations = [];
|
||||
|
||||
async function acquire() {
|
||||
if (activeFsOperations < FS_CONCURRENCY) {
|
||||
activeFsOperations += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
pendingFsOperations.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function release() {
|
||||
const next = pendingFsOperations.shift();
|
||||
if (next) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
activeFsOperations = Math.max(0, activeFsOperations - 1);
|
||||
}
|
||||
|
||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||
// Using fsPromises from import
|
||||
const items = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Debug: log all entries including hidden files
|
||||
|
||||
|
||||
// Skip heavy build directories and VCS directories
|
||||
if (entry.name === 'node_modules' ||
|
||||
entry.name === 'dist' ||
|
||||
entry.name === 'build' ||
|
||||
entry.name === '.git' ||
|
||||
entry.name === '.svn' ||
|
||||
entry.name === '.hg') continue;
|
||||
|
||||
const itemPath = path.join(dirPath, entry.name);
|
||||
const item = {
|
||||
name: entry.name,
|
||||
path: itemPath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
// Get file stats for additional metadata
|
||||
try {
|
||||
const stats = await fsPromises.stat(itemPath);
|
||||
item.size = stats.size;
|
||||
item.modified = stats.mtime.toISOString();
|
||||
|
||||
// Convert permissions to rwx format
|
||||
const mode = stats.mode;
|
||||
const ownerPerm = (mode >> 6) & 7;
|
||||
const groupPerm = (mode >> 3) & 7;
|
||||
const otherPerm = mode & 7;
|
||||
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
||||
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
||||
} catch (statError) {
|
||||
// If stat fails, provide default values
|
||||
item.size = 0;
|
||||
item.modified = null;
|
||||
item.permissions = '000';
|
||||
item.permissionsRwx = '---------';
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||
// Recursively get subdirectories but limit depth
|
||||
try {
|
||||
// Check if we can access the directory before trying to read it
|
||||
await fsPromises.access(item.path, fs.constants.R_OK);
|
||||
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||
} catch (e) {
|
||||
// Silently skip directories we can't access (permission denied, etc.)
|
||||
item.children = [];
|
||||
}
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
await acquire();
|
||||
try {
|
||||
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log non-permission errors to avoid spam
|
||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||
console.error('Error reading directory:', error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
|
||||
|
||||
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
|
||||
// serial stat() was the real bottleneck — issuing them concurrently lets
|
||||
// the kernel pipeline the round-trips and the recursive calls overlap too.
|
||||
const items = await Promise.all(filteredEntries.map(async (entry) => {
|
||||
const itemPath = path.join(dirPath, entry.name);
|
||||
const item = {
|
||||
name: entry.name,
|
||||
path: itemPath,
|
||||
type: entry.isDirectory() ? 'directory' : 'file'
|
||||
};
|
||||
|
||||
// Get file stats for additional metadata
|
||||
try {
|
||||
await acquire();
|
||||
try {
|
||||
const stats = await fsPromises.lstat(itemPath);
|
||||
item.size = stats.size;
|
||||
item.modified = stats.mtime.toISOString();
|
||||
|
||||
// Mark symlinks so UI can distinguish them
|
||||
if (stats.isSymbolicLink()) {
|
||||
item.isSymlink = true;
|
||||
}
|
||||
|
||||
// Convert permissions to rwx format
|
||||
const mode = stats.mode;
|
||||
const ownerPerm = (mode >> 6) & 7;
|
||||
const groupPerm = (mode >> 3) & 7;
|
||||
const otherPerm = mode & 7;
|
||||
item.permissions =
|
||||
((mode >> 6) & 7).toString() +
|
||||
((mode >> 3) & 7).toString() +
|
||||
(mode & 7).toString();
|
||||
item.permissionsRwx =
|
||||
permToRwx(ownerPerm) +
|
||||
permToRwx(groupPerm) +
|
||||
permToRwx(otherPerm);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
} catch (statError) {
|
||||
// If stat fails, provide default values
|
||||
item.size = 0;
|
||||
item.modified = null;
|
||||
item.permissions = '000';
|
||||
item.permissionsRwx = '---------';
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||
// Recurse. Let readdir's own EACCES bubble up through the catch in
|
||||
// the recursive call rather than doing a separate access() probe
|
||||
// (which doubled the round-trip count on SMB without adding info).
|
||||
// The recursive call starts with a bounded readdir; holding a permit
|
||||
// for the whole subtree can deadlock when sibling directories are
|
||||
// waiting on their own children.
|
||||
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
|
||||
}
|
||||
|
||||
return item;
|
||||
}));
|
||||
|
||||
return items.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
@@ -1563,6 +1691,40 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DISPLAY_HOST = getConnectableHost(HOST);
|
||||
const VITE_PORT = process.env.VITE_PORT || 5173;
|
||||
const LOCAL_SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
||||
|
||||
async function writeLocalServerMarker() {
|
||||
const marker = {
|
||||
pid: process.pid,
|
||||
host: HOST,
|
||||
port: Number.parseInt(String(SERVER_PORT), 10),
|
||||
url: `http://${DISPLAY_HOST}:${SERVER_PORT}`,
|
||||
installMode,
|
||||
appRoot: APP_ROOT,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fsPromises.mkdir(path.dirname(LOCAL_SERVER_MARKER_PATH), { recursive: true });
|
||||
await fsPromises.writeFile(LOCAL_SERVER_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function removeLocalServerMarker() {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(LOCAL_SERVER_MARKER_PATH, 'utf8');
|
||||
const marker = JSON.parse(raw);
|
||||
if (marker.pid && marker.pid !== process.pid) return;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsPromises.unlink(LOCAL_SERVER_MARKER_PATH);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn('[WARN] Could not remove local server marker:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
@@ -1589,6 +1751,9 @@ async function startServer() {
|
||||
|
||||
server.listen(SERVER_PORT, HOST, async () => {
|
||||
const appInstallPath = APP_ROOT;
|
||||
await writeLocalServerMarker().catch((error) => {
|
||||
console.warn('[WARN] Could not write local server marker:', error.message);
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(c.dim('═'.repeat(63)));
|
||||
@@ -1611,12 +1776,26 @@ async function startServer() {
|
||||
|
||||
await closeSessionsWatcher();
|
||||
// Clean up plugin processes on shutdown
|
||||
const shutdownPlugins = async () => {
|
||||
await stopAllPlugins();
|
||||
const shutdownRuntimeServices = async () => {
|
||||
try {
|
||||
await browserUseService.stopAllSessions();
|
||||
} catch (err) {
|
||||
console.error('[Browser] Error stopping sessions during shutdown:', err?.message || err);
|
||||
}
|
||||
try {
|
||||
await stopAllPlugins();
|
||||
} catch (err) {
|
||||
console.error('[Plugins] Error stopping plugins during shutdown:', err?.message || err);
|
||||
}
|
||||
try {
|
||||
await removeLocalServerMarker();
|
||||
} catch (err) {
|
||||
console.error('[Local Server] Error removing server marker during shutdown:', err?.message || err);
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdownPlugins());
|
||||
process.on('SIGINT', () => void shutdownPlugins());
|
||||
process.on('SIGTERM', () => void shutdownRuntimeServices());
|
||||
process.on('SIGINT', () => void shutdownRuntimeServices());
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -22,7 +22,7 @@ try {
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
console.error('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
// Keep the default database in a stable user-level location so rebuilding dist-server
|
||||
|
||||
120
server/modules/browser-use/browser-use-mcp.routes.ts
Normal file
120
server/modules/browser-use/browser-use-mcp.routes.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import express from 'express';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readBearerToken(header: unknown): string | null {
|
||||
if (typeof header !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const expected = browserUseService.getMcpToken();
|
||||
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
|
||||
if (!token || token !== expected) {
|
||||
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.post('/tools/:toolName', async (req, res) => {
|
||||
try {
|
||||
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
|
||||
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
|
||||
const toolName = req.params.toolName;
|
||||
let result: unknown;
|
||||
|
||||
switch (toolName) {
|
||||
case 'browser_create_session':
|
||||
result = await browserUseService.createAgentSession({
|
||||
profileName: typeof input.profileName === 'string' ? input.profileName : null,
|
||||
});
|
||||
break;
|
||||
case 'browser_list_sessions':
|
||||
result = await browserUseService.listAgentSessions();
|
||||
break;
|
||||
case 'browser_snapshot':
|
||||
case 'browser_take_screenshot':
|
||||
result = await browserUseService.agentSnapshot(sessionId);
|
||||
break;
|
||||
case 'browser_navigate':
|
||||
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
|
||||
break;
|
||||
case 'browser_click':
|
||||
result = await browserUseService.agentClick(sessionId, {
|
||||
selector: typeof input.selector === 'string' ? input.selector : undefined,
|
||||
text: typeof input.text === 'string' ? input.text : undefined,
|
||||
x: typeof input.x === 'number' ? input.x : undefined,
|
||||
y: typeof input.y === 'number' ? input.y : undefined,
|
||||
});
|
||||
break;
|
||||
case 'browser_type':
|
||||
result = await browserUseService.agentType(sessionId, {
|
||||
selector: typeof input.selector === 'string' ? input.selector : undefined,
|
||||
text: String(input.text || ''),
|
||||
submit: input.submit === true,
|
||||
});
|
||||
break;
|
||||
case 'browser_fill_form':
|
||||
result = await browserUseService.agentFillForm(
|
||||
sessionId,
|
||||
Array.isArray(input.fields)
|
||||
? input.fields.map((field) => {
|
||||
const record = field as Record<string, unknown>;
|
||||
return {
|
||||
selector: String(record.selector || ''),
|
||||
value: String(record.value || ''),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
);
|
||||
break;
|
||||
case 'browser_press_key':
|
||||
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
|
||||
break;
|
||||
case 'browser_select_option':
|
||||
result = await browserUseService.agentSelectOption(
|
||||
sessionId,
|
||||
String(input.selector || ''),
|
||||
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
|
||||
);
|
||||
break;
|
||||
case 'browser_wait_for':
|
||||
result = await browserUseService.agentWaitFor(sessionId, {
|
||||
text: typeof input.text === 'string' ? input.text : undefined,
|
||||
url: typeof input.url === 'string' ? input.url : undefined,
|
||||
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
|
||||
});
|
||||
break;
|
||||
case 'browser_tabs':
|
||||
result = await browserUseService.agentTabs(sessionId, {
|
||||
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
|
||||
? input.action
|
||||
: undefined,
|
||||
index: typeof input.index === 'number' ? input.index : undefined,
|
||||
url: typeof input.url === 'string' ? input.url : undefined,
|
||||
});
|
||||
break;
|
||||
case 'browser_close_session':
|
||||
result = await browserUseService.agentStopSession(sessionId);
|
||||
break;
|
||||
default:
|
||||
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
96
server/modules/browser-use/browser-use.routes.ts
Normal file
96
server/modules/browser-use/browser-use.routes.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import express from 'express';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readParam(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] || '' : value || '';
|
||||
}
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await browserUseService.getStatus() });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/settings', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await browserUseService.updateSettings(req.body || {});
|
||||
res.json({ success: true, data: { settings } });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/runtime/install', async (_req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.installRuntime();
|
||||
res.status(result.success ? 200 : 500).json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
error: result.success ? undefined : result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
836
server/modules/browser-use/browser-use.service.ts
Normal file
836
server/modules/browser-use/browser-use.service.ts
Normal file
@@ -0,0 +1,836 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { appConfigDb } from '@/modules/database/index.js';
|
||||
import { providerMcpService } from '@/modules/providers/index.js';
|
||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = getModuleDir(import.meta.url);
|
||||
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
||||
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
||||
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
|
||||
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
|
||||
|
||||
type BrowserUseRuntime = 'cloud' | 'local';
|
||||
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
||||
|
||||
type BrowserUseSession = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
createdBy: 'agent';
|
||||
runtime: BrowserUseRuntime;
|
||||
status: BrowserUseSessionStatus;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
screenshotDataUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastAction: string | null;
|
||||
message: string | null;
|
||||
profileName: string | null;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
cursor: {
|
||||
x: number;
|
||||
y: number;
|
||||
actor: 'agent';
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
||||
|
||||
type RuntimeHandle = {
|
||||
browser?: any;
|
||||
context?: any;
|
||||
page?: any;
|
||||
};
|
||||
|
||||
type BrowserUseSettings = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type RuntimeReadiness = {
|
||||
playwright: any | null;
|
||||
playwrightInstalled: boolean;
|
||||
chromiumInstalled: boolean;
|
||||
chromiumExecutablePath: string | null;
|
||||
installInProgress: boolean;
|
||||
installMessage: string | null;
|
||||
};
|
||||
|
||||
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
|
||||
|
||||
const sessions = new Map<string, BrowserUseSession>();
|
||||
const handles = new Map<string, RuntimeHandle>();
|
||||
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
|
||||
let lastInstallMessage: string | null = null;
|
||||
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
|
||||
|
||||
const DEFAULT_SETTINGS: BrowserUseSettings = {
|
||||
enabled: false,
|
||||
};
|
||||
const AGENT_OWNER_ID = 'agent';
|
||||
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
|
||||
const MCP_SERVER_NAME = 'cloudcli-browser';
|
||||
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
|
||||
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
function getRuntime(): BrowserUseRuntime {
|
||||
return IS_PLATFORM ? 'cloud' : 'local';
|
||||
}
|
||||
|
||||
function readSettings(): BrowserUseSettings {
|
||||
try {
|
||||
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
|
||||
return {
|
||||
enabled: parsed.enabled === true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.warn('[Browser] Failed to read settings:', error?.message || error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
|
||||
const normalized = {
|
||||
enabled: settings.enabled === true,
|
||||
};
|
||||
|
||||
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getOrCreateMcpToken(): string {
|
||||
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const token = randomBytes(32).toString('hex');
|
||||
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
|
||||
if (!settings.enabled) {
|
||||
return 'Browser is disabled in settings.';
|
||||
}
|
||||
|
||||
if (!readiness.playwrightInstalled) {
|
||||
return 'Install Playwright and Chromium to use browser sessions.';
|
||||
}
|
||||
|
||||
if (!readiness.chromiumInstalled) {
|
||||
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
|
||||
}
|
||||
|
||||
return readiness.installMessage || 'Browser runtime is not ready.';
|
||||
}
|
||||
|
||||
function getPlaywright(): any | null {
|
||||
try {
|
||||
return require('playwright');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMcpCommand(): { command: string; args: string[] } {
|
||||
const serverDir = path.resolve(__dirname, '..', '..');
|
||||
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
|
||||
if (fs.existsSync(mcpScriptPath)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [mcpScriptPath],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'cloudcli',
|
||||
args: ['browser-use-mcp'],
|
||||
};
|
||||
}
|
||||
|
||||
function getMcpApiUrl(): string {
|
||||
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
|
||||
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
|
||||
}
|
||||
|
||||
async function removeMcpServerFromAllProviders(name: string) {
|
||||
const results = await providerMcpService.removeMcpServerFromAllProviders({
|
||||
name,
|
||||
scope: 'user',
|
||||
});
|
||||
return results.map((result) => ({ ...result, name }));
|
||||
}
|
||||
|
||||
function normalizeProfileName(profileName?: string | null): string | null {
|
||||
const normalized = String(profileName || '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.slice(0, 80);
|
||||
}
|
||||
|
||||
function getProfilePath(profileName: string): string {
|
||||
const safeName = profileName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'default';
|
||||
return path.join(PROFILE_ROOT, safeName);
|
||||
}
|
||||
|
||||
function probeRuntime(): RuntimeProbe {
|
||||
const playwright = getPlaywright();
|
||||
const readiness: RuntimeProbe = {
|
||||
playwright,
|
||||
playwrightInstalled: Boolean(playwright),
|
||||
chromiumInstalled: false,
|
||||
chromiumExecutablePath: null,
|
||||
};
|
||||
|
||||
if (!playwright) {
|
||||
return readiness;
|
||||
}
|
||||
|
||||
try {
|
||||
const executablePath = playwright.chromium.executablePath();
|
||||
readiness.chromiumExecutablePath = executablePath;
|
||||
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
|
||||
} catch {
|
||||
readiness.chromiumInstalled = false;
|
||||
}
|
||||
|
||||
return readiness;
|
||||
}
|
||||
|
||||
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
|
||||
const now = Date.now();
|
||||
const cachedProbe = runtimeProbeCache;
|
||||
const canUseCache = !options.force
|
||||
&& !installPromise
|
||||
&& cachedProbe
|
||||
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
|
||||
const probe = canUseCache ? cachedProbe.value : probeRuntime();
|
||||
|
||||
if (!canUseCache && !installPromise) {
|
||||
runtimeProbeCache = { value: probe, updatedAt: now };
|
||||
}
|
||||
|
||||
return {
|
||||
...probe,
|
||||
installInProgress: Boolean(installPromise),
|
||||
installMessage: lastInstallMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
|
||||
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
|
||||
10,
|
||||
);
|
||||
|
||||
function runCommand(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const output: string[] = [];
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish(() => reject(new Error(
|
||||
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
|
||||
)));
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.on('error', (error) => finish(() => reject(error)));
|
||||
child.on('close', (code) => finish(() => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function formatInstallError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('sudo') && message.includes('password')) {
|
||||
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
|
||||
}
|
||||
return message || 'Failed to install Browser runtime.';
|
||||
}
|
||||
|
||||
async function installRuntime(): Promise<{ success: boolean; message: string }> {
|
||||
if (installPromise) {
|
||||
return installPromise;
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
runtimeProbeCache = null;
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
lastInstallMessage = 'Installing Playwright package...';
|
||||
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
lastInstallMessage = 'Installing Chromium system dependencies...';
|
||||
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
|
||||
}
|
||||
|
||||
lastInstallMessage = 'Installing Chromium runtime...';
|
||||
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
|
||||
|
||||
lastInstallMessage = 'Browser runtime installed.';
|
||||
return { success: true, message: lastInstallMessage };
|
||||
} catch (error) {
|
||||
lastInstallMessage = formatInstallError(error);
|
||||
return { success: false, message: lastInstallMessage };
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await installPromise;
|
||||
} finally {
|
||||
installPromise = null;
|
||||
runtimeProbeCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('URL is required.');
|
||||
}
|
||||
|
||||
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error('Only http and https URLs are supported.');
|
||||
}
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
|
||||
const { ownerId: _ownerId, ...publicFields } = session;
|
||||
return publicFields;
|
||||
}
|
||||
|
||||
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
||||
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
||||
}
|
||||
|
||||
async function closeHandle(sessionId: string): Promise<void> {
|
||||
const handle = handles.get(sessionId);
|
||||
handles.delete(sessionId);
|
||||
await handle?.context?.close?.().catch(() => undefined);
|
||||
await handle?.browser?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
||||
await Promise.all([...sessions.values()].map(async (session) => {
|
||||
if (session.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedAt = Date.parse(session.updatedAt);
|
||||
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
await closeHandle(session.id);
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date(now).toISOString();
|
||||
session.lastAction = 'expire';
|
||||
session.message = 'Browser session expired after inactivity.';
|
||||
}));
|
||||
}
|
||||
|
||||
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
|
||||
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
|
||||
session.title = await page.title().catch(() => null);
|
||||
session.url = page.url() || session.url;
|
||||
session.viewport = page.viewportSize?.() || session.viewport;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
||||
if (typeof input.x === 'number' && typeof input.y === 'number') {
|
||||
return { x: input.x, y: input.y };
|
||||
}
|
||||
|
||||
const locator = input.selector
|
||||
? page.locator(input.selector).first()
|
||||
: input.text
|
||||
? page.getByText(input.text, { exact: false }).first()
|
||||
: null;
|
||||
|
||||
if (!locator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const box = await locator.boundingBox().catch(() => null);
|
||||
if (!box) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(box.x + box.width / 2),
|
||||
y: Math.round(box.y + box.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
export const browserUseService = {
|
||||
async getSettings() {
|
||||
return readSettings();
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<BrowserUseSettings>) {
|
||||
const current = readSettings();
|
||||
const nextSettings = {
|
||||
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
|
||||
};
|
||||
|
||||
const next = writeSettings(nextSettings);
|
||||
if (next.enabled) {
|
||||
await this.registerAgentMcp();
|
||||
} else if (current.enabled) {
|
||||
await this.unregisterAgentMcp();
|
||||
await this.stopAllSessions();
|
||||
}
|
||||
return next;
|
||||
},
|
||||
|
||||
async getStatus() {
|
||||
const settings = readSettings();
|
||||
const readiness = getRuntimeReadiness();
|
||||
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
runtime: getRuntime(),
|
||||
available,
|
||||
playwrightInstalled: readiness.playwrightInstalled,
|
||||
chromiumInstalled: readiness.chromiumInstalled,
|
||||
installInProgress: readiness.installInProgress,
|
||||
sessionCount: sessions.size,
|
||||
message: available
|
||||
? 'Browser runtime is available.'
|
||||
: getSetupMessage(settings, readiness),
|
||||
};
|
||||
},
|
||||
|
||||
async registerAgentMcp() {
|
||||
const { command, args } = getMcpCommand();
|
||||
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
|
||||
const results = await providerMcpService.addMcpServerToAllProviders({
|
||||
name: MCP_SERVER_NAME,
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command,
|
||||
args,
|
||||
env: {
|
||||
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
|
||||
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
|
||||
},
|
||||
});
|
||||
return { name: MCP_SERVER_NAME, command, args, results };
|
||||
},
|
||||
|
||||
getMcpToken() {
|
||||
return getOrCreateMcpToken();
|
||||
},
|
||||
|
||||
async unregisterAgentMcp() {
|
||||
const results = (await Promise.all(
|
||||
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
|
||||
)).flat();
|
||||
return { name: MCP_SERVER_NAME, results };
|
||||
},
|
||||
|
||||
async installRuntime() {
|
||||
const result = await installRuntime();
|
||||
return {
|
||||
...result,
|
||||
status: await this.getStatus(),
|
||||
};
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
await expireStaleSessions();
|
||||
return [...sessions.values()]
|
||||
.filter((session) => session.ownerId === AGENT_OWNER_ID)
|
||||
.map(publicSession);
|
||||
},
|
||||
|
||||
async createAgentSession(options?: { profileName?: string | null }) {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new Error('Browser agent tools are disabled.');
|
||||
}
|
||||
|
||||
await expireStaleSessions();
|
||||
const profileName = normalizeProfileName(options?.profileName);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const session: BrowserUseSession = {
|
||||
id: randomUUID(),
|
||||
ownerId: AGENT_OWNER_ID,
|
||||
createdBy: 'agent',
|
||||
runtime: getRuntime(),
|
||||
status: 'unavailable',
|
||||
url: null,
|
||||
title: null,
|
||||
screenshotDataUrl: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastAction: 'create',
|
||||
message: null,
|
||||
profileName,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
cursor: null,
|
||||
};
|
||||
|
||||
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
|
||||
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
||||
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
|
||||
}
|
||||
|
||||
const readiness = getRuntimeReadiness();
|
||||
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
|
||||
session.message = getSetupMessage(settings, readiness);
|
||||
sessions.set(session.id, session);
|
||||
return publicSession(session);
|
||||
}
|
||||
|
||||
let browser: any | undefined;
|
||||
let context: any | undefined;
|
||||
let page: any;
|
||||
const launchOptions = {
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
};
|
||||
const contextOptions = {
|
||||
viewport: { width: 1440, height: 900 },
|
||||
serviceWorkers: 'block',
|
||||
};
|
||||
|
||||
if (profileName) {
|
||||
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
|
||||
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
|
||||
...launchOptions,
|
||||
...contextOptions,
|
||||
});
|
||||
page = context.pages()[0] || await context.newPage();
|
||||
} else {
|
||||
browser = await readiness.playwright.chromium.launch(launchOptions);
|
||||
context = await browser.newContext(contextOptions);
|
||||
page = await context.newPage();
|
||||
}
|
||||
session.status = 'ready';
|
||||
session.message = 'Browser session is ready.';
|
||||
sessions.set(session.id, session);
|
||||
handles.set(session.id, { browser, context, page });
|
||||
await captureSession(session, page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async listAgentSessions() {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
return [];
|
||||
}
|
||||
await expireStaleSessions();
|
||||
return [...sessions.values()]
|
||||
.filter((session) => session.ownerId === AGENT_OWNER_ID)
|
||||
.map(publicSession);
|
||||
},
|
||||
|
||||
async getAgentSession(sessionId: string) {
|
||||
const settings = readSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new Error('Browser agent tools are disabled.');
|
||||
}
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
throw new Error('Browser session not found.');
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
async agentNavigate(sessionId: string, rawUrl: string) {
|
||||
await this.getAgentSession(sessionId);
|
||||
await expireStaleSessions();
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
throw new Error('Browser session not found.');
|
||||
}
|
||||
|
||||
if (session.status !== 'ready') {
|
||||
throw new Error(session.message || 'Browser session is not available.');
|
||||
}
|
||||
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
|
||||
const url = normalizeUrl(rawUrl);
|
||||
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
session.lastAction = `navigate:${url}`;
|
||||
session.cursor = null;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentSnapshot(sessionId: string) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
await captureSession(session, handle.page);
|
||||
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
|
||||
return {
|
||||
session: publicSession(session),
|
||||
text: text.slice(0, 30_000),
|
||||
};
|
||||
},
|
||||
|
||||
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
const point = await getActionPoint(handle.page, input);
|
||||
|
||||
if (input.selector) {
|
||||
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
|
||||
} else if (input.text) {
|
||||
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
|
||||
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
|
||||
await handle.page.mouse.click(input.x, input.y);
|
||||
} else {
|
||||
throw new Error('Provide selector, text, or x/y coordinates.');
|
||||
}
|
||||
|
||||
session.lastAction = 'click';
|
||||
session.cursor = point ? { ...point, actor: 'agent' } : null;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
|
||||
if (input.selector) {
|
||||
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
|
||||
session.cursor = await getActionPoint(handle.page, input).then((point) => (
|
||||
point ? { ...point, actor: 'agent' as const } : null
|
||||
));
|
||||
} else {
|
||||
await handle.page.keyboard.type(input.text);
|
||||
}
|
||||
if (input.submit) {
|
||||
await handle.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
session.lastAction = 'type';
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
for (const field of fields) {
|
||||
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
|
||||
}
|
||||
session.lastAction = 'fill_form';
|
||||
if (fields[0]) {
|
||||
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
|
||||
point ? { ...point, actor: 'agent' as const } : null
|
||||
));
|
||||
}
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentPressKey(sessionId: string, key: string) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
await handle.page.keyboard.press(key);
|
||||
session.lastAction = `press_key:${key}`;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
|
||||
session.lastAction = 'select_option';
|
||||
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
|
||||
point ? { ...point, actor: 'agent' as const } : null
|
||||
));
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
|
||||
if (input.text) {
|
||||
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
|
||||
} else if (input.url) {
|
||||
await handle.page.waitForURL(input.url, { timeout });
|
||||
} else {
|
||||
await handle.page.waitForTimeout(timeout);
|
||||
}
|
||||
session.lastAction = 'wait_for';
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
|
||||
const session = await this.getAgentSession(sessionId);
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.context || !handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
const action = input.action || 'list';
|
||||
if (action === 'new') {
|
||||
const page = await handle.context.newPage();
|
||||
handles.set(sessionId, { ...handle, page });
|
||||
if (input.url) {
|
||||
await this.agentNavigate(sessionId, input.url);
|
||||
}
|
||||
} else if (action === 'select') {
|
||||
const page = handle.context.pages()[input.index || 0];
|
||||
if (!page) {
|
||||
throw new Error('Tab not found.');
|
||||
}
|
||||
handles.set(sessionId, { ...handle, page });
|
||||
} else if (action === 'close') {
|
||||
const pages = handle.context.pages();
|
||||
const page = pages[input.index ?? pages.indexOf(handle.page)];
|
||||
if (!page) {
|
||||
throw new Error('Tab not found.');
|
||||
}
|
||||
await page.close();
|
||||
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
|
||||
}
|
||||
const updatedHandle = handles.get(sessionId);
|
||||
await captureSession(session, updatedHandle?.page || handle.page);
|
||||
return {
|
||||
session: publicSession(session),
|
||||
tabs: handle.context.pages().map((page: any, index: number) => ({
|
||||
index,
|
||||
url: page.url(),
|
||||
active: page === (updatedHandle?.page || handle.page),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async stopSession(sessionId: string) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
return { stopped: false };
|
||||
}
|
||||
|
||||
await closeHandle(sessionId);
|
||||
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = 'stop';
|
||||
session.message = 'Browser session stopped. Create a new session to continue browsing.';
|
||||
return { stopped: true, session: publicSession(session) };
|
||||
},
|
||||
|
||||
async deleteSession(sessionId: string) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== AGENT_OWNER_ID) {
|
||||
return { deleted: false };
|
||||
}
|
||||
|
||||
await closeHandle(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
return { deleted: true, sessionId };
|
||||
},
|
||||
|
||||
async agentStopSession(sessionId: string) {
|
||||
await this.getAgentSession(sessionId);
|
||||
return this.stopSession(sessionId);
|
||||
},
|
||||
|
||||
async stopAllSessions() {
|
||||
await Promise.all([...sessions.keys()].map(async (sessionId) => {
|
||||
await closeHandle(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = 'shutdown';
|
||||
session.message = 'Browser session stopped during server shutdown.';
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
process.once('beforeExit', () => {
|
||||
void browserUseService.stopAllSessions();
|
||||
});
|
||||
10
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
10
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
test('browser monitor list starts empty without agent sessions', async () => {
|
||||
const sessions = await browserUseService.listSessions();
|
||||
|
||||
assert.deepEqual(sessions, []);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
|
||||
export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js';
|
||||
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
||||
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3';
|
||||
import {
|
||||
APP_CONFIG_TABLE_SCHEMA_SQL,
|
||||
LAST_SCANNED_AT_SQL,
|
||||
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
|
||||
PROJECTS_TABLE_SCHEMA_SQL,
|
||||
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
||||
SESSIONS_TABLE_SCHEMA_SQL,
|
||||
@@ -382,6 +383,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the `provider_session_id` mapping column used by the session gateway.
|
||||
*
|
||||
* Rows that existed before this migration were always keyed directly by the
|
||||
* provider-native session id, so backfilling `provider_session_id` with
|
||||
* `session_id` keeps every legacy row resolvable through the new mapping.
|
||||
*/
|
||||
const addProviderSessionIdMapping = (db: Database): void => {
|
||||
const sessionsTableInfo = getTableInfo(db, 'sessions');
|
||||
const columnNames = sessionsTableInfo.map((column) => column.name);
|
||||
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
|
||||
db.exec(`
|
||||
UPDATE sessions
|
||||
SET provider_session_id = session_id
|
||||
WHERE provider_session_id IS NULL
|
||||
`);
|
||||
};
|
||||
|
||||
const ensureProjectsForSessionPaths = (db: Database): void => {
|
||||
if (!tableExists(db, 'sessions')) {
|
||||
return;
|
||||
@@ -421,6 +441,9 @@ export const runMigrations = (db: Database) => {
|
||||
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
||||
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
|
||||
db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)');
|
||||
|
||||
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||
rebuildProjectsTableWithPrimaryKeySchema(db);
|
||||
@@ -428,9 +451,11 @@ export const runMigrations = (db: Database) => {
|
||||
migrateLegacyWorkspaceTableIntoProjects(db);
|
||||
rebuildSessionsTableWithProjectSchema(db);
|
||||
migrateLegacySessionNames(db);
|
||||
addProviderSessionIdMapping(db);
|
||||
ensureProjectsForSessionPaths(db);
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type NotificationChannelEndpointRow = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
channel: string;
|
||||
endpoint_id: string;
|
||||
label: string | null;
|
||||
metadata_json: string | null;
|
||||
enabled: number;
|
||||
last_seen_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type UpsertNotificationChannelEndpointInput = {
|
||||
userId: number;
|
||||
channel: string;
|
||||
endpointId: string;
|
||||
label?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function normalizeRequiredText(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeNullableText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!metadata || typeof metadata !== 'object') return null;
|
||||
return JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
|
||||
if (!metadataJson) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(metadataJson);
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationChannelEndpointsDb = {
|
||||
upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
|
||||
const channel = normalizeRequiredText(input.channel);
|
||||
const endpointId = normalizeRequiredText(input.endpointId);
|
||||
if (!channel) throw new Error('channel is required');
|
||||
if (!endpointId) throw new Error('endpointId is required');
|
||||
|
||||
const enabled = input.enabled === false ? 0 : 1;
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`INSERT INTO notification_channel_endpoints (
|
||||
user_id,
|
||||
channel,
|
||||
endpoint_id,
|
||||
label,
|
||||
metadata_json,
|
||||
enabled,
|
||||
last_seen_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
metadata_json = excluded.metadata_json,
|
||||
enabled = excluded.enabled,
|
||||
last_seen_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(
|
||||
input.userId,
|
||||
channel,
|
||||
endpointId,
|
||||
normalizeNullableText(input.label),
|
||||
serializeMetadata(input.metadata),
|
||||
enabled
|
||||
);
|
||||
|
||||
return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
|
||||
},
|
||||
|
||||
getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
|
||||
const db = getConnection();
|
||||
const row = db.prepare(
|
||||
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||
FROM notification_channel_endpoints
|
||||
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||
).get(
|
||||
userId,
|
||||
normalizeRequiredText(channel),
|
||||
normalizeRequiredText(endpointId)
|
||||
) as NotificationChannelEndpointRow | undefined;
|
||||
return row || null;
|
||||
},
|
||||
|
||||
getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(
|
||||
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||
FROM notification_channel_endpoints
|
||||
WHERE user_id = ? AND channel = ?
|
||||
ORDER BY last_seen_at DESC`
|
||||
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
|
||||
},
|
||||
|
||||
getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(
|
||||
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
|
||||
FROM notification_channel_endpoints
|
||||
WHERE user_id = ? AND channel = ? AND enabled = 1
|
||||
ORDER BY last_seen_at DESC`
|
||||
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
|
||||
},
|
||||
|
||||
setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
|
||||
const db = getConnection();
|
||||
const result = db.prepare(
|
||||
`UPDATE notification_channel_endpoints
|
||||
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||
).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
|
||||
const db = getConnection();
|
||||
const result = db.prepare(
|
||||
`UPDATE notification_channel_endpoints
|
||||
SET last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
|
||||
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
|
||||
const db = getConnection();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
|
||||
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
parseMetadata,
|
||||
};
|
||||
@@ -10,6 +10,9 @@ type NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
desktop: boolean;
|
||||
sound: boolean;
|
||||
[key: string]: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
@@ -22,6 +25,8 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false,
|
||||
desktop: false,
|
||||
sound: true,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
@@ -32,11 +37,21 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
|
||||
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
||||
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
||||
const sourceChannels = source.channels && typeof source.channels === 'object'
|
||||
? source.channels as Record<string, unknown>
|
||||
: {};
|
||||
const extraChannels = Object.fromEntries(
|
||||
Object.entries(sourceChannels)
|
||||
.filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
|
||||
) as Record<string, boolean>;
|
||||
|
||||
return {
|
||||
channels: {
|
||||
...extraChannels,
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true,
|
||||
desktop: source.channels?.desktop === true,
|
||||
sound: source.channels?.sound !== false,
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
@@ -100,4 +115,3 @@ export const notificationPreferencesDb = {
|
||||
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js';
|
||||
type SessionRow = {
|
||||
session_id: string;
|
||||
provider: string;
|
||||
provider_session_id: string | null;
|
||||
project_path: string | null;
|
||||
jsonl_path: string | null;
|
||||
custom_name: string | null;
|
||||
@@ -13,15 +14,22 @@ type SessionRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type SessionMetadataLookupRow = Pick<
|
||||
SessionRow,
|
||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at'
|
||||
>;
|
||||
const SESSION_ROW_COLUMNS =
|
||||
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
|
||||
|
||||
const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
|
||||
function normalizeTimestamp(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
const parsed = new Date(value);
|
||||
// SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
|
||||
// Normalize it here so every session reader returns canonical ISO strings
|
||||
// and the sidebar never interprets fresh rows as local-time "hours old".
|
||||
const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
|
||||
? `${value.replace(' ', 'T')}Z`
|
||||
: value;
|
||||
|
||||
const parsed = new Date(normalizedValue);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
@@ -29,14 +37,38 @@ function normalizeTimestamp(value?: string): string | null {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
|
||||
if (!row) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
|
||||
updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
|
||||
return rows.map((row) => normalizeSessionRow(row) as SessionRow);
|
||||
}
|
||||
|
||||
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
||||
void provider;
|
||||
return normalizeProjectPath(projectPath);
|
||||
}
|
||||
|
||||
export const sessionsDb = {
|
||||
/**
|
||||
* Upserts one session row discovered on disk by a provider synchronizer.
|
||||
*
|
||||
* The given id is the provider-native session id. Rows are keyed by
|
||||
* `provider_session_id` so a session that was first created by the app
|
||||
* (with an app-allocated `session_id`) is updated in place once its
|
||||
* transcript shows up on disk, instead of producing a duplicate row.
|
||||
*/
|
||||
createSession(
|
||||
sessionId: string,
|
||||
providerSessionId: string,
|
||||
provider: string,
|
||||
projectPath: string,
|
||||
customName?: string,
|
||||
@@ -53,19 +85,54 @@ export const sessionsDb = {
|
||||
// since it's a foreign key in the sessions table.
|
||||
projectsDb.createProjectPath(normalizedProjectPath);
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT session_id FROM sessions
|
||||
WHERE provider_session_id = ? AND provider = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(providerSessionId, provider) as { session_id: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE sessions SET
|
||||
provider = ?,
|
||||
updated_at = COALESCE(?, CURRENT_TIMESTAMP),
|
||||
project_path = ?,
|
||||
jsonl_path = ?,
|
||||
isArchived = 0,
|
||||
custom_name = COALESCE(?, custom_name)
|
||||
WHERE session_id = ?`
|
||||
).run(
|
||||
provider,
|
||||
updatedAtValue,
|
||||
normalizedProjectPath,
|
||||
jsonlPath ?? null,
|
||||
customName ?? null,
|
||||
existing.session_id
|
||||
);
|
||||
|
||||
return existing.session_id;
|
||||
}
|
||||
|
||||
// Sessions created outside the app (directly via the provider CLI) are
|
||||
// keyed by the provider-native id for both columns. The ON CONFLICT path
|
||||
// covers legacy rows that predate the provider_session_id mapping.
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
provider_session_id = excluded.provider_session_id,
|
||||
updated_at = excluded.updated_at,
|
||||
project_path = excluded.project_path,
|
||||
jsonl_path = excluded.jsonl_path,
|
||||
isArchived = 0,
|
||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||
).run(
|
||||
sessionId,
|
||||
providerSessionId,
|
||||
provider,
|
||||
providerSessionId,
|
||||
customName ?? null,
|
||||
normalizedProjectPath,
|
||||
jsonlPath ?? null,
|
||||
@@ -73,9 +140,77 @@ export const sessionsDb = {
|
||||
updatedAtValue
|
||||
);
|
||||
|
||||
return providerSessionId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Inserts one app-allocated session row before any provider run happens.
|
||||
*
|
||||
* The session gateway uses this when the frontend starts a brand-new chat:
|
||||
* `session_id` is the stable app-facing id, while `provider_session_id`
|
||||
* stays NULL until the provider runtime announces its own id and
|
||||
* `assignProviderSessionId` records the mapping.
|
||||
*/
|
||||
createAppSession(sessionId: string, provider: string, projectPath: string): string {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||
|
||||
projectsDb.createProjectPath(normalizedProjectPath);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
|
||||
).run(sessionId, provider, normalizedProjectPath);
|
||||
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Records the provider-native session id for one app-allocated session.
|
||||
*
|
||||
* If the filesystem watcher indexed the provider transcript before this
|
||||
* mapping was recorded (a duplicate row keyed by the provider id exists),
|
||||
* the duplicate is merged into the app row: its transcript path and name
|
||||
* are adopted and the duplicate row is removed. Runs in a transaction so
|
||||
* the sidebar can never observe both rows at once.
|
||||
*/
|
||||
assignProviderSessionId(sessionId: string, providerSessionId: string): void {
|
||||
const db = getConnection();
|
||||
|
||||
const merge = db.transaction(() => {
|
||||
const duplicate = db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS} FROM sessions
|
||||
WHERE (session_id = ? OR provider_session_id = ?)
|
||||
AND session_id <> ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined;
|
||||
|
||||
if (duplicate) {
|
||||
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id);
|
||||
db.prepare(
|
||||
`UPDATE sessions SET
|
||||
provider_session_id = ?,
|
||||
jsonl_path = COALESCE(jsonl_path, ?),
|
||||
custom_name = COALESCE(custom_name, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ?`
|
||||
).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE sessions SET
|
||||
provider_session_id = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ?`
|
||||
).run(providerSessionId, sessionId);
|
||||
});
|
||||
|
||||
merge();
|
||||
},
|
||||
|
||||
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
@@ -85,30 +220,91 @@ export const sessionsDb = {
|
||||
).run(customName, sessionId);
|
||||
},
|
||||
|
||||
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
||||
getSessionById(sessionId: string): SessionRow | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE session_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
||||
.get(sessionId) as SessionRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
return normalizeSessionRow(row) ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves one session row through the provider-native id.
|
||||
*
|
||||
* The filesystem watcher only knows provider ids (they come from transcript
|
||||
* file names), so it uses this lookup to translate disk artifacts back to
|
||||
* the app-facing session row before broadcasting sidebar updates.
|
||||
*/
|
||||
getSessionByProviderSessionId(providerSessionId: string): SessionRow | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE provider_session_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(providerSessionId) as SessionRow | undefined;
|
||||
|
||||
return normalizeSessionRow(row) ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds the newest app-created session for a project that is still waiting
|
||||
* for its provider-native id to be recorded.
|
||||
*
|
||||
* Primary intention: OpenCode can expose a new session in its shared
|
||||
* `opencode.db` before the websocket runtime reports that same provider id
|
||||
* back to our app. At that moment the sidebar already has an optimistic
|
||||
* app-owned session row, but the watcher only knows the provider-native id.
|
||||
*
|
||||
* Without this lookup, the synchronizer would insert a second row keyed by
|
||||
* the provider id, then `assignProviderSessionId()` would merge it a moment
|
||||
* later. That eventually self-heals, but on slow networks the user can still
|
||||
* briefly see two sidebar sessions for the same conversation.
|
||||
*
|
||||
* This helper lets the synchronizer claim the pending app row first, so the
|
||||
* provider id is attached before any watcher-created row exists. The result
|
||||
* is simpler than frontend dedupe and keeps the race resolved at the source.
|
||||
*/
|
||||
findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE provider = ?
|
||||
AND project_path = ?
|
||||
AND provider_session_id IS NULL
|
||||
AND isArchived = 0
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(provider, normalizedProjectPath) as SessionRow | undefined;
|
||||
|
||||
return normalizeSessionRow(row) ?? null;
|
||||
},
|
||||
|
||||
getAllSessions(): SessionRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE isArchived = 0`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -117,27 +313,31 @@ export const sessionsDb = {
|
||||
*/
|
||||
getArchivedSessions(): SessionRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE isArchived = 1
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
getSessionsByProjectPath(projectPath: string): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0`
|
||||
)
|
||||
.all(normalizedProjectPath) as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -147,21 +347,23 @@ export const sessionsDb = {
|
||||
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
)
|
||||
.all(normalizedProjectPath) as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
AND isArchived = 0
|
||||
@@ -169,6 +371,8 @@ export const sessionsDb = {
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
countSessionsByProjectPath(projectPath: string): number {
|
||||
|
||||
@@ -69,6 +69,23 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
);
|
||||
`;
|
||||
|
||||
export const NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS notification_channel_endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
label TEXT,
|
||||
metadata_json TEXT,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, channel, endpoint_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -83,6 +100,12 @@ export const SESSIONS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
-- The session id used by the provider CLI/SDK on disk (JSONL file name,
|
||||
-- store.db folder, sqlite row id, ...). \`session_id\` is the stable
|
||||
-- app-facing id that the frontend uses for the whole session lifetime;
|
||||
-- \`provider_session_id\` is filled in once the provider announces its own
|
||||
-- id mid-run, or equals \`session_id\` for sessions discovered on disk.
|
||||
provider_session_id TEXT,
|
||||
custom_name TEXT,
|
||||
project_path TEXT,
|
||||
jsonl_path TEXT,
|
||||
@@ -138,6 +161,10 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL}
|
||||
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
|
||||
${NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled);
|
||||
|
||||
${PROJECTS_TABLE_SCHEMA_SQL}
|
||||
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
||||
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
||||
|
||||
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
108
server/modules/database/tests/sessions-provider-mapping.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeConnection } from '@/modules/database/connection.js';
|
||||
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
import { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('disk-discovered sessions are keyed by the provider id for both columns', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk');
|
||||
|
||||
const row = sessionsDb.getSessionById('provider-abc');
|
||||
assert.equal(row?.session_id, 'provider-abc');
|
||||
assert.equal(row?.provider_session_id, 'provider-abc');
|
||||
|
||||
const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc');
|
||||
assert.equal(byProviderId?.session_id, 'provider-abc');
|
||||
});
|
||||
});
|
||||
|
||||
test('app sessions get the provider id assigned without creating a duplicate row', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo');
|
||||
sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz');
|
||||
|
||||
// A later synchronizer pass that discovers the transcript on disk must
|
||||
// update the app row in place instead of inserting a provider-keyed row.
|
||||
const returnedId = sessionsDb.createSession(
|
||||
'provider-xyz',
|
||||
'claude',
|
||||
'/workspace/demo',
|
||||
'Synced Name',
|
||||
undefined,
|
||||
undefined,
|
||||
'/fake/path/provider-xyz.jsonl',
|
||||
);
|
||||
|
||||
assert.equal(returnedId, 'app-id-1');
|
||||
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||
|
||||
const row = sessionsDb.getSessionById('app-id-1');
|
||||
assert.equal(row?.provider_session_id, 'provider-xyz');
|
||||
assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl');
|
||||
});
|
||||
});
|
||||
|
||||
test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo');
|
||||
|
||||
// Simulate the race: the filesystem watcher indexed the provider
|
||||
// transcript before the runtime announced its session id to the gateway.
|
||||
sessionsDb.createSession(
|
||||
'provider-race',
|
||||
'codex',
|
||||
'/workspace/demo',
|
||||
'Watcher Name',
|
||||
undefined,
|
||||
undefined,
|
||||
'/fake/provider-race.jsonl',
|
||||
);
|
||||
assert.equal(sessionsDb.getAllSessions().length, 2);
|
||||
|
||||
sessionsDb.assignProviderSessionId('app-id-2', 'provider-race');
|
||||
|
||||
const rows = sessionsDb.getAllSessions();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0]?.session_id, 'app-id-2');
|
||||
assert.equal(rows[0]?.provider_session_id, 'provider-race');
|
||||
// Transcript path and name from the duplicate are adopted.
|
||||
assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl');
|
||||
assert.equal(rows[0]?.custom_name, 'Watcher Name');
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
|
||||
|
||||
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
|
||||
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
|
||||
});
|
||||
});
|
||||
@@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag
|
||||
assert.equal(restoredSession?.isArchived, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project');
|
||||
|
||||
const row = sessionsDb.getSessionById('session-timezone');
|
||||
assert.ok(row?.created_at.endsWith('Z'));
|
||||
assert.ok(row?.updated_at.endsWith('Z'));
|
||||
assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
13
server/modules/notifications/index.ts
Normal file
13
server/modules/notifications/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
buildNotificationPayload,
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled,
|
||||
notifyRunFailed,
|
||||
notifyRunStopped,
|
||||
} from '@/modules/notifications/services/notification-orchestrator.service.js';
|
||||
export {
|
||||
registerDesktopNotificationClient,
|
||||
sendDesktopNotification,
|
||||
unregisterDesktopNotificationClient,
|
||||
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||
export { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js';
|
||||
127
server/modules/notifications/notifications.routes.ts
Normal file
127
server/modules/notifications/notifications.routes.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import express from 'express';
|
||||
|
||||
import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function readText(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function sanitizeEndpoint(endpoint: any) {
|
||||
return {
|
||||
id: endpoint.id,
|
||||
channel: endpoint.channel,
|
||||
endpointId: endpoint.endpoint_id,
|
||||
label: endpoint.label,
|
||||
metadata: notificationChannelEndpointsDb.parseMetadata(endpoint.metadata_json),
|
||||
enabled: Boolean(endpoint.enabled),
|
||||
lastSeenAt: endpoint.last_seen_at,
|
||||
createdAt: endpoint.created_at,
|
||||
updatedAt: endpoint.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function readUserId(req: express.Request): number {
|
||||
const userId = Number((req as any).user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
throw new Error('Authenticated user is missing');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function updateChannelPreference(userId: number, channel: string): unknown {
|
||||
const currentPrefs = notificationPreferencesDb.getPreferences(userId);
|
||||
const hasEnabledEndpoint = notificationChannelEndpointsDb.getEnabledEndpoints(userId, channel).length > 0;
|
||||
return notificationPreferencesDb.updatePreferences(userId, {
|
||||
...currentPrefs,
|
||||
channels: { ...currentPrefs.channels, [channel]: hasEnabledEndpoint },
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/endpoints', (req, res) => {
|
||||
try {
|
||||
const channel = readText(req.query.channel);
|
||||
if (!channel) {
|
||||
return res.status(400).json({ error: 'channel is required' });
|
||||
}
|
||||
|
||||
const userId = readUserId(req);
|
||||
const endpoints = notificationChannelEndpointsDb
|
||||
.getEndpoints(userId, channel)
|
||||
.map(sanitizeEndpoint);
|
||||
return res.json({ success: true, endpoints });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification endpoints:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch notification endpoints' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/endpoints/current', (req, res) => {
|
||||
try {
|
||||
const { channel, endpointId, label, metadata = {}, enabled = true } = req.body || {};
|
||||
const normalizedChannel = readText(channel);
|
||||
const normalizedEndpointId = readText(endpointId);
|
||||
if (!normalizedChannel || !normalizedEndpointId) {
|
||||
return res.status(400).json({ error: 'channel and endpointId are required' });
|
||||
}
|
||||
|
||||
const userId = readUserId(req);
|
||||
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
|
||||
userId,
|
||||
channel: normalizedChannel,
|
||||
endpointId: normalizedEndpointId,
|
||||
label,
|
||||
metadata: metadata && typeof metadata === 'object' ? metadata : {},
|
||||
enabled: enabled !== false,
|
||||
});
|
||||
|
||||
const preferences = updateChannelPreference(userId, normalizedChannel);
|
||||
return res.json({ success: true, endpoint: sanitizeEndpoint(endpoint), preferences });
|
||||
} catch (error) {
|
||||
console.error('Error registering notification endpoint:', error);
|
||||
return res.status(500).json({ error: 'Failed to register notification endpoint' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/endpoints/:channel/:endpointId', (req, res) => {
|
||||
try {
|
||||
const { channel, endpointId } = req.params;
|
||||
const { enabled } = req.body || {};
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
}
|
||||
|
||||
const userId = readUserId(req);
|
||||
const updated = notificationChannelEndpointsDb.setEndpointEnabled(userId, channel, endpointId, enabled);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Notification endpoint not found' });
|
||||
}
|
||||
|
||||
const endpoint = notificationChannelEndpointsDb.getEndpoint(userId, channel, endpointId);
|
||||
const preferences = updateChannelPreference(userId, channel);
|
||||
return res.json({ success: true, endpoint: endpoint ? sanitizeEndpoint(endpoint) : null, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error updating notification endpoint:', error);
|
||||
return res.status(500).json({ error: 'Failed to update notification endpoint' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/endpoints/:channel/:endpointId', (req, res) => {
|
||||
try {
|
||||
const { channel, endpointId } = req.params;
|
||||
const userId = readUserId(req);
|
||||
const removed = notificationChannelEndpointsDb.removeEndpoint(userId, channel, endpointId);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'Notification endpoint not found' });
|
||||
}
|
||||
|
||||
const preferences = updateChannelPreference(userId, channel);
|
||||
return res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error removing notification endpoint:', error);
|
||||
return res.status(500).json({ error: 'Failed to remove notification endpoint' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { notificationChannelEndpointsDb } from '@/modules/database/index.js';
|
||||
|
||||
const DESKTOP_CHANNEL = 'desktop';
|
||||
|
||||
const clientsByUserId = new Map<number, Map<string, WebSocket>>();
|
||||
const clientBySocket = new WeakMap<WebSocket, { userId: number; endpointId: string }>();
|
||||
|
||||
function normalizeUserId(userId: unknown): number | null {
|
||||
const numeric = Number(userId);
|
||||
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizeEndpointId(endpointId: unknown): string {
|
||||
if (typeof endpointId !== 'string') return '';
|
||||
return endpointId.trim();
|
||||
}
|
||||
|
||||
function getUserClients(userId: unknown, create = false): Map<string, WebSocket> | null {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) return null;
|
||||
let clients = clientsByUserId.get(normalizedUserId);
|
||||
if (!clients && create) {
|
||||
clients = new Map();
|
||||
clientsByUserId.set(normalizedUserId, clients);
|
||||
}
|
||||
return clients || null;
|
||||
}
|
||||
|
||||
export function registerDesktopNotificationClient({
|
||||
userId,
|
||||
deviceId,
|
||||
label = null,
|
||||
platform = null,
|
||||
appVersion = null,
|
||||
ws,
|
||||
}: {
|
||||
userId: number;
|
||||
deviceId: string;
|
||||
label?: string | null;
|
||||
platform?: string | null;
|
||||
appVersion?: string | null;
|
||||
ws: WebSocket;
|
||||
}) {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
const endpointId = normalizeEndpointId(deviceId);
|
||||
if (!normalizedUserId || !endpointId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = notificationChannelEndpointsDb.upsertEndpoint({
|
||||
userId: normalizedUserId,
|
||||
channel: DESKTOP_CHANNEL,
|
||||
endpointId,
|
||||
label,
|
||||
metadata: { platform, appVersion },
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const clients = getUserClients(normalizedUserId, true)!;
|
||||
const previous = clients.get(endpointId);
|
||||
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
|
||||
previous.close(4000, 'Device reconnected');
|
||||
}
|
||||
|
||||
clients.set(endpointId, ws);
|
||||
clientBySocket.set(ws, { userId: normalizedUserId, endpointId });
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export function unregisterDesktopNotificationClient(ws: WebSocket): void {
|
||||
const registration = clientBySocket.get(ws);
|
||||
if (!registration) return;
|
||||
|
||||
const clients = getUserClients(registration.userId);
|
||||
if (clients?.get(registration.endpointId) === ws) {
|
||||
clients.delete(registration.endpointId);
|
||||
if (clients.size === 0) {
|
||||
clientsByUserId.delete(registration.userId);
|
||||
}
|
||||
}
|
||||
clientBySocket.delete(ws);
|
||||
}
|
||||
|
||||
export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) return { attempted: 0, sent: 0 };
|
||||
|
||||
const clients = getUserClients(normalizedUserId);
|
||||
if (!clients?.size) return { attempted: 0, sent: 0 };
|
||||
|
||||
const enabledEndpointIds = new Set(
|
||||
notificationChannelEndpointsDb
|
||||
.getEnabledEndpoints(normalizedUserId, DESKTOP_CHANNEL)
|
||||
.map((endpoint) => endpoint.endpoint_id)
|
||||
);
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'notification',
|
||||
id: typeof (payload as any)?.data?.tag === 'string' ? (payload as any).data.tag : `${Date.now()}`,
|
||||
payload,
|
||||
});
|
||||
|
||||
let attempted = 0;
|
||||
let sent = 0;
|
||||
for (const [endpointId, ws] of clients.entries()) {
|
||||
if (!enabledEndpointIds.has(endpointId)) continue;
|
||||
attempted += 1;
|
||||
if (ws.readyState !== ws.OPEN) {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ws.send(message);
|
||||
notificationChannelEndpointsDb.touchEndpoint(normalizedUserId, DESKTOP_CHANNEL, endpointId);
|
||||
sent += 1;
|
||||
} catch {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted, sent };
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { sendDesktopNotification as sendDesktopNotificationToClients } from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||
|
||||
const KIND_TO_PREF_KEY = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
system: 'System'
|
||||
};
|
||||
|
||||
const recentEventKeys = new Map();
|
||||
const DEDUPE_WINDOW_MS = 20000;
|
||||
|
||||
const cleanupOldEventKeys = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||
recentEventKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isNotificationEventEnabled(preferences, event) {
|
||||
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||
|
||||
return eventEnabled;
|
||||
}
|
||||
|
||||
function isDuplicate(event) {
|
||||
cleanupOldEventKeys();
|
||||
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||
if (recentEventKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
recentEventKeys.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
function createNotificationEvent({
|
||||
provider,
|
||||
sessionId = null,
|
||||
kind = 'info',
|
||||
code = 'generic.info',
|
||||
meta = {},
|
||||
severity = 'info',
|
||||
dedupeKey = null,
|
||||
requiresUserAction = false
|
||||
}) {
|
||||
return {
|
||||
provider,
|
||||
sessionId,
|
||||
kind,
|
||||
code,
|
||||
meta,
|
||||
severity,
|
||||
requiresUserAction,
|
||||
dedupeKey,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error) {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error == null) {
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeSessionName(sessionName) {
|
||||
if (typeof sessionName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = sessionName.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
|
||||
}
|
||||
|
||||
function rowMatchesProvider(row, provider) {
|
||||
return row && (!provider || row.provider === provider);
|
||||
}
|
||||
|
||||
function resolveSessionRow(sessionId, provider) {
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appSessionRow = sessionsDb.getSessionById(sessionId);
|
||||
if (rowMatchesProvider(appSessionRow, provider)) {
|
||||
return appSessionRow;
|
||||
}
|
||||
|
||||
const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
if (rowMatchesProvider(providerSessionRow, provider)) {
|
||||
return providerSessionRow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNotificationSession(event) {
|
||||
if (!event?.sessionId || !event.provider || event.provider === 'system') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const row = resolveSessionRow(event.sessionId, event.provider);
|
||||
if (!row || row.session_id === event.sessionId) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
sessionId: row.session_id
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionName(event) {
|
||||
const explicitSessionName = normalizeSessionName(event.meta?.sessionName);
|
||||
if (explicitSessionName) {
|
||||
return explicitSessionName;
|
||||
}
|
||||
|
||||
if (!event.sessionId || !event.provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider));
|
||||
}
|
||||
|
||||
function buildNotificationPayload(event) {
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const CODE_MAP = {
|
||||
'permission.required': normalizedEvent.meta?.toolName
|
||||
? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval`
|
||||
: 'Action Required: A tool needs your approval',
|
||||
'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped',
|
||||
'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error',
|
||||
'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification',
|
||||
'push.enabled': 'Push notifications are now enabled!'
|
||||
};
|
||||
const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant';
|
||||
const sessionName = resolveSessionName(normalizedEvent);
|
||||
const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification';
|
||||
|
||||
return {
|
||||
title: sessionName || 'CloudCLI',
|
||||
body: `${providerLabel}: ${message}`,
|
||||
data: {
|
||||
sessionId: normalizedEvent.sessionId || null,
|
||||
code: normalizedEvent.code,
|
||||
provider: normalizedEvent.provider || null,
|
||||
sessionName,
|
||||
tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendWebPushPayload(userId, payload) {
|
||||
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||
if (!subscriptions.length) return Promise.resolve();
|
||||
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
return Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
webPush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.keys_p256dh,
|
||||
auth: sub.keys_auth
|
||||
}
|
||||
},
|
||||
serializedPayload
|
||||
)
|
||||
)
|
||||
).then((results) => {
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const statusCode = result.reason?.statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const notificationChannels = [
|
||||
{
|
||||
id: 'webPush',
|
||||
// TODO: Web push still uses push_subscriptions. Do not remove that table until
|
||||
// browser push subscriptions are migrated into notification_channel_endpoints.
|
||||
isEnabled: (preferences) => Boolean(preferences?.channels?.webPush),
|
||||
send: ({ userId, payload }) => sendWebPushPayload(userId, payload)
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
isEnabled: (preferences) => Boolean(preferences?.channels?.desktop),
|
||||
send: ({ userId, payload }) => sendDesktopNotificationToClients(userId, payload)
|
||||
}
|
||||
];
|
||||
|
||||
function notifyUserIfEnabled({ userId, event }) {
|
||||
if (!userId || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedEvent = normalizeNotificationSession(event);
|
||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||
if (!isNotificationEventEnabled(preferences, normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate(normalizedEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildNotificationPayload(normalizedEvent);
|
||||
for (const channel of notificationChannels) {
|
||||
if (!channel.isEnabled(preferences)) {
|
||||
continue;
|
||||
}
|
||||
Promise.resolve(channel.send({ userId, event: normalizedEvent, payload })).catch((err) => {
|
||||
console.error(`Notification channel "${channel.id}" send error:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {
|
||||
notifyUserIfEnabled({
|
||||
userId,
|
||||
event: createNotificationEvent({
|
||||
provider,
|
||||
sessionId,
|
||||
kind: 'stop',
|
||||
code: 'run.stopped',
|
||||
meta: { stopReason, sessionName },
|
||||
severity: 'info',
|
||||
dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {
|
||||
const errorMessage = normalizeErrorMessage(error);
|
||||
|
||||
notifyUserIfEnabled({
|
||||
userId,
|
||||
event: createNotificationEvent({
|
||||
provider,
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
code: 'run.failed',
|
||||
meta: { error: errorMessage, sessionName },
|
||||
severity: 'error',
|
||||
dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
buildNotificationPayload,
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled,
|
||||
notifyRunStopped,
|
||||
notifyRunFailed
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import {
|
||||
registerDesktopNotificationClient,
|
||||
unregisterDesktopNotificationClient,
|
||||
} from '@/modules/notifications/services/desktop-notification-clients.service.js';
|
||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type DesktopNotificationRegisterMessage = {
|
||||
type?: unknown;
|
||||
kind?: unknown;
|
||||
deviceId?: unknown;
|
||||
label?: unknown;
|
||||
platform?: unknown;
|
||||
appVersion?: unknown;
|
||||
};
|
||||
|
||||
function readRequestUserId(request: AuthenticatedWebSocketRequest): number | null {
|
||||
const user = request.user;
|
||||
const rawUserId = typeof user?.id === 'number' || typeof user?.id === 'string'
|
||||
? user.id
|
||||
: typeof user?.userId === 'number' || typeof user?.userId === 'string'
|
||||
? user.userId
|
||||
: null;
|
||||
const numericUserId = Number(rawUserId);
|
||||
return Number.isInteger(numericUserId) && numericUserId > 0 ? numericUserId : null;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function sendJson(ws: WebSocket, payload: unknown): void {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
export function handleDesktopNotificationsConnection(
|
||||
ws: WebSocket,
|
||||
request: AuthenticatedWebSocketRequest
|
||||
): void {
|
||||
const userId = readRequestUserId(request);
|
||||
if (!userId) {
|
||||
ws.close(1008, 'Missing authenticated user');
|
||||
return;
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
ws.on('message', (rawMessage) => {
|
||||
const data = parseIncomingJsonObject(rawMessage) as DesktopNotificationRegisterMessage | null;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof data.type === 'string' ? data.type : typeof data.kind === 'string' ? data.kind : '';
|
||||
if (type === 'notification_ack') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== 'register' || registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = readOptionalString(data.deviceId);
|
||||
if (!deviceId) {
|
||||
sendJson(ws, {
|
||||
type: 'error',
|
||||
code: 'DEVICE_ID_REQUIRED',
|
||||
message: 'Desktop notification registration requires deviceId.',
|
||||
});
|
||||
ws.close(1008, 'Missing deviceId');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = registerDesktopNotificationClient({
|
||||
userId,
|
||||
deviceId,
|
||||
label: readOptionalString(data.label),
|
||||
platform: readOptionalString(data.platform),
|
||||
appVersion: readOptionalString(data.appVersion),
|
||||
ws,
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
ws.close(1011, 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
registered = true;
|
||||
sendJson(ws, {
|
||||
type: 'registered',
|
||||
deviceId: device.endpoint_id,
|
||||
enabled: Boolean(device.enabled),
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
unregisterDesktopNotificationClient(ws);
|
||||
});
|
||||
}
|
||||
@@ -67,8 +67,17 @@ function resolveRouteErrorMessage(error: unknown): string {
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const projects = await getProjectsWithSessions();
|
||||
asyncHandler(async (req, res) => {
|
||||
const skipSynchronization =
|
||||
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
|
||||
readQueryStringValue(req.query.skipSync).trim() === '1';
|
||||
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
|
||||
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
|
||||
const projects = await getProjectsWithSessions({
|
||||
skipSynchronization,
|
||||
sessionsLimit,
|
||||
sessionsOffset,
|
||||
});
|
||||
res.json(projects);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -30,10 +30,6 @@ type ProjectApiView = {
|
||||
isArchived: boolean;
|
||||
isStarred: boolean;
|
||||
sessions: [];
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
opencodeSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
@@ -82,10 +78,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
|
||||
isArchived: Boolean(projectRow.isArchived),
|
||||
isStarred: Boolean(projectRow.isStarred),
|
||||
sessions: [],
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
opencodeSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
|
||||
@@ -9,13 +9,12 @@ import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type SessionSummary = {
|
||||
id: string;
|
||||
provider: string;
|
||||
summary: string;
|
||||
messageCount: number;
|
||||
lastActivity: string;
|
||||
};
|
||||
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
session_id: string;
|
||||
@@ -31,10 +30,6 @@ export type ProjectListItem = {
|
||||
fullPath: string;
|
||||
isStarred: boolean;
|
||||
sessions: SessionSummary[];
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -64,7 +59,7 @@ type SessionPaginationOptions = {
|
||||
};
|
||||
|
||||
type ProjectSessionsPageResult = {
|
||||
sessionsByProvider: SessionsByProvider;
|
||||
sessions: SessionSummary[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
@@ -72,10 +67,6 @@ type ProjectSessionsPageResult = {
|
||||
export type ProjectSessionsPageApiView = {
|
||||
projectId: string;
|
||||
sessions: SessionSummary[];
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -129,39 +120,18 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
|
||||
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
||||
return {
|
||||
id: row.session_id,
|
||||
provider: row.provider,
|
||||
summary: row.custom_name || '',
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
|
||||
const byProvider: SessionsByProvider = {
|
||||
claude: [],
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
opencode: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const provider = row.provider as keyof SessionsByProvider;
|
||||
const bucket = byProvider[provider];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.push(mapSessionRowToSummary(row));
|
||||
}
|
||||
|
||||
return byProvider;
|
||||
}
|
||||
|
||||
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
|
||||
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
|
||||
|
||||
return {
|
||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||
sessions: rows.map(mapSessionRowToSummary),
|
||||
total: rows.length,
|
||||
hasMore: false,
|
||||
};
|
||||
@@ -183,16 +153,17 @@ function readProjectSessionsPageByPath(
|
||||
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
||||
|
||||
return {
|
||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||
sessions: rows.map(mapSessionRowToSummary),
|
||||
total,
|
||||
hasMore: pagination.offset + rows.length < total,
|
||||
};
|
||||
}
|
||||
|
||||
// Broadcast progress to all connected WebSocket clients
|
||||
// Broadcast progress to all connected WebSocket clients.
|
||||
// Uses the unified `kind` envelope like every other websocket frame.
|
||||
function broadcastProgress(progress: ProgressUpdate) {
|
||||
const message = JSON.stringify({
|
||||
type: 'loading_progress',
|
||||
kind: 'loading_progress',
|
||||
...progress,
|
||||
});
|
||||
|
||||
@@ -204,7 +175,7 @@ function broadcastProgress(progress: ProgressUpdate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all projects from DB and returns provider-bucketed session summaries.
|
||||
* Reads all projects from DB and returns normalized session summaries.
|
||||
*/
|
||||
export async function getProjectsWithSessions(
|
||||
options: GetProjectsWithSessionsOptions = {}
|
||||
@@ -252,11 +223,7 @@ export async function getProjectsWithSessions(
|
||||
displayName,
|
||||
fullPath: projectPath,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessions: sessionsPage.sessions,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -309,11 +276,7 @@ export async function getArchivedProjectsWithSessions(
|
||||
fullPath: row.project_path,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
isArchived: true,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessions: sessionsPage.sessions,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -342,11 +305,7 @@ export async function getProjectSessionsPage(
|
||||
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
||||
return {
|
||||
projectId: projectRow.project_id,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessions: sessionsPage.sessions,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
|
||||
@@ -83,7 +83,7 @@ The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
|
||||
- Update `server/modules/providers/provider.routes.ts`.
|
||||
- Update `server/routes/agent.js` if the provider is launchable from the agent runtime.
|
||||
- Update `server/index.js` if the provider needs runtime boot or shutdown wiring.
|
||||
- Update `public/modelConstants.js` if the provider appears in README or public API docs.
|
||||
- Update the `PROVIDER_ORDER` list in `public/api-docs.html` if the provider should appear in the public API docs.
|
||||
- Update `src/components/chat/hooks/useChatProviderState.ts` and
|
||||
`src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx` if
|
||||
the provider should be selectable in chat.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||
export { providerSkillsService } from './services/skills.service.js';
|
||||
export { providerMcpService } from './services/mcp.service.js';
|
||||
|
||||
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
|
||||
@@ -18,18 +18,28 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default (recommended)',
|
||||
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
||||
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'sonnet',
|
||||
label: 'Sonnet',
|
||||
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
||||
value: 'fable',
|
||||
label: 'Fable',
|
||||
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||
},
|
||||
{
|
||||
value: 'sonnet[1m]',
|
||||
label: 'Sonnet (1M context)',
|
||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'opus[1m]',
|
||||
label: 'Opus 4.8 (1M context)',
|
||||
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||
},
|
||||
{
|
||||
value: 'haiku',
|
||||
label: 'Haiku',
|
||||
|
||||
@@ -25,6 +25,21 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'claude' as const;
|
||||
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
||||
|
||||
/**
|
||||
* Returns true when a JSONL file is a subagent transcript rather than a
|
||||
* top-level session.
|
||||
*
|
||||
* Claude stores subagent transcripts under a `subagents/` directory, e.g.
|
||||
* `~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl`.
|
||||
* Those files repeat the parent session's `sessionId`, so indexing them as
|
||||
* standalone sessions overwrites the parent row's `jsonl_path` and corrupts
|
||||
* the main session record. The recursive scan in `synchronize()` reaches
|
||||
* them, so both entry points must skip them.
|
||||
*/
|
||||
private isSubagentTranscript(filePath: string): boolean {
|
||||
return path.normalize(filePath).split(path.sep).includes('subagents');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans ~/.claude/projects and upserts discovered sessions into DB.
|
||||
*/
|
||||
@@ -38,6 +53,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
if (this.isSubagentTranscript(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
@@ -66,6 +85,9 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
if (this.isSubagentTranscript(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
@@ -111,7 +133,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||
const existingSessionName = existingSession?.custom_name;
|
||||
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
@@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
||||
|
||||
async function getSessionMessages(
|
||||
sessionId: string,
|
||||
providerSessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
): Promise<ClaudeHistoryMessagesResult> {
|
||||
try {
|
||||
// The DB row is keyed by the app-facing session id, while the JSONL rows
|
||||
// on disk carry the provider-native id — both ids are needed here.
|
||||
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
|
||||
if (!jsonLPath) {
|
||||
@@ -133,7 +136,7 @@ async function getSessionMessages(
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
if (entry.sessionId === sessionId) {
|
||||
if (entry.sessionId === providerSessionId) {
|
||||
messages.push(entry);
|
||||
}
|
||||
} catch {
|
||||
@@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||
|
||||
let result: ClaudeHistoryResult;
|
||||
try {
|
||||
// Load full history first so `total` reflects frontend-normalized messages,
|
||||
// not raw JSONL records.
|
||||
result = await getSessionMessages(sessionId, null, 0);
|
||||
result = await getSessionMessages(sessionId, providerSessionId, null, 0);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||
@@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
}
|
||||
|
||||
const totalNormalized = normalized.length;
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
@@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, totalNormalized - normalizedOffset),
|
||||
);
|
||||
const hasMore = normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset: normalizedOffset,
|
||||
|
||||
@@ -99,6 +99,14 @@ export class ClaudeSkillsProvider extends SkillsProvider {
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(getClaudeHomePath(), 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
|
||||
private async listPluginSkills(claudeHomePath: string): Promise<ProviderSkill[]> {
|
||||
const settings = await readJsonConfig(path.join(claudeHomePath, 'settings.json'));
|
||||
const enabledPlugins = readObjectRecord(settings.enabledPlugins);
|
||||
|
||||
@@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||
if (existingSession) {
|
||||
// If session name is untitled and we now have a name, update it
|
||||
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
||||
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
||||
?? sessionsDb.getSessionById(parsed.sessionId);
|
||||
const existingSessionName = existingSession?.custom_name;
|
||||
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import readline from 'node:readline';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
@@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
}
|
||||
|
||||
const totalNormalized = normalized.length;
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
@@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, totalNormalized - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, totalNormalized - normalizedOffset),
|
||||
);
|
||||
const hasMore = normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0;
|
||||
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset: normalizedOffset,
|
||||
|
||||
@@ -57,4 +57,12 @@ export class CodexSkillsProvider extends SkillsProvider {
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '$',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
generateMessageId,
|
||||
readObjectRecord,
|
||||
sanitizeLeafDirectoryName,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
@@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions {
|
||||
|
||||
/**
|
||||
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
||||
*
|
||||
* Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is
|
||||
* the most recent page, matching every other provider.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { projectPath = '', limit = null, offset = 0 } = options;
|
||||
// The store.db folder on disk is named after the provider-native id, not
|
||||
// the app-facing session id this method is addressed with.
|
||||
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||
|
||||
try {
|
||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
||||
const blobs = await this.loadCursorBlobs(providerSessionId, projectPath);
|
||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||
const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result');
|
||||
const total = renderableMessages.length;
|
||||
|
||||
if (limit !== null) {
|
||||
const start = offset;
|
||||
const page = limit === 0
|
||||
? []
|
||||
: renderableMessages.slice(start, start + limit);
|
||||
const hasMore = limit === 0
|
||||
? start < total
|
||||
: start + limit < total;
|
||||
return {
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset);
|
||||
|
||||
return {
|
||||
messages: renderableMessages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -28,4 +28,12 @@ export class CursorSkillsProvider extends SkillsProvider {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.cursor', 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,14 @@ import {
|
||||
|
||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' },
|
||||
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
|
||||
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
|
||||
],
|
||||
DEFAULT: 'gemini-3.1-pro-preview',
|
||||
DEFAULT: 'gemini-3-flash-preview',
|
||||
};
|
||||
|
||||
export class GeminiProviderModels implements IProviderModels {
|
||||
|
||||
@@ -5,7 +5,7 @@ import readline from 'node:readline';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
@@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = pageLimit === null
|
||||
? normalized.slice(start)
|
||||
: normalized.slice(start, start + pageLimit);
|
||||
// Tail pagination via the shared contract: offset 0 returns the most
|
||||
// recent page, matching every other provider.
|
||||
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
@@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions {
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
||||
hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
tokenUsage: result.tokenUsage,
|
||||
|
||||
@@ -33,4 +33,12 @@ export class GeminiSkillsProvider extends SkillsProvider {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
export class HermesProviderAuth implements IProviderAuth {
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.HERMES_CLI_PATH || 'hermes acp';
|
||||
const [command, ...args] = cliPath.trim().split(/\s+/);
|
||||
try {
|
||||
const result = spawn.sync(command || 'hermes', [...args, '--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return result.error ? false : result.status === 0 || result.status === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
if (!installed) {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Hermes is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
return {
|
||||
provider: 'hermes',
|
||||
installed,
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method ?? 'managed_by_hermes',
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkCredentials(): Promise<{ authenticated: boolean; email: string | null; method: string | null }> {
|
||||
if (this.hasKnownProviderEnv(process.env)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'env' };
|
||||
}
|
||||
|
||||
const hermesHome = path.join(os.homedir(), '.hermes');
|
||||
try {
|
||||
const authJson = readObjectRecord(JSON.parse(await readFile(path.join(hermesHome, 'auth.json'), 'utf8')));
|
||||
if (
|
||||
readOptionalString(authJson?.apiKey)
|
||||
|| readOptionalString(authJson?.api_key)
|
||||
|| readOptionalString(authJson?.token)
|
||||
|| readOptionalString(authJson?.access_token)
|
||||
|| readOptionalString(authJson?.refresh_token)
|
||||
) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: readOptionalString(authJson?.email) ?? 'Hermes Auth',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to dotenv check.
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await readFile(path.join(hermesHome, '.env'), 'utf8');
|
||||
if (this.hasKnownProviderEnv(this.parseEnvFile(envContent))) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'env_file' };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
try {
|
||||
const configContent = await readFile(path.join(hermesHome, 'config.yaml'), 'utf8');
|
||||
if (/^\s*api_key\s*:\s*["']?[^"'#\s]+/m.test(configContent)) {
|
||||
return { authenticated: true, email: 'Hermes Config', method: 'config_file' };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
||||
if (key && value) {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private hasKnownProviderEnv(env: Record<string, string | undefined>): boolean {
|
||||
const keys = [
|
||||
'HERMES_API_KEY',
|
||||
'NOUS_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GLM_API_KEY',
|
||||
'KIMI_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'MINIMAX_CN_API_KEY',
|
||||
'HF_TOKEN',
|
||||
'NVIDIA_API_KEY',
|
||||
'ARCEEAI_API_KEY',
|
||||
'OLLAMA_API_KEY',
|
||||
'KILOCODE_API_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
];
|
||||
return keys.some((key) => Boolean(env[key]?.trim()));
|
||||
}
|
||||
}
|
||||
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const yamlScalar = (value: unknown): string => {
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
return JSON.stringify(String(value));
|
||||
};
|
||||
|
||||
const parseYamlScalar = (value: string): unknown => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (trimmed === 'null') {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (trimmed === 'false') {
|
||||
return false;
|
||||
}
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
}
|
||||
if (
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
|| (trimmed.startsWith('{') && trimmed.endsWith('}'))
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return trimmed.replace(/\s+#.*$/, '').trim();
|
||||
};
|
||||
|
||||
const getIndent = (line: string): number => line.match(/^\s*/)?.[0].length ?? 0;
|
||||
|
||||
const parseYamlArray = (
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
indent: number,
|
||||
): { value: unknown[]; nextIndex: number } => {
|
||||
const value: unknown[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (getIndent(line) !== indent || !line.trimStart().startsWith('- ')) {
|
||||
break;
|
||||
}
|
||||
value.push(parseYamlScalar(line.trimStart().slice(2)));
|
||||
index += 1;
|
||||
}
|
||||
return { value, nextIndex: index };
|
||||
};
|
||||
|
||||
const parseYamlMap = (
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
indent: number,
|
||||
): { value: Record<string, unknown>; nextIndex: number } => {
|
||||
const value: Record<string, unknown> = {};
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const currentIndent = getIndent(line);
|
||||
if (currentIndent < indent) {
|
||||
break;
|
||||
}
|
||||
if (currentIndent > indent) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const match = line.slice(indent).match(/^([^:#]+):(?:\s*(.*))?$/);
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1].trim();
|
||||
const raw = match[2]?.trim() ?? '';
|
||||
if (raw) {
|
||||
value[key] = parseYamlScalar(raw);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLine = lines[index + 1];
|
||||
if (nextLine && getIndent(nextLine) > indent && nextLine.trimStart().startsWith('- ')) {
|
||||
const parsed = parseYamlArray(lines, index + 1, getIndent(nextLine));
|
||||
value[key] = parsed.value;
|
||||
index = parsed.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseYamlMap(lines, index + 1, indent + 2);
|
||||
value[key] = parsed.value;
|
||||
index = parsed.nextIndex;
|
||||
}
|
||||
return { value, nextIndex: index };
|
||||
};
|
||||
|
||||
const readYamlConfig = async (filePath: string): Promise<string> => {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readMcpServers = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
const content = await readYamlConfig(filePath);
|
||||
const lines = content.split(/\r?\n/);
|
||||
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
|
||||
if (sectionIndex === -1) {
|
||||
return {};
|
||||
}
|
||||
const parsed = parseYamlMap(lines, sectionIndex + 1, 2);
|
||||
return readObjectRecord(parsed.value) ?? {};
|
||||
};
|
||||
|
||||
const serializeYamlMap = (value: Record<string, unknown>, indent = 0): string[] => {
|
||||
const lines: string[] = [];
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
if (rawValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const prefix = `${' '.repeat(indent)}${key}:`;
|
||||
if (Array.isArray(rawValue)) {
|
||||
lines.push(prefix);
|
||||
for (const item of rawValue) {
|
||||
lines.push(`${' '.repeat(indent + 2)}- ${yamlScalar(item)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const nested = readObjectRecord(rawValue);
|
||||
if (nested) {
|
||||
lines.push(prefix);
|
||||
lines.push(...serializeYamlMap(nested, indent + 2));
|
||||
continue;
|
||||
}
|
||||
lines.push(`${prefix} ${yamlScalar(rawValue)}`);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const replaceMcpServersSection = (content: string, servers: Record<string, unknown>): string => {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
|
||||
const serialized = ['mcp_servers:', ...serializeYamlMap(servers, 2)];
|
||||
|
||||
if (sectionIndex === -1) {
|
||||
const prefix = content.trimEnd();
|
||||
return `${prefix ? `${prefix}\n\n` : ''}${serialized.join('\n')}\n`;
|
||||
}
|
||||
|
||||
let endIndex = sectionIndex + 1;
|
||||
while (endIndex < lines.length) {
|
||||
const line = lines[endIndex];
|
||||
if (line.trim() && getIndent(line) === 0) {
|
||||
break;
|
||||
}
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
lines.splice(sectionIndex, endIndex - sectionIndex, ...serialized);
|
||||
return `${lines.join('\n').trimEnd()}\n`;
|
||||
};
|
||||
|
||||
const writeMcpServers = async (filePath: string, servers: Record<string, unknown>): Promise<void> => {
|
||||
const content = await readYamlConfig(filePath);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, replaceMcpServersSection(content, servers), 'utf8');
|
||||
};
|
||||
|
||||
export class HermesMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('hermes', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||
: path.join(workspacePath, '.hermes', 'config.yaml');
|
||||
return readMcpServers(filePath);
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||
: path.join(workspacePath, '.hermes', 'config.yaml');
|
||||
await writeMcpServers(filePath, servers);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(scope: McpScope, name: string, rawConfig: unknown): ProviderMcpServer | null {
|
||||
const config = readObjectRecord(rawConfig);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
151
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
151
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import { readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||
|
||||
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: HERMES_CONFIGURED_MODEL,
|
||||
label: 'Use Hermes default',
|
||||
description: 'Uses the provider and model selected in Hermes.',
|
||||
},
|
||||
],
|
||||
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||
};
|
||||
|
||||
const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function stripScalar(raw: string): string | null {
|
||||
let value = raw.trim();
|
||||
// Drop an unquoted trailing comment.
|
||||
if (!value.startsWith('"') && !value.startsWith("'")) {
|
||||
const comment = value.search(/\s#/);
|
||||
if (comment >= 0) {
|
||||
value = value.slice(0, comment).trim();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
|
||||
|
||||
// Minimal, indentation-aware reader for the flat `key: value` and one-level
|
||||
// nested (`section:`\n` key: value`) shapes used by ~/.hermes/config.yaml.
|
||||
// Avoids the fragile single-regex lookahead that could terminate a section
|
||||
// early and silently miss the configured model.
|
||||
export function readYamlPath(content: string, pathParts: string[]): string | null {
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
const re = new RegExp(`^\\s*${escapeRegex(pathParts[0])}\\s*:\\s*(.*)$`);
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const match = line.match(re);
|
||||
if (match) return stripScalar(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [section, key] = pathParts;
|
||||
const sectionRe = new RegExp(`^(\\s*)${escapeRegex(section)}\\s*:\\s*$`);
|
||||
const keyRe = new RegExp(`^\\s*${escapeRegex(key)}\\s*:\\s*(.*)$`);
|
||||
let sectionIndent: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
|
||||
if (sectionIndent === null) {
|
||||
const match = line.match(sectionRe);
|
||||
if (match) sectionIndent = match[1].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Left the nested block once indentation returns to the section level or less.
|
||||
if (indentOf(line) <= sectionIndent) {
|
||||
sectionIndent = line.match(sectionRe)?.[1].length ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(keyRe);
|
||||
if (match) return stripScalar(match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class HermesProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
const activeModel = await this.readConfiguredModel();
|
||||
if (!activeModel) {
|
||||
return HERMES_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
return {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: HERMES_CONFIGURED_MODEL,
|
||||
label: 'Use Hermes default',
|
||||
description: `Uses the provider and model selected in Hermes. Current config: ${activeModel}`,
|
||||
},
|
||||
],
|
||||
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
const configured = await this.readConfiguredModel();
|
||||
return { model: configured ?? HERMES_CONFIGURED_MODEL };
|
||||
}
|
||||
|
||||
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
|
||||
if (input.model === HERMES_CONFIGURED_MODEL) {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
sessionId: input.sessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'hermes',
|
||||
sessionId: input.sessionId,
|
||||
supported: false,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readConfiguredModel(): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(HERMES_CONFIG_PATH, 'utf8');
|
||||
return readOptionalString(readYamlPath(content, ['model', 'default']))
|
||||
?? readOptionalString(readYamlPath(content, ['model']))
|
||||
?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import fsSync from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import { normalizeSessionName } from '@/shared/utils.js';
|
||||
|
||||
type HermesSessionRow = {
|
||||
id: string;
|
||||
cwd: string | null;
|
||||
title: string | null;
|
||||
started_at: number | null;
|
||||
ended_at: number | null;
|
||||
message_count: number | null;
|
||||
};
|
||||
|
||||
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||
|
||||
function unixSecondsToIso(value: number | null | undefined): string {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function openHermesDatabase(): Database.Database | null {
|
||||
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||
return null;
|
||||
}
|
||||
return new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||
}
|
||||
|
||||
export class HermesSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'hermes' as const;
|
||||
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const db = openHermesDatabase();
|
||||
if (!db) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = since
|
||||
? db.prepare(`
|
||||
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||
FROM sessions
|
||||
WHERE COALESCE(ended_at, started_at) >= ?
|
||||
ORDER BY COALESCE(ended_at, started_at) ASC
|
||||
`).all(Math.floor(since.getTime() / 1000)) as HermesSessionRow[]
|
||||
: db.prepare(`
|
||||
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||
FROM sessions
|
||||
ORDER BY COALESCE(ended_at, started_at) ASC
|
||||
`).all() as HermesSessionRow[];
|
||||
|
||||
let processed = 0;
|
||||
for (const row of rows) {
|
||||
if (this.upsertRow(row)) {
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (path.resolve(filePath) !== HERMES_DB_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = openHermesDatabase();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||
FROM sessions
|
||||
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||
LIMIT 1
|
||||
`).get() as HermesSessionRow | undefined;
|
||||
return row && this.upsertRow(row) ? row.id : null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
private upsertRow(row: HermesSessionRow): boolean {
|
||||
if (!row.id || !row.cwd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionsDb.createSession(
|
||||
row.id,
|
||||
this.provider,
|
||||
row.cwd,
|
||||
normalizeSessionName(row.title ?? undefined, 'Untitled Hermes Session'),
|
||||
unixSecondsToIso(row.started_at),
|
||||
unixSecondsToIso(row.ended_at ?? row.started_at),
|
||||
HERMES_DB_PATH,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
381
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
381
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import fsSync from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
normalizeProviderTimestamp,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'hermes';
|
||||
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||
|
||||
type HermesMessageRow = {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_call_id: string | null;
|
||||
tool_calls: string | null;
|
||||
tool_name: string | null;
|
||||
timestamp: number;
|
||||
reasoning: string | null;
|
||||
reasoning_content: string | null;
|
||||
finish_reason: string | null;
|
||||
};
|
||||
|
||||
function formatContent(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function readUpdateType(raw: AnyRecord): string {
|
||||
return readOptionalString(raw.type)
|
||||
?? readOptionalString(raw.kind)
|
||||
?? readOptionalString(raw.sessionUpdate)
|
||||
?? readOptionalString(raw.session_update)
|
||||
?? readOptionalString(raw.update)
|
||||
?? readOptionalString(raw.event)
|
||||
?? '';
|
||||
}
|
||||
|
||||
function readEventSessionId(raw: AnyRecord, sessionId: string | null): string | null {
|
||||
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
|
||||
}
|
||||
|
||||
function readTextContent(value: unknown): string | null {
|
||||
const direct = readOptionalString(value);
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((entry) => readTextContent(entry))
|
||||
.filter((entry): entry is string => Boolean(entry?.trim()));
|
||||
return parts.length > 0 ? parts.join('') : null;
|
||||
}
|
||||
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestedContent = record.content;
|
||||
const nestedText = nestedContent === value ? null : readTextContent(nestedContent);
|
||||
|
||||
return readOptionalString(record.text)
|
||||
?? readOptionalString(record.content)
|
||||
?? nestedText
|
||||
?? readOptionalString(record.delta)
|
||||
?? readOptionalString(record.rawOutput)
|
||||
?? readOptionalString(record.raw_output)
|
||||
?? readOptionalString(record.output)
|
||||
?? null;
|
||||
}
|
||||
|
||||
function readToolPayload(raw: AnyRecord): AnyRecord {
|
||||
return readObjectRecord(raw.toolCall)
|
||||
?? readObjectRecord(raw.tool_call)
|
||||
?? readObjectRecord(raw.tool)
|
||||
?? raw;
|
||||
}
|
||||
|
||||
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
|
||||
const envelope = readObjectRecord(rawMessage);
|
||||
if (!envelope) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nestedUpdate = readObjectRecord(envelope.update);
|
||||
const raw = nestedUpdate ? { ...nestedUpdate, sessionId: envelope.sessionId ?? envelope.session_id ?? sessionId } : envelope;
|
||||
|
||||
const type = readUpdateType(raw);
|
||||
const eventSessionId = readEventSessionId(raw, sessionId);
|
||||
const timestamp = normalizeProviderTimestamp(raw.timestamp ?? raw.time ?? raw.createdAt ?? raw.created_at);
|
||||
const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageId) ?? readOptionalString(raw.message_id) ?? generateMessageId(PROVIDER);
|
||||
|
||||
if (['agent_message_chunk', 'assistant_message_chunk', 'message_delta', 'text_delta', 'text'].includes(type)) {
|
||||
const content = readTextContent(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readOptionalString(raw.delta)
|
||||
?? readTextContent(readObjectRecord(raw.message)?.content)
|
||||
?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: history ? 'text' : 'stream_delta',
|
||||
role: history ? 'assistant' : undefined,
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
|
||||
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
|
||||
const content = readTextContent(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readTextContent(readObjectRecord(raw.message)?.content)
|
||||
?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: history ? 'text' : role === 'assistant' ? 'stream_delta' : 'text',
|
||||
role: history || role === 'user' ? role : undefined,
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
|
||||
const content = readTextContent(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call', 'tool_use', 'tool_call_start'].includes(type)) {
|
||||
const tool = readToolPayload(raw);
|
||||
const toolId = readOptionalString(raw.toolCallId)
|
||||
?? readOptionalString(raw.tool_call_id)
|
||||
?? readOptionalString(raw.toolId)
|
||||
?? readOptionalString(tool.toolCallId)
|
||||
?? readOptionalString(tool.tool_call_id)
|
||||
?? readOptionalString(tool.toolId)
|
||||
?? readOptionalString(tool.id)
|
||||
?? baseId;
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(raw.toolName)
|
||||
?? readOptionalString(raw.tool_name)
|
||||
?? readOptionalString(raw.title)
|
||||
?? readOptionalString(raw.name)
|
||||
?? readOptionalString(tool?.name)
|
||||
?? readOptionalString(tool?.title)
|
||||
?? 'Tool',
|
||||
toolInput: raw.rawInput
|
||||
?? raw.raw_input
|
||||
?? raw.input
|
||||
?? raw.arguments
|
||||
?? raw.params
|
||||
?? tool?.rawInput
|
||||
?? tool?.raw_input
|
||||
?? tool?.input
|
||||
?? tool?.arguments
|
||||
?? {},
|
||||
toolId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
|
||||
const tool = readToolPayload(raw);
|
||||
const content = readTextContent(raw.content)
|
||||
?? readTextContent(raw.rawOutput)
|
||||
?? readTextContent(raw.raw_output)
|
||||
?? readTextContent(raw.output)
|
||||
?? readTextContent(raw.result)
|
||||
?? readTextContent(tool.rawOutput)
|
||||
?? readTextContent(tool.raw_output)
|
||||
?? readTextContent(tool.output)
|
||||
?? readTextContent(tool.result)
|
||||
?? '';
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: readOptionalString(raw.toolCallId)
|
||||
?? readOptionalString(raw.tool_call_id)
|
||||
?? readOptionalString(raw.toolId)
|
||||
?? readOptionalString(tool.toolCallId)
|
||||
?? readOptionalString(tool.tool_call_id)
|
||||
?? readOptionalString(tool.toolId)
|
||||
?? readOptionalString(tool.id)
|
||||
?? '',
|
||||
content: content || formatContent(raw.delta ?? ''),
|
||||
isError: Boolean(raw.error) || raw.status === 'error' || raw.status === 'failed',
|
||||
toolUseResult: raw.result ?? raw.output ?? raw.rawOutput ?? raw.raw_output ?? tool.result ?? tool.output ?? tool.rawOutput ?? tool.raw_output,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'plan') {
|
||||
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? formatContent(raw.plan);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'status',
|
||||
text: 'plan',
|
||||
summary: content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown Hermes error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseJsonArray(value: string | null): unknown[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readHermesHistoryFromDatabase(sessionId: string): NormalizedMessage[] {
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const db = new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning, reasoning_content, finish_reason
|
||||
FROM messages
|
||||
WHERE session_id = ? AND active = 1
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
`).all(sessionId) as HermesMessageRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const timestamp = new Date(row.timestamp * 1000).toISOString();
|
||||
const baseId = `hermes-${sessionId}-${row.id}`;
|
||||
|
||||
const reasoning = row.reasoning_content || row.reasoning;
|
||||
if (reasoning?.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-thinking`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: reasoning,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const toolCall of parseJsonArray(row.tool_calls)) {
|
||||
const call = readObjectRecord(toolCall);
|
||||
const fn = readObjectRecord(call?.function);
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-tool-${readOptionalString(call?.id) ?? normalized.length}`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(fn?.name) ?? readOptionalString(call?.name) ?? 'Tool',
|
||||
toolInput: fn?.arguments ?? call?.arguments ?? {},
|
||||
toolId: readOptionalString(call?.id) ?? `${baseId}-tool`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (row.role === 'tool') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-result`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: row.tool_call_id ?? '',
|
||||
content: row.content ?? '',
|
||||
isError: row.finish_reason === 'error',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.content?.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: row.role === 'user' ? 'user' : 'assistant',
|
||||
content: row.content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export class HermesSessionsProvider implements IProviderSessions {
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
return normalizeHermesEvent(rawMessage, sessionId);
|
||||
}
|
||||
|
||||
async fetchHistory(sessionId: string, options: FetchHistoryOptions = {}): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const row = sessionsDb.getSessionById(sessionId) ?? sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
const messages = readHermesHistoryFromDatabase(row?.provider_session_id ?? sessionId);
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
const page = sliceTailPage(messages, pageLimit, start);
|
||||
return {
|
||||
messages: page.page,
|
||||
total: messages.length,
|
||||
hasMore: page.hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type {
|
||||
ProviderSkillRegistryActionResult,
|
||||
ProviderSkillRegistryInstallInput,
|
||||
ProviderSkillRegistrySearchOptions,
|
||||
ProviderSkillRegistrySearchResult,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, addUniqueProviderSkillSource, readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const HERMES_COMMAND =
|
||||
(process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes').trim().split(/\s+/)[0] || 'hermes';
|
||||
const HERMES_SKILLS_TIMEOUT_MS = 45_000;
|
||||
const HERMES_SKILLS_MAX_BUFFER = 1024 * 1024 * 8;
|
||||
|
||||
function normalizeSearchResult(value: unknown): ProviderSkillRegistrySearchResult | null {
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = readOptionalString(record.name);
|
||||
const identifier = readOptionalString(record.identifier);
|
||||
if (!name || !identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
identifier,
|
||||
source: readOptionalString(record.source) ?? undefined,
|
||||
trustLevel: readOptionalString(record.trust_level) ?? readOptionalString(record.trustLevel) ?? undefined,
|
||||
description: readOptionalString(record.description) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class HermesSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('hermes');
|
||||
}
|
||||
|
||||
async searchRegistry(
|
||||
query: string,
|
||||
options: ProviderSkillRegistrySearchOptions = {},
|
||||
): Promise<ProviderSkillRegistrySearchResult[]> {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const args = ['skills', 'search', normalizedQuery, '--json'];
|
||||
const source = options.source?.trim();
|
||||
if (source) {
|
||||
args.push('--source', source);
|
||||
}
|
||||
if (options.limit && Number.isFinite(options.limit)) {
|
||||
args.push('--limit', String(Math.max(1, Math.min(Math.floor(options.limit), 50))));
|
||||
}
|
||||
|
||||
const result = await this.runHermes(args);
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.map(normalizeSearchResult).filter((entry): entry is ProviderSkillRegistrySearchResult => Boolean(entry))
|
||||
: [];
|
||||
} catch (error) {
|
||||
throw new AppError('Hermes returned invalid skill search JSON.', {
|
||||
code: 'HERMES_SKILL_SEARCH_PARSE_FAILED',
|
||||
statusCode: 502,
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async installRegistrySkill(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult> {
|
||||
const identifier = input.identifier.trim();
|
||||
if (!identifier) {
|
||||
throw new AppError('identifier is required.', {
|
||||
code: 'HERMES_SKILL_IDENTIFIER_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const args = ['skills', 'install', identifier, '--yes'];
|
||||
if (input.category?.trim()) {
|
||||
args.push('--category', input.category.trim());
|
||||
}
|
||||
if (input.name?.trim()) {
|
||||
args.push('--name', input.name.trim());
|
||||
}
|
||||
if (input.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
return this.runHermes(args);
|
||||
}
|
||||
|
||||
async uninstallRegistrySkill(name: string): Promise<ProviderSkillRegistryActionResult> {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
throw new AppError('name is required.', {
|
||||
code: 'HERMES_SKILL_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
return this.runHermes(['skills', 'uninstall', normalizedName]);
|
||||
}
|
||||
|
||||
async checkRegistryUpdates(): Promise<ProviderSkillRegistryActionResult> {
|
||||
return this.runHermes(['skills', 'check']);
|
||||
}
|
||||
|
||||
async updateRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
|
||||
return this.runHermes(['skills', 'update']);
|
||||
}
|
||||
|
||||
async auditRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
|
||||
return this.runHermes(['skills', 'audit']);
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const sources: ProviderSkillSource[] = [];
|
||||
const seenRootDirs = new Set<string>();
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(workspacePath, '.hermes', 'skills'),
|
||||
commandPrefix: '/',
|
||||
recursive: true,
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
|
||||
commandPrefix: '/',
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
|
||||
commandPrefix: '/',
|
||||
recursive: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async runHermes(args: string[]): Promise<ProviderSkillRegistryActionResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_COMMAND, args, {
|
||||
timeout: HERMES_SKILLS_TIMEOUT_MS,
|
||||
maxBuffer: HERMES_SKILLS_MAX_BUFFER,
|
||||
env: process.env,
|
||||
});
|
||||
return { ok: true, stdout, stderr };
|
||||
} catch (error) {
|
||||
const maybeError = error as Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number | string;
|
||||
};
|
||||
throw new AppError(maybeError.stderr || maybeError.message || 'Hermes skill command failed.', {
|
||||
code: 'HERMES_SKILL_COMMAND_FAILED',
|
||||
statusCode: 502,
|
||||
details: {
|
||||
exitCode: maybeError.code,
|
||||
stdout: maybeError.stdout,
|
||||
stderr: maybeError.stderr,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HermesProviderAuth } from '@/modules/providers/list/hermes/hermes-auth.provider.js';
|
||||
import { HermesMcpProvider } from '@/modules/providers/list/hermes/hermes-mcp.provider.js';
|
||||
import { HermesProviderModels } from '@/modules/providers/list/hermes/hermes-models.provider.js';
|
||||
import { HermesSessionSynchronizer } from '@/modules/providers/list/hermes/hermes-session-synchronizer.provider.js';
|
||||
import { HermesSessionsProvider } from '@/modules/providers/list/hermes/hermes-sessions.provider.js';
|
||||
import { HermesSkillsProvider } from '@/modules/providers/list/hermes/hermes-skills.provider.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class HermesProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new HermesProviderModels();
|
||||
readonly mcp = new HermesMcpProvider();
|
||||
readonly auth: IProviderAuth = new HermesProviderAuth();
|
||||
readonly skills: IProviderSkills = new HermesSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new HermesSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new HermesSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('hermes');
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,21 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
}
|
||||
|
||||
const fallbackTitle = 'Untitled OpenCode Session';
|
||||
const existingSession = sessionsDb.getSessionById(sessionId);
|
||||
const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||
?? sessionsDb.getSessionById(sessionId)
|
||||
?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath);
|
||||
if (pendingAppSession && !pendingAppSession.provider_session_id) {
|
||||
// Slow networks can let the sqlite watcher index opencode.db before the
|
||||
// runtime reports its provider id back through the websocket mapping.
|
||||
// Bind that id to the fresh app row first so the watcher does not create
|
||||
// a temporary provider-id sidebar entry for the same session.
|
||||
sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId);
|
||||
}
|
||||
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||
?? sessionsDb.getSessionById(sessionId);
|
||||
const existingName = existingSession?.custom_name;
|
||||
const nextName = existingName && existingName !== fallbackTitle
|
||||
? existingName
|
||||
@@ -120,7 +134,9 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
|
||||
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||
sessionsDb.createSession(
|
||||
// Return the canonical stored row id so watcher-triggered sidebar updates
|
||||
// stay on the app session once provider_session_id has already been mapped.
|
||||
return sessionsDb.createSession(
|
||||
sessionId,
|
||||
this.provider,
|
||||
projectPath,
|
||||
@@ -129,8 +145,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
||||
null,
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
readObjectRecord,
|
||||
readJsonRecord,
|
||||
readOptionalString,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'opencode';
|
||||
@@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
// OpenCode's shared sqlite database keys messages by the provider-native
|
||||
// session id, not the app-facing id this method is addressed with.
|
||||
const providerSessionId = options.providerSessionId ?? sessionId;
|
||||
const db = openOpenCodeDatabase();
|
||||
if (!db) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
@@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
|
||||
m.id,
|
||||
COALESCE(p.time_created, 0),
|
||||
p.id
|
||||
`).all(sessionId) as OpenCodeHistoryRow[];
|
||||
`).all(providerSessionId) as OpenCodeHistoryRow[];
|
||||
|
||||
const normalized = this.normalizeHistoryRows(rows, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId);
|
||||
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
|
||||
|
||||
const normalizedOffset = Math.max(0, offset);
|
||||
const normalizedLimit = limit === null ? null : Math.max(0, limit);
|
||||
const total = normalized.length;
|
||||
const messages = normalizedLimit === null
|
||||
? normalized
|
||||
: normalized.slice(
|
||||
Math.max(0, total - normalizedOffset - normalizedLimit),
|
||||
Math.max(0, total - normalizedOffset),
|
||||
);
|
||||
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: page,
|
||||
total,
|
||||
hasMore: normalizedLimit === null
|
||||
? false
|
||||
: Math.max(0, total - normalizedOffset - normalizedLimit) > 0,
|
||||
hasMore,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
tokenUsage,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import { HermesProvider } from '@/modules/providers/list/hermes/hermes.provider.js';
|
||||
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
@@ -13,6 +14,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
hermes: new HermesProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
|
||||
import { hermesGatewayService } from '@/modules/providers/services/hermes-gateway.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
|
||||
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
|
||||
@@ -11,6 +13,8 @@ import type {
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderSkillCreateFile,
|
||||
ProviderSkillCreateInput,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
@@ -178,6 +182,146 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
|
||||
};
|
||||
};
|
||||
|
||||
const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const rawEntries = Array.isArray(body.entries)
|
||||
? body.entries
|
||||
: typeof body.content === 'string'
|
||||
? [{
|
||||
content: body.content,
|
||||
directoryName: body.directoryName,
|
||||
fileName: body.fileName,
|
||||
files: body.files,
|
||||
}]
|
||||
: null;
|
||||
|
||||
if (!rawEntries || rawEntries.length === 0) {
|
||||
throw new AppError('At least one skill entry is required.', {
|
||||
code: 'PROVIDER_SKILLS_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const entries = rawEntries.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new AppError(`Skill entry ${index + 1} must be an object.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
const content = typeof record.content === 'string' ? record.content : '';
|
||||
const directoryName = readOptionalQueryString(record.directoryName);
|
||||
const fileName = readOptionalQueryString(record.fileName);
|
||||
const rawFiles = record.files;
|
||||
|
||||
if (!content.trim()) {
|
||||
throw new AppError(`Skill entry ${index + 1} must include markdown content.`, {
|
||||
code: 'PROVIDER_SKILL_CONTENT_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (rawFiles !== undefined && !Array.isArray(rawFiles)) {
|
||||
throw new AppError(`Skill entry ${index + 1} files must be an array.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const files: ProviderSkillCreateFile[] | undefined = rawFiles?.map((file, fileIndex) => {
|
||||
if (!file || typeof file !== 'object') {
|
||||
throw new AppError(`Skill entry ${index + 1} file ${fileIndex + 1} must be an object.`, {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const fileRecord = file as Record<string, unknown>;
|
||||
const relativePath = readOptionalQueryString(fileRecord.relativePath);
|
||||
const fileContent = typeof fileRecord.content === 'string' ? fileRecord.content : null;
|
||||
const encoding = fileRecord.encoding === 'utf8' || fileRecord.encoding === 'base64'
|
||||
? fileRecord.encoding
|
||||
: null;
|
||||
|
||||
if (!relativePath || fileContent === null || !encoding) {
|
||||
throw new AppError(
|
||||
`Skill entry ${index + 1} file ${fileIndex + 1} requires relativePath, content, and encoding.`,
|
||||
{
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
content: fileContent,
|
||||
encoding,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
directoryName,
|
||||
fileName,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
return { entries };
|
||||
};
|
||||
|
||||
const parseSkillRegistryLimit = (value: unknown): number => {
|
||||
const raw = readOptionalQueryString(value);
|
||||
if (!raw) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new AppError('limit must be a valid integer.', {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(parsed, 50));
|
||||
};
|
||||
|
||||
const parseSkillRegistryInstallPayload = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const identifier = readOptionalQueryString(body.identifier);
|
||||
if (!identifier) {
|
||||
throw new AppError('identifier is required.', {
|
||||
code: 'SKILL_IDENTIFIER_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
identifier,
|
||||
category: readOptionalQueryString(body.category),
|
||||
name: readOptionalQueryString(body.name),
|
||||
force: body.force === true,
|
||||
};
|
||||
};
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (
|
||||
@@ -186,6 +330,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
|| normalized === 'hermes'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -196,6 +341,18 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
});
|
||||
};
|
||||
|
||||
const parseHermesProvider = (value: unknown): 'hermes' => {
|
||||
const provider = parseProvider(value);
|
||||
if (provider !== 'hermes') {
|
||||
throw new AppError('Gateway controls are only available for Hermes.', {
|
||||
code: 'HERMES_GATEWAY_UNSUPPORTED_PROVIDER',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
const parseSessionRenameSummary = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
@@ -319,6 +476,98 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const input = parseProviderSkillCreatePayload(req.body);
|
||||
const skills = await providerSkillsService.addProviderSkills(provider, input);
|
||||
res.json(createApiSuccessResponse({ provider, skills }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/skills/:directoryName',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.removeProviderSkill(provider, {
|
||||
directoryName: readPathParam(req.params.directoryName, 'directoryName'),
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/skills/registry/search',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const query = readOptionalQueryString(req.query.query);
|
||||
if (!query) {
|
||||
throw new AppError('query is required.', {
|
||||
code: 'SKILL_SEARCH_QUERY_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await providerSkillsService.searchSkillRegistry(provider, query, {
|
||||
source: readOptionalQueryString(req.query.source),
|
||||
limit: parseSkillRegistryLimit(req.query.limit),
|
||||
});
|
||||
res.json(createApiSuccessResponse({ provider, results }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/install',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.installRegistrySkill(
|
||||
provider,
|
||||
parseSkillRegistryInstallPayload(req.body),
|
||||
);
|
||||
res.status(201).json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/check',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.checkRegistryUpdates(provider);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/update',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.updateRegistrySkills(provider);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/audit',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.auditRegistrySkills(provider);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/skills/registry/:name',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.uninstallRegistrySkill(
|
||||
provider,
|
||||
readPathParam(req.params.name, 'name'),
|
||||
);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
@@ -382,7 +631,96 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/capabilities',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
res.json(createApiSuccessResponse({
|
||||
providers: providerCapabilitiesService.listAllProviderCapabilities(),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/capabilities',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
res.json(createApiSuccessResponse(
|
||||
providerCapabilitiesService.getProviderCapabilities(provider),
|
||||
));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Hermes gateway routes -----------------
|
||||
router.get(
|
||||
'/:provider/gateway/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.getStatus();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/gateway/logs',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
res.json(createApiSuccessResponse({ logs: hermesGatewayService.getLogs() }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/start',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.start();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/stop',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.stop();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/gateway/restart',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
parseHermesProvider(req.params.provider);
|
||||
const status = await hermesGatewayService.restart();
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Session routes -----------------
|
||||
/**
|
||||
* Session gateway entry point: allocates the stable app-facing session id for
|
||||
* a brand-new chat. The frontend must call this before the first `chat.send`
|
||||
* so the session id in the URL, the store, and the websocket all agree from
|
||||
* the very first message — there is no client-visible session-id handoff.
|
||||
*/
|
||||
router.post(
|
||||
'/sessions',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const provider = parseProvider(body.provider);
|
||||
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : '';
|
||||
const result = sessionsService.createAppSession(provider, projectPath);
|
||||
res.status(201).json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/sessions/running',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
const sessions = sessionsService.listRunningSessions();
|
||||
res.json(createApiSuccessResponse({ sessions }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/sessions/archived',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
@@ -459,7 +797,7 @@ router.get(
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
317
server/modules/providers/services/hermes-gateway.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const gatewayCommandParts = (process.env.HERMES_GATEWAY_COMMAND || '').trim().split(/\s+/).filter(Boolean);
|
||||
const fallbackHermesCommand = (
|
||||
process.env.HERMES_COMMAND_PATH
|
||||
|| process.env.HERMES_CLI_PATH
|
||||
|| 'hermes'
|
||||
).trim().split(/\s+/)[0] || 'hermes';
|
||||
const HERMES_COMMAND = gatewayCommandParts[0] || fallbackHermesCommand;
|
||||
const HERMES_BASE_ARGS = gatewayCommandParts.slice(1);
|
||||
|
||||
const MAX_LOG_LINES = 300;
|
||||
const COMMAND_TIMEOUT_MS = 10_000;
|
||||
|
||||
type CommandResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type HermesGatewayProfile = {
|
||||
name: string;
|
||||
current: boolean;
|
||||
model: string | null;
|
||||
gateway: string | null;
|
||||
alias: string | null;
|
||||
distribution: string | null;
|
||||
};
|
||||
|
||||
export type HermesGatewayStatus = {
|
||||
installed: boolean;
|
||||
command: string;
|
||||
version: string | null;
|
||||
running: boolean;
|
||||
managedByCloudCLI: boolean;
|
||||
state: 'running' | 'stopped' | 'unknown';
|
||||
statusOutput: string;
|
||||
profiles: HermesGatewayProfile[];
|
||||
logs: string[];
|
||||
lastExit: {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
at: string;
|
||||
} | null;
|
||||
commands: {
|
||||
setup: string;
|
||||
run: string;
|
||||
};
|
||||
};
|
||||
|
||||
const removeAnsi = (value: string): string => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
|
||||
const compactOutput = (result: CommandResult): string => (
|
||||
[result.stdout, result.stderr].filter(Boolean).join('\n').trim()
|
||||
);
|
||||
|
||||
const parseGatewayRunning = (output: string, managedByCloudCLI: boolean): boolean => {
|
||||
if (managedByCloudCLI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = output.toLowerCase();
|
||||
if (/\b(not running|stopped|inactive|failed)\b/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /\b(running|active)\b/.test(normalized);
|
||||
};
|
||||
|
||||
const parseProfiles = (output: string): HermesGatewayProfile[] => {
|
||||
const profiles: HermesGatewayProfile[] = [];
|
||||
for (const rawLine of removeAnsi(output).split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('Profile ') || /^[-─\s]+$/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = line.startsWith('◆');
|
||||
const cleaned = line.replace(/^[◆*]\s*/, '').trim();
|
||||
const columns = cleaned.split(/\s{2,}/).map((column) => column.trim());
|
||||
const [name, model, gateway, alias, distribution] = columns;
|
||||
if (!name || name.toLowerCase() === 'profile') {
|
||||
continue;
|
||||
}
|
||||
|
||||
profiles.push({
|
||||
name,
|
||||
current,
|
||||
model: model && model !== '—' ? model : null,
|
||||
gateway: gateway && gateway !== '—' ? gateway : null,
|
||||
alias: alias && alias !== '—' ? alias : null,
|
||||
distribution: distribution && distribution !== '—' ? distribution : null,
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
class HermesGatewayService {
|
||||
private gatewayProcess: ChildProcess | null = null;
|
||||
private readonly logs: string[] = [];
|
||||
private lastExit: HermesGatewayStatus['lastExit'] = null;
|
||||
|
||||
async getStatus(): Promise<HermesGatewayStatus> {
|
||||
const versionResult = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
const installed = versionResult.exitCode === 0;
|
||||
const version = installed ? compactOutput(versionResult).split(/\r?\n/)[0] || null : null;
|
||||
|
||||
const statusResult = installed
|
||||
? await this.runHermes(['gateway', 'status'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: versionResult.error || 'Hermes is not installed.', exitCode: 1 };
|
||||
const profilesResult = installed
|
||||
? await this.runHermes(['profile', 'list'], { timeout: COMMAND_TIMEOUT_MS })
|
||||
: { stdout: '', stderr: '', exitCode: 1 };
|
||||
|
||||
const statusOutput = compactOutput(statusResult);
|
||||
const managedByCloudCLI = this.isManagedProcessRunning();
|
||||
const running = parseGatewayRunning(statusOutput, managedByCloudCLI);
|
||||
|
||||
return {
|
||||
installed,
|
||||
command: this.commandPrefix().join(' '),
|
||||
version,
|
||||
running,
|
||||
managedByCloudCLI,
|
||||
state: running ? 'running' : statusOutput ? 'stopped' : 'unknown',
|
||||
statusOutput,
|
||||
profiles: parseProfiles(profilesResult.stdout),
|
||||
logs: this.getLogs(),
|
||||
lastExit: this.lastExit,
|
||||
commands: {
|
||||
setup: [...this.commandPrefix(), 'gateway', 'setup'].join(' '),
|
||||
run: [...this.commandPrefix(), 'gateway', 'run'].join(' '),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<HermesGatewayStatus> {
|
||||
await this.assertInstalled();
|
||||
if (this.isManagedProcessRunning()) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const currentStatus = await this.getStatus();
|
||||
if (currentStatus.running) {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
const args = [...HERMES_BASE_ARGS, 'gateway', 'run', '--accept-hooks'];
|
||||
this.appendLog(`[cloudcli] starting Hermes gateway: ${HERMES_COMMAND} ${args.join(' ')}`);
|
||||
this.lastExit = null;
|
||||
const child = spawn(HERMES_COMMAND, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.gatewayProcess = child;
|
||||
|
||||
child.stdout?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.stderr?.on('data', (chunk) => this.appendLog(String(chunk)));
|
||||
child.on('error', (error) => {
|
||||
this.appendLog(`[cloudcli] gateway process error: ${error.message}`);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
this.lastExit = {
|
||||
code,
|
||||
signal,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.appendLog(`[cloudcli] gateway exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`);
|
||||
this.gatewayProcess = null;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
if (!this.isManagedProcessRunning()) {
|
||||
throw new AppError('Hermes gateway exited before it could start.', {
|
||||
code: 'HERMES_GATEWAY_START_FAILED',
|
||||
statusCode: 500,
|
||||
details: {
|
||||
logs: this.getLogs().slice(-20),
|
||||
lastExit: this.lastExit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning() && this.gatewayProcess) {
|
||||
this.appendLog('[cloudcli] stopping managed Hermes gateway');
|
||||
await this.stopManagedProcess();
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async restart(): Promise<HermesGatewayStatus> {
|
||||
if (this.isManagedProcessRunning()) {
|
||||
await this.stopManagedProcess();
|
||||
} else {
|
||||
await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
return this.start();
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
private async assertInstalled(): Promise<void> {
|
||||
const result = await this.runHermes(['--version'], { timeout: 5000 });
|
||||
if (result.exitCode !== 0) {
|
||||
throw new AppError('Hermes is not installed or is not available on PATH.', {
|
||||
code: 'HERMES_NOT_INSTALLED',
|
||||
statusCode: 400,
|
||||
details: compactOutput(result),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isManagedProcessRunning(): boolean {
|
||||
return Boolean(this.gatewayProcess && !this.gatewayProcess.killed && this.gatewayProcess.exitCode === null);
|
||||
}
|
||||
|
||||
private async stopManagedProcess(): Promise<void> {
|
||||
const child = this.gatewayProcess;
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once('exit', () => resolve());
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (this.gatewayProcess === child && this.isManagedProcessRunning()) {
|
||||
this.appendLog('[cloudcli] gateway did not stop after SIGTERM; sending SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async runHermes(args: string[], options: { timeout: number }): Promise<CommandResult> {
|
||||
try {
|
||||
const result = await execFileAsync(HERMES_COMMAND, [...HERMES_BASE_ARGS, ...args], {
|
||||
timeout: options.timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_ACCEPT_HOOKS: '1',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number | string | null;
|
||||
};
|
||||
|
||||
return {
|
||||
stdout: execError.stdout ?? '',
|
||||
stderr: execError.stderr ?? '',
|
||||
exitCode: typeof execError.code === 'number' ? execError.code : 1,
|
||||
error: execError.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private appendLog(chunk: string): void {
|
||||
const lines = removeAnsi(chunk)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logs.push(...lines.map((line) => `${new Date().toISOString()} ${line}`));
|
||||
if (this.logs.length > MAX_LOG_LINES) {
|
||||
this.logs.splice(0, this.logs.length - MAX_LOG_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
private commandPrefix(): string[] {
|
||||
return [HERMES_COMMAND, ...HERMES_BASE_ARGS];
|
||||
}
|
||||
}
|
||||
|
||||
export const hermesGatewayService = new HermesGatewayService();
|
||||
@@ -80,4 +80,30 @@ export const providerMcpService = {
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes one MCP server from every provider. Mirrors `addMcpServerToAllProviders`
|
||||
* by iterating the live provider registry, so callers stay in sync with which
|
||||
* providers exist instead of maintaining their own provider list.
|
||||
*/
|
||||
async removeMcpServerFromAllProviders(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<Array<{ provider: LLMProvider; removed: boolean; error?: string }>> {
|
||||
const results: Array<{ provider: LLMProvider; removed: boolean; error?: string }> = [];
|
||||
const providers = providerRegistry.listProviders();
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const result = await provider.mcp.removeServer(input);
|
||||
results.push({ provider: provider.id, removed: result.removed });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
provider: provider.id,
|
||||
removed: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Static, backend-owned description of what one provider integration supports.
|
||||
*
|
||||
* The frontend renders its composer UI (permission mode picker, image upload,
|
||||
* abort button, ...) purely from this shape, which is what keeps the frontend
|
||||
* free of per-provider conditionals. New provider features should be exposed
|
||||
* here instead of branching on the provider id in React components.
|
||||
*/
|
||||
type ProviderCapabilities = {
|
||||
provider: LLMProvider;
|
||||
/** Permission modes the provider runtime understands, in cycle order. */
|
||||
permissionModes: string[];
|
||||
defaultPermissionMode: string;
|
||||
/** Whether image attachments can be included in a chat.send. */
|
||||
supportsImages: boolean;
|
||||
/** Whether an in-flight run can be cancelled via chat.abort. */
|
||||
supportsAbort: boolean;
|
||||
/** Whether interactive tool permission prompts can reach the UI. */
|
||||
supportsPermissionRequests: boolean;
|
||||
/** Whether the token-usage endpoint has data for this provider. */
|
||||
supportsTokenUsage: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The capability matrix mirrors what each runtime actually implements today:
|
||||
* - permission modes match the option sets accepted by each CLI/SDK.
|
||||
* - only the Claude SDK integration surfaces interactive permission requests.
|
||||
* - Cursor has no token usage endpoint support (its store.db has no usage rows).
|
||||
*/
|
||||
const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
claude: {
|
||||
provider: 'claude',
|
||||
permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: true,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: true,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
cursor: {
|
||||
provider: 'cursor',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: false,
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
opencode: {
|
||||
provider: 'opencode',
|
||||
permissionModes: ['default'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
hermes: {
|
||||
provider: 'hermes',
|
||||
permissionModes: ['default'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: true,
|
||||
supportsTokenUsage: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Application service exposing the provider capability matrix.
|
||||
*/
|
||||
export const providerCapabilitiesService = {
|
||||
getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
|
||||
return PROVIDER_CAPABILITIES[provider];
|
||||
},
|
||||
|
||||
listAllProviderCapabilities(): ProviderCapabilities[] {
|
||||
return Object.values(PROVIDER_CAPABILITIES);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user