Codex/pr 1217 (#1226)
* bundle node and windows git runtimes * split desktop runtime into release package * fix desktop runtime packaging ci * embed desktop runtime release tag * show desktop runtime download progress * fix desktop runtime release handling * refactor desktop runtime version config * fix desktop package license --------- Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com> Co-authored-by: ekko <fqsy1416@gmail.com>
This commit is contained in:
@@ -24,6 +24,10 @@ on:
|
|||||||
description: "Optional release tag to attach artifacts to"
|
description: "Optional release tag to attach artifacts to"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
runtime_release_tag:
|
||||||
|
description: "Optional runtime release tag embedded into the desktop app"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -121,9 +125,6 @@ jobs:
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
packages/desktop/package-lock.json
|
packages/desktop/package-lock.json
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v3
|
|
||||||
|
|
||||||
- name: Install web UI dependencies
|
- name: Install web UI dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts
|
npm ci --ignore-scripts
|
||||||
@@ -138,11 +139,11 @@ jobs:
|
|||||||
- name: Install desktop dependencies
|
- name: Install desktop dependencies
|
||||||
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
||||||
|
|
||||||
- name: Prepare bundled Python
|
- name: Write runtime release metadata
|
||||||
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TARGET_OS: ${{ needs.validate.outputs.target_os }}
|
RUNTIME_RELEASE_TAG: ${{ github.event.inputs.runtime_release_tag }}
|
||||||
TARGET_ARCH: ${{ needs.validate.outputs.target_arch }}
|
run: npm --prefix packages/desktop run write:runtime-release
|
||||||
run: npm --prefix packages/desktop run prepare:python
|
|
||||||
|
|
||||||
- name: Configure macOS signing
|
- name: Configure macOS signing
|
||||||
if: needs.validate.outputs.target_os == 'darwin'
|
if: needs.validate.outputs.target_os == 'darwin'
|
||||||
|
|||||||
@@ -86,9 +86,6 @@ jobs:
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
packages/desktop/package-lock.json
|
packages/desktop/package-lock.json
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v3
|
|
||||||
|
|
||||||
- name: Install web UI dependencies
|
- name: Install web UI dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci --ignore-scripts
|
npm ci --ignore-scripts
|
||||||
@@ -103,11 +100,11 @@ jobs:
|
|||||||
- name: Install desktop dependencies
|
- name: Install desktop dependencies
|
||||||
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
||||||
|
|
||||||
- name: Prepare bundled Python
|
- name: Write runtime release metadata
|
||||||
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TARGET_OS: ${{ matrix.target_os }}
|
HERMES_DESKTOP_RUNTIME_RELEASE_TAG: ${{ vars.HERMES_DESKTOP_RUNTIME_RELEASE_TAG }}
|
||||||
TARGET_ARCH: ${{ matrix.target_arch }}
|
run: npm --prefix packages/desktop run write:runtime-release
|
||||||
run: npm --prefix packages/desktop run prepare:python
|
|
||||||
|
|
||||||
- name: Configure macOS signing
|
- name: Configure macOS signing
|
||||||
if: matrix.target_os == 'darwin'
|
if: matrix.target_os == 'darwin'
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
name: Publish Desktop Runtime to Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Existing release tag to attach runtime assets to"
|
||||||
|
required: true
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-runtime-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
runtime:
|
||||||
|
name: Runtime (${{ matrix.label }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- label: macOS arm64
|
||||||
|
runner: macos-14
|
||||||
|
target_os: darwin
|
||||||
|
target_arch: arm64
|
||||||
|
- label: macOS x64
|
||||||
|
runner: macos-15-intel
|
||||||
|
target_os: darwin
|
||||||
|
target_arch: x64
|
||||||
|
- label: Windows x64
|
||||||
|
runner: windows-latest
|
||||||
|
target_os: win32
|
||||||
|
target_arch: x64
|
||||||
|
- label: Linux x64
|
||||||
|
runner: ubuntu-22.04
|
||||||
|
target_os: linux
|
||||||
|
target_arch: x64
|
||||||
|
- label: Linux arm64
|
||||||
|
runner: ubuntu-22.04-arm
|
||||||
|
target_os: linux
|
||||||
|
target_arch: arm64
|
||||||
|
skip_browser_runtime: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
||||||
|
|
||||||
|
- name: Resolve runtime asset names
|
||||||
|
id: names
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TARGET_OS: ${{ matrix.target_os }}
|
||||||
|
TARGET_ARCH: ${{ matrix.target_arch }}
|
||||||
|
run: |
|
||||||
|
echo "asset=$(node packages/desktop/scripts/runtime-asset-name.mjs)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "manifest=$(node packages/desktop/scripts/runtime-asset-name.mjs --manifest)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Check existing release assets
|
||||||
|
id: check
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
||||||
|
ASSET: ${{ steps.names.outputs.asset }}
|
||||||
|
MANIFEST: ${{ steps.names.outputs.manifest }}
|
||||||
|
run: |
|
||||||
|
assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' || true)"
|
||||||
|
if printf '%s\n' "$assets" | grep -Fx "$ASSET" >/dev/null \
|
||||||
|
&& printf '%s\n' "$assets" | grep -Fx "$MANIFEST" >/dev/null; then
|
||||||
|
echo "missing=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Runtime asset already exists: $ASSET"
|
||||||
|
else
|
||||||
|
echo "missing=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Runtime asset missing: $ASSET or $MANIFEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.check.outputs.missing == 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: packages/desktop/package-lock.json
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
if: steps.check.outputs.missing == 'true'
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
if: steps.check.outputs.missing == 'true'
|
||||||
|
run: npm ci --prefix packages/desktop --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Prepare runtime resources
|
||||||
|
if: steps.check.outputs.missing == 'true'
|
||||||
|
env:
|
||||||
|
TARGET_OS: ${{ matrix.target_os }}
|
||||||
|
TARGET_ARCH: ${{ matrix.target_arch }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
HERMES_SKIP_BROWSER_RUNTIME: ${{ matrix.skip_browser_runtime || 'false' }}
|
||||||
|
run: npm --prefix packages/desktop run prepare:runtime
|
||||||
|
|
||||||
|
- name: Package runtime
|
||||||
|
if: steps.check.outputs.missing == 'true'
|
||||||
|
env:
|
||||||
|
TARGET_OS: ${{ matrix.target_os }}
|
||||||
|
TARGET_ARCH: ${{ matrix.target_arch }}
|
||||||
|
run: npm --prefix packages/desktop run package:runtime
|
||||||
|
|
||||||
|
- name: Upload runtime assets to release
|
||||||
|
if: steps.check.outputs.missing == 'true'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
files: |
|
||||||
|
packages/desktop/release/runtime/${{ steps.names.outputs.asset }}
|
||||||
|
packages/desktop/release/runtime/${{ steps.names.outputs.asset }}.sha256
|
||||||
|
packages/desktop/release/runtime/${{ steps.names.outputs.manifest }}
|
||||||
+5
-4
@@ -50,11 +50,12 @@
|
|||||||
"build:website": "vite build --config vite.config.website.ts",
|
"build:website": "vite build --config vite.config.website.ts",
|
||||||
"preview:website": "vite preview --config vite.config.website.ts",
|
"preview:website": "vite preview --config vite.config.website.ts",
|
||||||
"desktop:install": "npm ci --prefix packages/desktop --no-audit --no-fund",
|
"desktop:install": "npm ci --prefix packages/desktop --no-audit --no-fund",
|
||||||
|
"desktop:prepare-runtime": "npm --prefix packages/desktop run prepare:runtime",
|
||||||
"desktop:prepare-python": "npm --prefix packages/desktop run prepare:python",
|
"desktop:prepare-python": "npm --prefix packages/desktop run prepare:python",
|
||||||
"build:desktop": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --publish never",
|
"build:desktop": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --publish never",
|
||||||
"build:desktop:mac": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --mac --publish never",
|
"build:desktop:mac": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --mac --publish never",
|
||||||
"build:desktop:win": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --win --publish never",
|
"build:desktop:win": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --win --publish never",
|
||||||
"build:desktop:linux": "npm run build && npm run desktop:install && npm run desktop:prepare-python && npm --prefix packages/desktop run dist -- --linux --publish never",
|
"build:desktop:linux": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --linux --publish never",
|
||||||
"openapi:generate": "node scripts/generate-openapi.mjs",
|
"openapi:generate": "node scripts/generate-openapi.mjs",
|
||||||
"claude": "claude --dangerously-skip-permissions"
|
"claude": "claude --dangerously-skip-permissions"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"tag": "hermes-0.15.2-runtime"
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ files:
|
|||||||
- "!**/node_modules/.bin"
|
- "!**/node_modules/.bin"
|
||||||
- "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}"
|
- "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}"
|
||||||
|
|
||||||
# Web UI source (built dist) and bundled Python live outside the asar.
|
# Web UI source (built dist) lives outside the asar. Python/Node/Git runtime
|
||||||
|
# assets are downloaded into the user's Web UI home on first launch.
|
||||||
# This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root.
|
# This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root.
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: "build"
|
- from: "build"
|
||||||
@@ -32,6 +33,7 @@ extraResources:
|
|||||||
- "icon.ico"
|
- "icon.ico"
|
||||||
- "trayTemplate.png"
|
- "trayTemplate.png"
|
||||||
- "trayWindows.png"
|
- "trayWindows.png"
|
||||||
|
- "runtime-release.json"
|
||||||
- from: "../.."
|
- from: "../.."
|
||||||
to: "webui"
|
to: "webui"
|
||||||
filter:
|
filter:
|
||||||
@@ -44,10 +46,6 @@ extraResources:
|
|||||||
- "!packages/desktop/**"
|
- "!packages/desktop/**"
|
||||||
- "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}"
|
- "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}"
|
||||||
- "!node_modules/**/*.md"
|
- "!node_modules/**/*.md"
|
||||||
- from: "resources/python/${os}-${arch}"
|
|
||||||
to: "python"
|
|
||||||
filter:
|
|
||||||
- "**/*"
|
|
||||||
|
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
|
|||||||
Generated
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "hermes-studio",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.7",
|
"version": "0.6.7",
|
||||||
"license": "MIT",
|
"license": "BSL-1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.3.9"
|
"electron-updater": "^6.3.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,16 +7,22 @@
|
|||||||
"name": "Hermes Studio Contributors",
|
"name": "Hermes Studio Contributors",
|
||||||
"email": "noreply@hermes-studio.local"
|
"email": "noreply@hermes-studio.local"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "BSL-1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/main/index.js",
|
"main": "dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:main": "tsc -p tsconfig.json",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"build": "npm run build:main",
|
"build": "npm run build:main",
|
||||||
|
"fetch:node": "node scripts/fetch-node.mjs",
|
||||||
|
"fetch:git": "node scripts/fetch-git.mjs",
|
||||||
"fetch:python": "node scripts/fetch-python.mjs",
|
"fetch:python": "node scripts/fetch-python.mjs",
|
||||||
"install:hermes": "node scripts/install-hermes.mjs",
|
"install:hermes": "node scripts/install-hermes.mjs",
|
||||||
"patch:hermes": "node scripts/apply-hermes-patches.mjs",
|
"patch:hermes": "node scripts/apply-hermes-patches.mjs",
|
||||||
"prepare:python": "npm run fetch:python && npm run install:hermes && npm run patch:hermes && npm run prune:python",
|
"write:runtime-release": "node scripts/write-runtime-release.mjs",
|
||||||
|
"prepare:runtime": "npm run fetch:node && npm run fetch:git && npm run fetch:python && npm run install:hermes && npm run patch:hermes && npm run prune:python",
|
||||||
|
"prepare:python": "npm run prepare:runtime",
|
||||||
|
"package:runtime": "node scripts/package-runtime.mjs",
|
||||||
|
"runtime:asset-name": "node scripts/runtime-asset-name.mjs",
|
||||||
"prune:python": "node scripts/prune-python.mjs",
|
"prune:python": "node scripts/prune-python.mjs",
|
||||||
"dev": "npm run build:main && electron .",
|
"dev": "npm run build:main && electron .",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Download Git for Windows MinGit for Windows builds. Other platforms create
|
||||||
|
// an empty resource directory so electron-builder can use the same resource map.
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
|
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||||
|
const OUT_DIR = resolve(ROOT, 'resources', 'git', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||||
|
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true })
|
||||||
|
|
||||||
|
if (TARGET_OS !== 'win32') {
|
||||||
|
writeFileSync(resolve(OUT_DIR, '.placeholder'), 'Git for Windows is only bundled on Windows.\n')
|
||||||
|
console.log(`Git resource placeholder ready at ${OUT_DIR}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TARGET_ARCH !== 'x64') {
|
||||||
|
console.error(`Unsupported Git for Windows target: ${TARGET_OS}-${TARGET_ARCH}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(resolve(OUT_DIR, 'cmd', 'git.exe'))) {
|
||||||
|
console.log(`Git for Windows already present at ${OUT_DIR}, skipping`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestMinGitUrl() {
|
||||||
|
if (process.env.GIT_FOR_WINDOWS_URL?.trim()) return process.env.GIT_FOR_WINDOWS_URL.trim()
|
||||||
|
|
||||||
|
const headers = { 'User-Agent': 'hermes-studio-desktop-build' }
|
||||||
|
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||||
|
if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`
|
||||||
|
|
||||||
|
const response = await fetch('https://api.github.com/repos/git-for-windows/git/releases/latest', {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API returned ${response.status}`)
|
||||||
|
}
|
||||||
|
const release = await response.json()
|
||||||
|
const asset = release.assets?.find(candidate =>
|
||||||
|
typeof candidate?.name === 'string'
|
||||||
|
&& /^MinGit-.*-64-bit\.zip$/.test(candidate.name)
|
||||||
|
&& typeof candidate.browser_download_url === 'string',
|
||||||
|
)
|
||||||
|
if (!asset) throw new Error('Could not find MinGit 64-bit zip in latest Git for Windows release')
|
||||||
|
return asset.browser_download_url
|
||||||
|
}
|
||||||
|
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = await latestMinGitUrl()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to resolve Git for Windows download URL: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = url.split('/').pop() || 'mingit.zip'
|
||||||
|
const archivePath = resolve(tmpdir(), file)
|
||||||
|
|
||||||
|
console.log(`Fetching ${url}`)
|
||||||
|
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' })
|
||||||
|
if (curl.status !== 0) {
|
||||||
|
console.error('curl failed')
|
||||||
|
process.exit(curl.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Extracting into ${OUT_DIR}`)
|
||||||
|
const extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR], { stdio: 'inherit' })
|
||||||
|
if (extract.status !== 0) {
|
||||||
|
console.error('extract failed')
|
||||||
|
process.exit(extract.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(archivePath, { force: true })
|
||||||
|
console.log(`Git for Windows ready at ${OUT_DIR}`)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Download a portable Node.js runtime for the current (or target) platform/arch
|
||||||
|
// and extract into resources/node/<os>-<arch>/.
|
||||||
|
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
|
const NODE_VERSION = (process.env.HERMES_DESKTOP_NODE_VERSION || process.env.NODE_VERSION || process.versions.node).replace(/^v/, '')
|
||||||
|
|
||||||
|
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||||
|
const OUT_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||||
|
|
||||||
|
const DIST_PLATFORM = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'darwin' : TARGET_OS
|
||||||
|
const DIST_ARCH = TARGET_ARCH === 'x64' ? 'x64' : TARGET_ARCH === 'arm64' ? 'arm64' : ''
|
||||||
|
if (!DIST_ARCH || !['win', 'darwin', 'linux'].includes(DIST_PLATFORM)) {
|
||||||
|
console.error(`Unsupported target: ${TARGET_OS}-${TARGET_ARCH}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = TARGET_OS === 'win32' ? 'zip' : 'tar.gz'
|
||||||
|
const file = `node-v${NODE_VERSION}-${DIST_PLATFORM}-${DIST_ARCH}.${ext}`
|
||||||
|
const baseUrl = (process.env.NODE_DIST_BASE_URL || 'https://nodejs.org/dist').replace(/\/$/, '')
|
||||||
|
const url = `${baseUrl}/v${NODE_VERSION}/${file}`
|
||||||
|
const marker = TARGET_OS === 'win32' ? 'node.exe' : join('bin', 'node')
|
||||||
|
|
||||||
|
if (existsSync(resolve(OUT_DIR, marker))) {
|
||||||
|
console.log(`Node.js already present at ${OUT_DIR}, skipping`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true })
|
||||||
|
const archivePath = resolve(tmpdir(), file)
|
||||||
|
|
||||||
|
console.log(`Fetching ${url}`)
|
||||||
|
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' })
|
||||||
|
if (curl.status !== 0) {
|
||||||
|
console.error('curl failed')
|
||||||
|
process.exit(curl.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Extracting into ${OUT_DIR}`)
|
||||||
|
let extract
|
||||||
|
if (TARGET_OS === 'win32') {
|
||||||
|
extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||||
|
} else {
|
||||||
|
extract = spawnSync('tar', ['-xzf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||||
|
}
|
||||||
|
if (extract.status !== 0) {
|
||||||
|
console.error('extract failed')
|
||||||
|
process.exit(extract.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(archivePath, { force: true })
|
||||||
|
console.log(`Node.js ready at ${OUT_DIR}`)
|
||||||
@@ -18,13 +18,14 @@ import { basename, resolve, dirname, join } from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os'
|
import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os'
|
||||||
|
import { hermesVersion } from './runtime-config.mjs'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const ROOT = resolve(__dirname, '..')
|
const ROOT = resolve(__dirname, '..')
|
||||||
|
|
||||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
|
const HERMES_VERSION = hermesVersion()
|
||||||
// Match the packaged runtime to the channel list exposed at /hermes/channels.
|
// Match the packaged runtime to the channel list exposed at /hermes/channels.
|
||||||
// Telegram, Discord, and Slack are covered by "messaging". We intentionally
|
// Telegram, Discord, and Slack are covered by "messaging". We intentionally
|
||||||
// install Matrix's plaintext deps below instead of using the "matrix" extra:
|
// install Matrix's plaintext deps below instead of using the "matrix" extra:
|
||||||
@@ -59,6 +60,7 @@ const SKIP_BROWSER_RUNTIME = process.env.HERMES_SKIP_BROWSER_RUNTIME === '1'
|
|||||||
|
|
||||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||||
|
const NODE_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||||
const NODE_PREFIX = resolve(PY_DIR, 'node')
|
const NODE_PREFIX = resolve(PY_DIR, 'node')
|
||||||
const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser')
|
const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser')
|
||||||
const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright')
|
const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright')
|
||||||
@@ -96,7 +98,8 @@ function optionalRun(command, args, options = {}) {
|
|||||||
|
|
||||||
function commandInvocation(command) {
|
function commandInvocation(command) {
|
||||||
if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) {
|
if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) {
|
||||||
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', command] }
|
const cmdCommand = /[\s&()[\]{}^=;!'+,`~]/.test(command) ? `"${command}"` : command
|
||||||
|
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', cmdCommand] }
|
||||||
}
|
}
|
||||||
return { command, argsPrefix: [] }
|
return { command, argsPrefix: [] }
|
||||||
}
|
}
|
||||||
@@ -109,15 +112,25 @@ function optionalRunInvocation(invocation, args, options = {}) {
|
|||||||
return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options)
|
return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pythonBuildEnv() {
|
||||||
|
if (TARGET_OS !== 'darwin') return process.env
|
||||||
|
|
||||||
|
const env = { ...process.env }
|
||||||
|
if (!env.AR && existsSync('/usr/bin/ar')) env.AR = '/usr/bin/ar'
|
||||||
|
if (!env.RANLIB && existsSync('/usr/bin/ranlib')) env.RANLIB = '/usr/bin/ranlib'
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
function installPythonPackages(packages, label) {
|
function installPythonPackages(packages, label) {
|
||||||
if (packages.length === 0) return
|
if (packages.length === 0) return
|
||||||
|
const env = pythonBuildEnv()
|
||||||
if (hasUv()) {
|
if (hasUv()) {
|
||||||
console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`)
|
console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`)
|
||||||
run('uv', [
|
run('uv', [
|
||||||
'pip', 'install',
|
'pip', 'install',
|
||||||
'--python', pyBin,
|
'--python', pyBin,
|
||||||
...packages,
|
...packages,
|
||||||
])
|
], { env })
|
||||||
} else {
|
} else {
|
||||||
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
|
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
|
||||||
run(pyBin, [
|
run(pyBin, [
|
||||||
@@ -125,17 +138,21 @@ function installPythonPackages(packages, label) {
|
|||||||
...packages,
|
...packages,
|
||||||
'--no-warn-script-location',
|
'--no-warn-script-location',
|
||||||
'--disable-pip-version-check',
|
'--disable-pip-version-check',
|
||||||
])
|
], { env })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function npmCommand() {
|
function npmCommand() {
|
||||||
|
const bundled = TARGET_OS === 'win32'
|
||||||
|
? resolve(NODE_DIR, 'npm.cmd')
|
||||||
|
: resolve(NODE_DIR, 'bin', 'npm')
|
||||||
const candidates = TARGET_OS === 'win32'
|
const candidates = TARGET_OS === 'win32'
|
||||||
? ['npm.cmd', 'npm.exe', 'npm']
|
? [bundled, 'npm.cmd', 'npm.exe', 'npm']
|
||||||
: ['npm']
|
: [bundled, 'npm']
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
|
if (candidate === bundled && !existsSync(candidate)) continue
|
||||||
const invocation = commandInvocation(candidate)
|
const invocation = commandInvocation(candidate)
|
||||||
const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore' })
|
const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore', env: browserRuntimeEnv(false) })
|
||||||
if (result.status === 0) return invocation
|
if (result.status === 0) return invocation
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -148,16 +165,24 @@ function agentBrowserCommand() {
|
|||||||
return resolve(NODE_PREFIX, 'bin', 'agent-browser')
|
return resolve(NODE_PREFIX, 'bin', 'agent-browser')
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserRuntimeEnv() {
|
function browserRuntimeEnv(includeAgentBrowser = true) {
|
||||||
|
const bundledNodeBin = TARGET_OS === 'win32'
|
||||||
|
? NODE_DIR
|
||||||
|
: resolve(NODE_DIR, 'bin')
|
||||||
const nodePath = TARGET_OS === 'win32'
|
const nodePath = TARGET_OS === 'win32'
|
||||||
? NODE_PREFIX
|
? NODE_PREFIX
|
||||||
: resolve(NODE_PREFIX, 'bin')
|
: resolve(NODE_PREFIX, 'bin')
|
||||||
const inheritedPath = process.env.PATH || process.env.Path || ''
|
const inheritedPath = process.env.PATH || process.env.Path || ''
|
||||||
const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH'
|
const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH'
|
||||||
const browserExecutable = ensureBundledBrowserExecutable()
|
const browserExecutable = includeAgentBrowser ? ensureBundledBrowserExecutable() : null
|
||||||
|
const pathEntries = includeAgentBrowser
|
||||||
|
? [nodePath, bundledNodeBin, inheritedPath]
|
||||||
|
: [bundledNodeBin, inheritedPath]
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
[pathKey]: [nodePath, inheritedPath].filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'),
|
[pathKey]: pathEntries.filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'),
|
||||||
|
HERMES_AGENT_NODE: TARGET_OS === 'win32' ? resolve(NODE_DIR, 'node.exe') : resolve(NODE_DIR, 'bin', 'node'),
|
||||||
|
HERMES_AGENT_NODE_ROOT: NODE_DIR,
|
||||||
AGENT_BROWSER_HOME,
|
AGENT_BROWSER_HOME,
|
||||||
PLAYWRIGHT_BROWSERS_PATH,
|
PLAYWRIGHT_BROWSERS_PATH,
|
||||||
}
|
}
|
||||||
@@ -447,4 +472,8 @@ if (!SKIP_BROWSER_RUNTIME) {
|
|||||||
], { env: browserRuntimeEnv() })
|
], { env: browserRuntimeEnv() })
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed')
|
if (SKIP_BROWSER_RUNTIME) {
|
||||||
|
console.log('✓ hermes Python, MCP, and websockets checks passed; browser runtime skipped')
|
||||||
|
} else {
|
||||||
|
console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Package prepared Python/Node/Git runtime resources into a release asset.
|
||||||
|
import {
|
||||||
|
cpSync,
|
||||||
|
createReadStream,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, '..')
|
||||||
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
|
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||||
|
const PLATFORM = `${OS_LABEL}-${TARGET_ARCH}`
|
||||||
|
const OUT_DIR = resolve(ROOT, 'release', 'runtime')
|
||||||
|
|
||||||
|
const PY_DIR = resolve(ROOT, 'resources', 'python', PLATFORM)
|
||||||
|
const NODE_DIR = resolve(ROOT, 'resources', 'node', PLATFORM)
|
||||||
|
const GIT_DIR = resolve(ROOT, 'resources', 'git', PLATFORM)
|
||||||
|
const pyBin = TARGET_OS === 'win32'
|
||||||
|
? resolve(PY_DIR, 'python.exe')
|
||||||
|
: resolve(PY_DIR, 'bin', 'python3')
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, { stdio: 'inherit', ...options })
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function output(command, args) {
|
||||||
|
const result = spawnSync(command, args, { encoding: 'utf-8' })
|
||||||
|
if (result.status !== 0) {
|
||||||
|
process.stderr.write(result.stderr || result.stdout || '')
|
||||||
|
process.exit(result.status ?? 1)
|
||||||
|
}
|
||||||
|
return result.stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(file) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
await new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
const stream = createReadStream(file)
|
||||||
|
stream.on('data', chunk => hash.update(chunk))
|
||||||
|
stream.on('end', resolvePromise)
|
||||||
|
stream.on('error', rejectPromise)
|
||||||
|
})
|
||||||
|
return hash.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of [PY_DIR, NODE_DIR]) {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
console.error(`Runtime directory missing: ${dir}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesAgentVersion = output(pyBin, [
|
||||||
|
'-c',
|
||||||
|
'import importlib.metadata as m; print(m.version("hermes-agent"))',
|
||||||
|
])
|
||||||
|
const assetName = `hermes-runtime-hermes-agent-${hermesAgentVersion}-${PLATFORM}.tar.gz`
|
||||||
|
const manifestName = `hermes-runtime-${PLATFORM}.json`
|
||||||
|
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true })
|
||||||
|
const stage = mkdtempSync(join(tmpdir(), `hermes-runtime-${PLATFORM}-`))
|
||||||
|
|
||||||
|
try {
|
||||||
|
cpSync(PY_DIR, join(stage, 'python'), { recursive: true, force: true, verbatimSymlinks: true })
|
||||||
|
cpSync(NODE_DIR, join(stage, 'node'), { recursive: true, force: true, verbatimSymlinks: true })
|
||||||
|
if (existsSync(GIT_DIR)) {
|
||||||
|
cpSync(GIT_DIR, join(stage, 'git'), { recursive: true, force: true, verbatimSymlinks: true })
|
||||||
|
} else {
|
||||||
|
mkdirSync(join(stage, 'git'), { recursive: true })
|
||||||
|
writeFileSync(join(stage, 'git', '.placeholder'), 'Git for Windows is only bundled on Windows.\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeManifest = {
|
||||||
|
schema: 1,
|
||||||
|
platform: PLATFORM,
|
||||||
|
targetOs: TARGET_OS,
|
||||||
|
targetArch: TARGET_ARCH,
|
||||||
|
hermesAgentVersion,
|
||||||
|
asset: {
|
||||||
|
name: assetName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeFileSync(join(stage, 'runtime-manifest.json'), JSON.stringify(runtimeManifest, null, 2) + '\n')
|
||||||
|
|
||||||
|
const assetPath = resolve(OUT_DIR, assetName)
|
||||||
|
rmSync(assetPath, { force: true })
|
||||||
|
run('tar', ['-czf', assetPath, '-C', stage, '.'])
|
||||||
|
|
||||||
|
const sha256 = await sha256File(assetPath)
|
||||||
|
writeFileSync(`${assetPath}.sha256`, `${sha256} ${assetName}\n`)
|
||||||
|
|
||||||
|
const platformManifest = {
|
||||||
|
...runtimeManifest,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
asset: {
|
||||||
|
name: assetName,
|
||||||
|
sha256,
|
||||||
|
size: statSync(assetPath).size,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeFileSync(resolve(OUT_DIR, manifestName), JSON.stringify(platformManifest, null, 2) + '\n')
|
||||||
|
|
||||||
|
console.log(`Runtime asset: ${assetPath}`)
|
||||||
|
console.log(`Runtime manifest: ${resolve(OUT_DIR, manifestName)}`)
|
||||||
|
} finally {
|
||||||
|
rmSync(stage, { recursive: true, force: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { arch as osArch, platform as osPlatform } from 'node:os'
|
||||||
|
import { hermesVersion, runtimeReleaseTag } from './runtime-config.mjs'
|
||||||
|
|
||||||
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
|
const HERMES_VERSION = hermesVersion()
|
||||||
|
const RUNTIME_RELEASE_TAG = runtimeReleaseTag()
|
||||||
|
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||||
|
|
||||||
|
if (!['win', 'mac', 'linux'].includes(OS_LABEL) || !['x64', 'arm64'].includes(TARGET_ARCH)) {
|
||||||
|
console.error(`Unsupported runtime target: ${TARGET_OS}-${TARGET_ARCH}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = `${OS_LABEL}-${TARGET_ARCH}`
|
||||||
|
const asset = `hermes-runtime-hermes-agent-${HERMES_VERSION}-${platform}.tar.gz`
|
||||||
|
const manifest = `hermes-runtime-${platform}.json`
|
||||||
|
|
||||||
|
if (process.argv.includes('--manifest')) {
|
||||||
|
console.log(manifest)
|
||||||
|
} else if (process.argv.includes('--platform')) {
|
||||||
|
console.log(platform)
|
||||||
|
} else if (process.argv.includes('--release-tag')) {
|
||||||
|
console.log(RUNTIME_RELEASE_TAG)
|
||||||
|
} else {
|
||||||
|
console.log(asset)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const DEFAULT_HERMES_VERSION = '0.15.2'
|
||||||
|
|
||||||
|
export function hermesVersion(env = process.env) {
|
||||||
|
return env.HERMES_VERSION || DEFAULT_HERMES_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeReleaseTag(env = process.env) {
|
||||||
|
const version = hermesVersion(env)
|
||||||
|
return env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG
|
||||||
|
|| env.RUNTIME_RELEASE_TAG
|
||||||
|
|| `hermes-${version}-runtime`
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { runtimeReleaseTag } from './runtime-config.mjs'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, '..')
|
||||||
|
const outFile = resolve(ROOT, 'build', 'runtime-release.json')
|
||||||
|
const tag = runtimeReleaseTag()
|
||||||
|
|
||||||
|
mkdirSync(dirname(outFile), { recursive: true })
|
||||||
|
writeFileSync(outFile, JSON.stringify({ tag }, null, 2) + '\n')
|
||||||
|
console.log(`Runtime release metadata: ${tag}`)
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { existsSync, mkdirSync } from 'node:fs'
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
import { delimiter, dirname } from 'node:path'
|
import { delimiter, dirname, join } from 'node:path'
|
||||||
import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths'
|
import {
|
||||||
|
bundledBrowserExecutable,
|
||||||
|
bundledGit,
|
||||||
|
bundledNode,
|
||||||
|
bundledPython,
|
||||||
|
gitPathDirs,
|
||||||
|
hermesBin,
|
||||||
|
hermesHome,
|
||||||
|
nodeBinDir,
|
||||||
|
pythonDir,
|
||||||
|
webUiHome,
|
||||||
|
} from './paths'
|
||||||
import { HERMES_CLI_ARG } from './cli-constants'
|
import { HERMES_CLI_ARG } from './cli-constants'
|
||||||
|
import { ensureDesktopRuntime } from './runtime-manager'
|
||||||
|
|
||||||
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
|
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
|
||||||
const index = argv.indexOf(HERMES_CLI_ARG)
|
const index = argv.indexOf(HERMES_CLI_ARG)
|
||||||
@@ -11,10 +23,17 @@ export function parseHermesCliArgs(argv: string[] = process.argv): string[] | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runBundledHermesCli(args: string[]): Promise<number> {
|
export async function runBundledHermesCli(args: string[]): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDesktopRuntime()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to prepare Hermes runtime: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
const command = hermesBin()
|
const command = hermesBin()
|
||||||
if (!existsSync(command)) {
|
if (!existsSync(command)) {
|
||||||
console.error(`hermes binary missing at ${command}`)
|
console.error(`hermes binary missing at ${command}`)
|
||||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
|
||||||
return 127
|
return 127
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +41,20 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
|
|||||||
mkdirSync(hermesHome(), { recursive: true })
|
mkdirSync(hermesHome(), { recursive: true })
|
||||||
|
|
||||||
const binDir = dirname(command)
|
const binDir = dirname(command)
|
||||||
const pathValue = process.env.PATH ? `${binDir}${delimiter}${process.env.PATH}` : binDir
|
const bundledNodeBin = nodeBinDir()
|
||||||
|
const bundledAgentBrowserBin = process.platform === 'win32'
|
||||||
|
? join(pythonDir(), 'node')
|
||||||
|
: join(pythonDir(), 'node', 'bin')
|
||||||
|
const inheritedPath = process.env.PATH || process.env.Path || ''
|
||||||
|
const pathValue = [
|
||||||
|
binDir,
|
||||||
|
bundledAgentBrowserBin,
|
||||||
|
bundledNodeBin,
|
||||||
|
gitPathDirs().join(delimiter),
|
||||||
|
inheritedPath,
|
||||||
|
].filter(Boolean).join(delimiter)
|
||||||
|
const gitBin = bundledGit()
|
||||||
|
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HERMES_DESKTOP: 'true',
|
HERMES_DESKTOP: 'true',
|
||||||
@@ -30,6 +62,12 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
|
|||||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
|
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
|
||||||
HERMES_AGENT_CLI_PYTHON: bundledPython(),
|
HERMES_AGENT_CLI_PYTHON: bundledPython(),
|
||||||
HERMES_AGENT_ROOT: pythonDir(),
|
HERMES_AGENT_ROOT: pythonDir(),
|
||||||
|
HERMES_AGENT_NODE: bundledNode(),
|
||||||
|
HERMES_AGENT_NODE_ROOT: process.platform === 'win32' ? bundledNodeBin : dirname(bundledNodeBin),
|
||||||
|
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(hermesHome(), 'agent-browser'),
|
||||||
|
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
|
||||||
|
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
|
||||||
HERMES_HOME: hermesHome(),
|
HERMES_HOME: hermesHome(),
|
||||||
HERMES_WEB_UI_HOME: webUiHome(),
|
HERMES_WEB_UI_HOME: webUiHome(),
|
||||||
HERMES_WEBUI_STATE_DIR: webUiHome(),
|
HERMES_WEBUI_STATE_DIR: webUiHome(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { checkForDesktopUpdates, initAutoUpdater } from './updater'
|
|||||||
import { t } from './desktop-i18n'
|
import { t } from './desktop-i18n'
|
||||||
import { installHermesStudioCliShim } from './cli-shim'
|
import { installHermesStudioCliShim } from './cli-shim'
|
||||||
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
|
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
|
||||||
|
import { ensureDesktopRuntime, type RuntimeProgress } from './runtime-manager'
|
||||||
|
|
||||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
||||||
const START_HIDDEN = process.argv.includes('--hidden')
|
const START_HIDDEN = process.argv.includes('--hidden')
|
||||||
@@ -15,6 +16,7 @@ let mainWindow: BrowserWindow | null = null
|
|||||||
let serverUrl: string | null = null
|
let serverUrl: string | null = null
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
|
let isBootstrapping = false
|
||||||
|
|
||||||
function showMainWindow() {
|
function showMainWindow() {
|
||||||
if (!mainWindow) {
|
if (!mainWindow) {
|
||||||
@@ -168,25 +170,91 @@ function splashHtml(): string {
|
|||||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
|
||||||
<style>
|
<style>
|
||||||
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
|
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
|
||||||
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
|
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px}
|
||||||
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
|
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
|
||||||
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
|
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
|
||||||
.row{display:flex;gap:8px}
|
.row{display:flex;gap:8px}
|
||||||
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
|
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
|
||||||
.label{font-size:14px;color:#999}
|
.label{font-size:14px;color:#b8b8b8}
|
||||||
|
.detail{min-height:18px;font-size:12px;color:#7f7f7f}
|
||||||
|
.progress{width:320px;height:6px;border-radius:999px;background:#2b2b2b;overflow:hidden}
|
||||||
|
.bar{width:0;height:100%;background:#d8d8d8;transition:width .18s ease}
|
||||||
h1{font-weight:500;margin:0;font-size:18px}
|
h1{font-weight:500;margin:0;font-size:18px}
|
||||||
</style></head><body><div class="wrap">
|
</style></head><body><div class="wrap">
|
||||||
<h1>Hermes Studio</h1>
|
<h1>Hermes Studio</h1>
|
||||||
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||||
<div class="label">Starting local services…</div>
|
<div id="label" class="label">Starting local services...</div>
|
||||||
|
<div class="progress"><div id="bar" class="bar"></div></div>
|
||||||
|
<div id="detail" class="detail"></div>
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let unit = units[0]
|
||||||
|
for (let i = 1; i < units.length && value >= 1024; i += 1) {
|
||||||
|
value /= 1024
|
||||||
|
unit = units[i]
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : 1)} ${unit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSplash(progress: RuntimeProgress) {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
const label = progress.message
|
||||||
|
const percent = typeof progress.percent === 'number' ? Math.round(progress.percent) : null
|
||||||
|
let detail = ''
|
||||||
|
if (progress.receivedBytes && progress.totalBytes) {
|
||||||
|
detail = `${formatBytes(progress.receivedBytes)} / ${formatBytes(progress.totalBytes)}`
|
||||||
|
if (percent !== null) detail += ` (${percent}%)`
|
||||||
|
} else if (percent !== null) {
|
||||||
|
detail = `${percent}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.webContents.executeJavaScript(`
|
||||||
|
{
|
||||||
|
const label = document.getElementById('label');
|
||||||
|
const detail = document.getElementById('detail');
|
||||||
|
const bar = document.getElementById('bar');
|
||||||
|
if (label) label.textContent = ${JSON.stringify(label)};
|
||||||
|
if (detail) detail.textContent = ${JSON.stringify(detail)};
|
||||||
|
if (bar) bar.style.width = ${JSON.stringify(percent === null ? '100%' : `${percent}%`)};
|
||||||
|
}
|
||||||
|
`).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
if (isBootstrapping) return
|
||||||
|
isBootstrapping = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureDesktopRuntime(updateSplash)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to prepare Hermes runtime:', err)
|
||||||
|
if (mainWindow) {
|
||||||
|
const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '')
|
||||||
|
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
|
||||||
|
`<html><body style="font-family:system-ui;padding:32px;background:#1a1a1a;color:#eee">
|
||||||
|
<h2>Failed to prepare Hermes runtime</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
|
||||||
|
<button id="retry" style="margin-top:16px;padding:8px 14px;border:1px solid #555;border-radius:6px;background:#2b2b2b;color:#eee;cursor:pointer">Retry</button>
|
||||||
|
<script>
|
||||||
|
document.getElementById('retry')?.addEventListener('click', () => {
|
||||||
|
window.hermesDesktop?.retryBootstrap?.()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body></html>`,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
isBootstrapping = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!hermesBinExists()) {
|
if (!hermesBinExists()) {
|
||||||
console.error(`hermes binary missing at ${hermesBin()}`)
|
console.error(`hermes binary missing at ${hermesBin()}`)
|
||||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -203,10 +271,20 @@ async function bootstrap() {
|
|||||||
</body></html>`,
|
</body></html>`,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
isBootstrapping = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('hermes-desktop:get-token', () => getToken())
|
ipcMain.handle('hermes-desktop:get-token', () => getToken())
|
||||||
|
ipcMain.handle('hermes-desktop:retry-bootstrap', async () => {
|
||||||
|
if (serverUrl) {
|
||||||
|
await mainWindow?.loadURL(serverUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await mainWindow?.loadURL(splashHtml())
|
||||||
|
await bootstrap()
|
||||||
|
})
|
||||||
|
|
||||||
function runDesktopApp() {
|
function runDesktopApp() {
|
||||||
const gotLock = app.requestSingleInstanceLock()
|
const gotLock = app.requestSingleInstanceLock()
|
||||||
|
|||||||
@@ -23,12 +23,69 @@ export function webuiServerEntry(): string {
|
|||||||
return join(webuiDir(), 'dist', 'server', 'index.js')
|
return join(webuiDir(), 'dist', 'server', 'index.js')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bundled Python directory.
|
export function runtimePlatformKey(): string {
|
||||||
|
return `${osLabel}-${archLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function desktopRuntimeDir(): string {
|
||||||
|
const override = process.env.HERMES_DESKTOP_RUNTIME_DIR?.trim()
|
||||||
|
if (override) return resolve(override)
|
||||||
|
return join(webUiHome(), 'desktop-runtime', runtimePlatformKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
function packagedResourceDir(name: string): string {
|
||||||
|
return resolve(process.resourcesPath, name)
|
||||||
|
}
|
||||||
|
|
||||||
// dev: packages/desktop/resources/python/<os>-<arch>
|
// dev: packages/desktop/resources/python/<os>-<arch>
|
||||||
// prod: <resources>/python
|
// prod: <resources>/python when present, otherwise downloaded runtime cache.
|
||||||
export function pythonDir(): string {
|
export function pythonDir(): string {
|
||||||
if (app.isPackaged) return resolve(process.resourcesPath, 'python')
|
if (app.isPackaged) {
|
||||||
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
|
const packaged = packagedResourceDir('python')
|
||||||
|
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'python')
|
||||||
|
}
|
||||||
|
return resolve(app.getAppPath(), 'resources', 'python', runtimePlatformKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeDir(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const packaged = packagedResourceDir('node')
|
||||||
|
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'node')
|
||||||
|
}
|
||||||
|
return resolve(app.getAppPath(), 'resources', 'node', runtimePlatformKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeBinDir(): string {
|
||||||
|
const dir = nodeDir()
|
||||||
|
return isWin ? dir : join(dir, 'bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundledNode(): string {
|
||||||
|
return isWin ? join(nodeDir(), 'node.exe') : join(nodeBinDir(), 'node')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gitDir(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const packaged = packagedResourceDir('git')
|
||||||
|
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'git')
|
||||||
|
}
|
||||||
|
return resolve(app.getAppPath(), 'resources', 'git', runtimePlatformKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gitPathDirs(): string[] {
|
||||||
|
if (!isWin) return []
|
||||||
|
const dir = gitDir()
|
||||||
|
return [
|
||||||
|
join(dir, 'cmd'),
|
||||||
|
join(dir, 'mingw64', 'bin'),
|
||||||
|
join(dir, 'usr', 'bin'),
|
||||||
|
].filter(existsSync)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundledGit(): string | undefined {
|
||||||
|
if (!isWin) return undefined
|
||||||
|
const git = join(gitDir(), 'cmd', 'git.exe')
|
||||||
|
return existsSync(git) ? git : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bundledAgentBrowserHome(): string {
|
export function bundledAgentBrowserHome(): string {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
createReadStream,
|
||||||
|
createWriteStream,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs'
|
||||||
|
import { get as httpGet } from 'node:http'
|
||||||
|
import { get as httpsGet } from 'node:https'
|
||||||
|
import { basename, dirname, join } from 'node:path'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import {
|
||||||
|
bundledGit,
|
||||||
|
bundledNode,
|
||||||
|
desktopRuntimeDir,
|
||||||
|
hermesBinExists,
|
||||||
|
runtimePlatformKey,
|
||||||
|
} from './paths'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const DEFAULT_RUNTIME_BASE_URL = 'https://download.ekkolearnai.com'
|
||||||
|
const RUNTIME_MANIFEST_NAME = 'runtime-manifest.json'
|
||||||
|
const PACKAGED_RUNTIME_RELEASE_NAME = 'runtime-release.json'
|
||||||
|
|
||||||
|
type RuntimeManifest = {
|
||||||
|
schema: number
|
||||||
|
platform: string
|
||||||
|
hermesAgentVersion?: string
|
||||||
|
asset?: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
sha256?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeDescriptor = {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
sha256?: string
|
||||||
|
hermesAgentVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeProgress = {
|
||||||
|
stage: 'resolve' | 'download' | 'verify' | 'extract' | 'ready'
|
||||||
|
message: string
|
||||||
|
percent?: number
|
||||||
|
receivedBytes?: number
|
||||||
|
totalBytes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeProgressHandler = (progress: RuntimeProgress) => void
|
||||||
|
|
||||||
|
function runtimeReady(): boolean {
|
||||||
|
const gitReady = process.platform !== 'win32' || !!bundledGit()
|
||||||
|
return hermesBinExists() && existsSync(bundledNode()) && gitReady
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseTagCandidates(): string[] {
|
||||||
|
const override = process.env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG?.trim()
|
||||||
|
if (override) return [override]
|
||||||
|
|
||||||
|
const version = app.getVersion()
|
||||||
|
const candidates = [packagedRuntimeReleaseTag(), version, `v${version}`, 'latest']
|
||||||
|
return Array.from(new Set(candidates.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function packagedRuntimeReleaseTag(): string | null {
|
||||||
|
const candidates = app.isPackaged
|
||||||
|
? [join(process.resourcesPath, 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
|
||||||
|
: [join(app.getAppPath(), 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!existsSync(candidate)) continue
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(readFileSync(candidate, 'utf-8')) as { tag?: unknown }
|
||||||
|
if (typeof metadata.tag === 'string' && metadata.tag.trim()) return metadata.tag.trim()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeAssetUrl(assetName: string, tag: string): string {
|
||||||
|
const repo = process.env.HERMES_DESKTOP_RUNTIME_REPO?.trim()
|
||||||
|
if (repo) {
|
||||||
|
if (tag === 'latest') {
|
||||||
|
return `https://github.com/${repo}/releases/latest/download/${encodeURIComponent(assetName)}`
|
||||||
|
}
|
||||||
|
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = process.env.HERMES_DESKTOP_RUNTIME_BASE_URL?.trim() || DEFAULT_RUNTIME_BASE_URL
|
||||||
|
if (template.includes('{asset}') || template.includes('{tag}')) {
|
||||||
|
return template
|
||||||
|
.replace(/\{asset\}/g, encodeURIComponent(assetName))
|
||||||
|
.replace(/\{tag\}/g, encodeURIComponent(tag))
|
||||||
|
}
|
||||||
|
return `${template.replace(/\/$/, '')}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GET ${url} returned ${response.status}`)
|
||||||
|
}
|
||||||
|
return await response.json() as T
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRuntimeDescriptor(): Promise<RuntimeDescriptor> {
|
||||||
|
const directUrl = process.env.HERMES_DESKTOP_RUNTIME_URL?.trim()
|
||||||
|
if (directUrl) {
|
||||||
|
return { name: basename(new URL(directUrl).pathname) || 'hermes-runtime.tar.gz', url: directUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformManifestName = `hermes-runtime-${runtimePlatformKey()}.json`
|
||||||
|
const manifestOverride = process.env.HERMES_DESKTOP_RUNTIME_MANIFEST_URL?.trim()
|
||||||
|
const candidates = manifestOverride
|
||||||
|
? [{ tag: '', url: manifestOverride }]
|
||||||
|
: releaseTagCandidates().map(tag => ({ tag, url: runtimeAssetUrl(platformManifestName, tag) }))
|
||||||
|
|
||||||
|
let lastError: Error | null = null
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const manifest = await fetchJson<RuntimeManifest>(candidate.url)
|
||||||
|
if (!manifest.asset?.name) {
|
||||||
|
throw new Error(`runtime manifest is missing asset.name: ${candidate.url}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: manifest.asset.name,
|
||||||
|
url: manifest.asset.url || runtimeAssetUrl(manifest.asset.name, candidate.tag),
|
||||||
|
sha256: manifest.asset.sha256,
|
||||||
|
hermesAgentVersion: manifest.hermesAgentVersion,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Unable to resolve Hermes desktop runtime package')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedRuntimeManifest(root: string): RuntimeManifest | null {
|
||||||
|
const file = join(root, RUNTIME_MANIFEST_NAME)
|
||||||
|
if (!existsSync(file)) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(file, 'utf-8')) as RuntimeManifest
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedRuntimeMatches(root: string, descriptor: RuntimeDescriptor): boolean {
|
||||||
|
if (!runtimeReady()) return false
|
||||||
|
const manifest = readCachedRuntimeManifest(root)
|
||||||
|
if (!manifest?.asset?.name) return true
|
||||||
|
return manifest.asset.name === descriptor.name
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(
|
||||||
|
url: string,
|
||||||
|
target: string,
|
||||||
|
onProgress?: RuntimeProgressHandler,
|
||||||
|
redirects = 5,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const getter = parsed.protocol === 'http:' ? httpGet : httpsGet
|
||||||
|
const req = getter(parsed, response => {
|
||||||
|
const status = response.statusCode || 0
|
||||||
|
const location = response.headers.location
|
||||||
|
if (status >= 300 && status < 400 && location && redirects > 0) {
|
||||||
|
response.resume()
|
||||||
|
downloadFile(new URL(location, url).toString(), target, onProgress, redirects - 1).then(resolve, reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
response.resume()
|
||||||
|
reject(new Error(`GET ${url} returned ${status}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBytes = Number(response.headers['content-length']) || undefined
|
||||||
|
let receivedBytes = 0
|
||||||
|
response.on('data', chunk => {
|
||||||
|
receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk)
|
||||||
|
onProgress?.({
|
||||||
|
stage: 'download',
|
||||||
|
message: 'Downloading Hermes runtime...',
|
||||||
|
percent: totalBytes ? Math.min(100, (receivedBytes / totalBytes) * 100) : undefined,
|
||||||
|
receivedBytes,
|
||||||
|
totalBytes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = createWriteStream(target)
|
||||||
|
response.pipe(file)
|
||||||
|
file.on('finish', () => file.close(() => resolve()))
|
||||||
|
file.on('error', reject)
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(file: string): Promise<string> {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const stream = createReadStream(file)
|
||||||
|
stream.on('data', chunk => hash.update(chunk))
|
||||||
|
stream.on('end', resolve)
|
||||||
|
stream.on('error', reject)
|
||||||
|
})
|
||||||
|
return hash.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractRuntimeArchive(archive: string, targetRoot: string): Promise<void> {
|
||||||
|
const parent = dirname(targetRoot)
|
||||||
|
const tempRoot = join(parent, `.runtime-${process.pid}-${Date.now()}`)
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
|
mkdirSync(tempRoot, { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-xzf', archive, '-C', tempRoot], {
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
for (const required of ['python', 'node']) {
|
||||||
|
if (!existsSync(join(tempRoot, required))) {
|
||||||
|
throw new Error(`Runtime archive did not contain ${required}/`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmSync(targetRoot, { recursive: true, force: true })
|
||||||
|
mkdirSync(parent, { recursive: true })
|
||||||
|
renameSync(tempRoot, targetRoot)
|
||||||
|
} catch (err) {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDesktopRuntime(onProgress?: RuntimeProgressHandler): Promise<void> {
|
||||||
|
const runtimeRoot = desktopRuntimeDir()
|
||||||
|
mkdirSync(runtimeRoot, { recursive: true })
|
||||||
|
|
||||||
|
let descriptor: RuntimeDescriptor
|
||||||
|
try {
|
||||||
|
onProgress?.({ stage: 'resolve', message: 'Checking Hermes runtime...' })
|
||||||
|
descriptor = await resolveRuntimeDescriptor()
|
||||||
|
} catch (err) {
|
||||||
|
if (runtimeReady() && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) {
|
||||||
|
console.warn(`[runtime] using cached Hermes runtime because update check failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedRuntimeMatches(runtimeRoot, descriptor) && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) return
|
||||||
|
|
||||||
|
const archive = join(dirname(runtimeRoot), `${descriptor.name}.download`)
|
||||||
|
console.log(`[runtime] downloading Hermes runtime ${descriptor.name}`)
|
||||||
|
onProgress?.({ stage: 'download', message: `Downloading ${descriptor.name}...` })
|
||||||
|
await downloadFile(descriptor.url, archive, onProgress)
|
||||||
|
if (descriptor.sha256) {
|
||||||
|
onProgress?.({ stage: 'verify', message: 'Verifying Hermes runtime...' })
|
||||||
|
const actual = await sha256File(archive)
|
||||||
|
if (actual !== descriptor.sha256) {
|
||||||
|
throw new Error(`Runtime checksum mismatch for ${descriptor.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({ stage: 'extract', message: 'Extracting Hermes runtime...' })
|
||||||
|
await extractRuntimeArchive(archive, runtimeRoot)
|
||||||
|
const archiveSize = statSync(archive).size
|
||||||
|
rmSync(archive, { force: true })
|
||||||
|
|
||||||
|
const manifestPath = join(runtimeRoot, RUNTIME_MANIFEST_NAME)
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
writeFileSync(manifestPath, JSON.stringify({
|
||||||
|
schema: 1,
|
||||||
|
platform: runtimePlatformKey(),
|
||||||
|
hermesAgentVersion: descriptor.hermesAgentVersion,
|
||||||
|
asset: {
|
||||||
|
name: descriptor.name,
|
||||||
|
sha256: descriptor.sha256,
|
||||||
|
size: archiveSize,
|
||||||
|
},
|
||||||
|
}, null, 2))
|
||||||
|
}
|
||||||
|
onProgress?.({ stage: 'ready', message: 'Hermes runtime ready.' })
|
||||||
|
console.log(`[runtime] Hermes runtime ready at ${runtimeRoot}`)
|
||||||
|
}
|
||||||
@@ -8,11 +8,15 @@ import { promisify } from 'node:util'
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import {
|
import {
|
||||||
bundledBrowserExecutable,
|
bundledBrowserExecutable,
|
||||||
|
bundledGit,
|
||||||
|
bundledNode,
|
||||||
|
gitPathDirs,
|
||||||
webuiServerEntry,
|
webuiServerEntry,
|
||||||
webuiDir,
|
webuiDir,
|
||||||
hermesBin,
|
hermesBin,
|
||||||
webUiHome,
|
webUiHome,
|
||||||
hermesHome,
|
hermesHome,
|
||||||
|
nodeBinDir,
|
||||||
tokenFile,
|
tokenFile,
|
||||||
pythonDir,
|
pythonDir,
|
||||||
} from './paths'
|
} from './paths'
|
||||||
@@ -296,22 +300,28 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
const bundledPython = isWin
|
const bundledPython = isWin
|
||||||
? join(pythonDir(), 'python.exe')
|
? join(pythonDir(), 'python.exe')
|
||||||
: join(pythonDir(), 'bin', 'python3')
|
: join(pythonDir(), 'bin', 'python3')
|
||||||
const bundledNodeBin = isWin
|
const bundledAgentBrowserBin = isWin
|
||||||
? join(pythonDir(), 'node')
|
? join(pythonDir(), 'node')
|
||||||
: join(pythonDir(), 'node', 'bin')
|
: join(pythonDir(), 'node', 'bin')
|
||||||
|
const bundledNodeBin = nodeBinDir()
|
||||||
|
const bundledGitPath = gitPathDirs().join(delimiter)
|
||||||
const bridgePort = await getFreeTcpPort()
|
const bridgePort = await getFreeTcpPort()
|
||||||
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||||
const loginShellPath = await getLoginShellPath()
|
const loginShellPath = await getLoginShellPath()
|
||||||
const nvmNodeBinPaths = getNvmNodeBinPaths()
|
const nvmNodeBinPaths = getNvmNodeBinPaths()
|
||||||
const runtimePath = mergePathEntries(
|
const runtimePath = mergePathEntries(
|
||||||
dirname(hermesBin()),
|
dirname(hermesBin()),
|
||||||
|
bundledAgentBrowserBin,
|
||||||
bundledNodeBin,
|
bundledNodeBin,
|
||||||
|
bundledGitPath,
|
||||||
loginShellPath,
|
loginShellPath,
|
||||||
nvmNodeBinPaths,
|
nvmNodeBinPaths,
|
||||||
process.env.PATH,
|
process.env.PATH,
|
||||||
|
process.env.Path,
|
||||||
COMMON_USER_BIN_DIRS.join(delimiter),
|
COMMON_USER_BIN_DIRS.join(delimiter),
|
||||||
)
|
)
|
||||||
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
|
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
|
||||||
|
const gitBin = bundledGit()
|
||||||
|
|
||||||
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
@@ -326,9 +336,12 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||||
HERMES_AGENT_CLI_PYTHON: bundledPython,
|
HERMES_AGENT_CLI_PYTHON: bundledPython,
|
||||||
HERMES_AGENT_ROOT: pythonDir(),
|
HERMES_AGENT_ROOT: pythonDir(),
|
||||||
|
HERMES_AGENT_NODE: bundledNode(),
|
||||||
|
HERMES_AGENT_NODE_ROOT: isWin ? bundledNodeBin : dirname(bundledNodeBin),
|
||||||
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'),
|
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'),
|
||||||
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
|
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
|
||||||
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
|
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
|
||||||
|
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
|
||||||
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
||||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
||||||
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
|
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
|
|
||||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||||
getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'),
|
getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'),
|
||||||
|
retryBootstrap: (): Promise<void> => ipcRenderer.invoke('hermes-desktop:retry-bootstrap'),
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,9 +117,14 @@ if (!buildWorkflow.includes('npm run harness:check')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
|
const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
|
||||||
|
const desktopRuntimeWorkflow = await readText('.github/workflows/desktop-runtime.yml')
|
||||||
const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
|
const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
|
||||||
|
const desktopPackageJson = await readText('packages/desktop/package.json')
|
||||||
const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
|
const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
|
||||||
const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
|
const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
|
||||||
|
const desktopRuntimeManager = await readText('packages/desktop/src/main/runtime-manager.ts')
|
||||||
|
const desktopPaths = await readText('packages/desktop/src/main/paths.ts')
|
||||||
|
const desktopRuntimeAssetName = await readText('packages/desktop/scripts/runtime-asset-name.mjs')
|
||||||
if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
|
if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
|
||||||
fail('desktop-release.yml must upload matrix-specific artifact_files')
|
fail('desktop-release.yml must upload matrix-specific artifact_files')
|
||||||
}
|
}
|
||||||
@@ -144,6 +149,42 @@ if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) {
|
|||||||
fail('desktop-release.yml must keep fail_on_unmatched_files: true')
|
fail('desktop-release.yml must keep fail_on_unmatched_files: true')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const phrase of [
|
||||||
|
'resources/python/${os}-${arch}',
|
||||||
|
'resources/node/${os}-${arch}',
|
||||||
|
'resources/git/${os}-${arch}',
|
||||||
|
]) {
|
||||||
|
if (electronBuilderConfig.includes(phrase)) {
|
||||||
|
fail(`electron-builder.yml must not bundle desktop runtime resource: ${phrase}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phrase of [
|
||||||
|
'"fetch:node"',
|
||||||
|
'"fetch:git"',
|
||||||
|
'"prepare:runtime"',
|
||||||
|
'"package:runtime"',
|
||||||
|
'"runtime:asset-name"',
|
||||||
|
]) {
|
||||||
|
if (!desktopPackageJson.includes(phrase)) {
|
||||||
|
fail(`packages/desktop/package.json must support runtime package publishing: ${phrase}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phrase of [
|
||||||
|
'steps.check.outputs.missing',
|
||||||
|
'npm --prefix packages/desktop run prepare:runtime',
|
||||||
|
'npm --prefix packages/desktop run package:runtime',
|
||||||
|
]) {
|
||||||
|
if (!desktopRuntimeWorkflow.includes(phrase)) {
|
||||||
|
fail(`desktop-runtime.yml must build and publish missing runtime package assets: ${phrase}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!desktopRuntimeAssetName.includes('hermes-runtime-hermes-agent-')) {
|
||||||
|
fail('runtime asset naming must include hermes-agent version')
|
||||||
|
}
|
||||||
|
|
||||||
for (const phrase of [
|
for (const phrase of [
|
||||||
'websockets',
|
'websockets',
|
||||||
'agent-browser@^0.26.0',
|
'agent-browser@^0.26.0',
|
||||||
@@ -160,6 +201,8 @@ for (const phrase of [
|
|||||||
|
|
||||||
for (const phrase of [
|
for (const phrase of [
|
||||||
'bundledNodeBin',
|
'bundledNodeBin',
|
||||||
|
'HERMES_AGENT_NODE',
|
||||||
|
'HERMES_AGENT_GIT',
|
||||||
'PLAYWRIGHT_BROWSERS_PATH',
|
'PLAYWRIGHT_BROWSERS_PATH',
|
||||||
'ms-playwright',
|
'ms-playwright',
|
||||||
]) {
|
]) {
|
||||||
@@ -168,6 +211,20 @@ for (const phrase of [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const phrase of [
|
||||||
|
'HERMES_DESKTOP_RUNTIME_URL',
|
||||||
|
'HERMES_DESKTOP_RUNTIME_BASE_URL',
|
||||||
|
'runtime-manifest.json',
|
||||||
|
]) {
|
||||||
|
if (!desktopRuntimeManager.includes(phrase)) {
|
||||||
|
fail(`desktop runtime manager must support downloadable runtime packages: ${phrase}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!desktopPaths.includes('HERMES_DESKTOP_RUNTIME_DIR')) {
|
||||||
|
fail('desktop paths must allow HERMES_DESKTOP_RUNTIME_DIR override')
|
||||||
|
}
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.error('Harness check failed:')
|
console.error('Harness check failed:')
|
||||||
for (const failure of failures) {
|
for (const failure of failures) {
|
||||||
|
|||||||
Reference in New Issue
Block a user