feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
@@ -0,0 +1,342 @@
#!/usr/bin/env node
// Apply locally-curated patches to hermes-agent inside the bundled venv.
// Each patch is idempotent: a marker string is searched for first, and the
// edit is skipped if the patch is already in place.
//
// Run after `install-hermes.mjs`. Designed to be safe to re-run.
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { platform as osPlatform, arch as osArch } from 'node:os'
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 PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
// Allow the CI sanity-check path to point at a temp install dir without
// the full bundled-Python layout (e.g. `pip install --target /tmp/foo`).
const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? (
TARGET_OS === 'win32'
? join(PY_DIR, 'Lib', 'site-packages')
: (() => {
const libDir = join(PY_DIR, 'lib')
if (!existsSync(libDir)) throw new Error(`No lib dir at ${libDir}`)
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
return join(libDir, py, 'site-packages')
})()
)
const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py')
const browserToolPath = join(sitePkgs, 'tools', 'browser_tool.py')
const sitecustomizePath = join(sitePkgs, 'sitecustomize.py')
if (!existsSync(dtPath)) {
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
process.exit(1)
}
let src = readFileSync(dtPath, 'utf-8')
const before = src
let applied = 0
let skipped = 0
function patch(id, marker, find, replace) {
if (src.includes(marker)) {
console.log(` · ${id} (already applied)`)
skipped++
return
}
if (!src.includes(find)) {
console.log(`${id} (anchor not found — upstream changed?)`)
return
}
src = src.replace(find, replace)
console.log(`${id}`)
applied++
}
function patchText(text, id, marker, find, replace) {
if (text.includes(marker)) {
console.log(` · ${id} (already applied)`)
skipped++
return text
}
if (!text.includes(find)) {
console.log(`${id} (anchor not found — upstream changed?)`)
return text
}
applied++
console.log(`${id}`)
return text.replace(find, replace)
}
console.log(`Patching ${dtPath}`)
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
// `_IncomingHandler.pre_start()` natively (present in 0.15.x and on main), so
// re-adding it just injected a duplicate method.
// ── dt-card-tpl-env ─────────────────────────────────────────────
// Fall back to DINGTALK_CARD_TEMPLATE_ID env var.
patch(
'dt-card-tpl-env',
'# patch:dt-card-tpl-env',
` self._card_template_id: Optional[str] = extra.get("card_template_id")`,
` # patch:dt-card-tpl-env — env var fallback
self._card_template_id: Optional[str] = (
extra.get("card_template_id") or os.getenv("DINGTALK_CARD_TEMPLATE_ID")
)`,
)
// ── dt-card-before-webhook ──────────────────────────────────────
// Try AI Card *before* validating session_webhook — Card SDK does not need
// a webhook URL. Move the lookup of `current_message` and the AI Card block
// up before the webhook gate.
patch(
'dt-card-before-webhook',
'# patch:dt-card-before-webhook',
` # Check metadata first (for direct webhook sends)
session_webhook = metadata.get("session_webhook")
if not session_webhook:
webhook_info = self._get_valid_webhook(chat_id)
if not webhook_info:
logger.warning(
"[%s] No valid session_webhook for chat_id=%s",
self.name, chat_id,
)
return SendResult(
success=False,
error="No valid session_webhook available. Reply must follow an incoming message.",
)
session_webhook, _ = webhook_info
if not self._http_client:
return SendResult(success=False, error="HTTP client not initialized")
# Look up the inbound message for this chat (for AI Card routing)
current_message = self._message_contexts.get(chat_id)`,
` # patch:dt-card-before-webhook — try AI Card first; webhook gate moved below.
if not self._http_client:
return SendResult(success=False, error="HTTP client not initialized")
# Look up the inbound message for this chat (for AI Card routing)
current_message = self._message_contexts.get(chat_id)
session_webhook = metadata.get("session_webhook")`,
)
// The above leaves the existing AI Card block intact; we still need to add
// the deferred webhook gate AFTER the AI Card attempt. The original code
// had `logger.debug("[%s] Sending via webhook", self.name)` immediately
// after the AI Card fallback log. Insert the gate right before that.
patch(
'dt-card-before-webhook-gate',
'# patch:dt-card-before-webhook-gate',
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
logger.debug("[%s] Sending via webhook", self.name)`,
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
# patch:dt-card-before-webhook-gate — webhook required only for fallback path
if not session_webhook:
webhook_info = self._get_valid_webhook(chat_id)
if not webhook_info:
logger.warning(
"[%s] No valid session_webhook for chat_id=%s",
self.name, chat_id,
)
return SendResult(
success=False,
error="No valid session_webhook available. Reply must follow an incoming message.",
)
session_webhook, _ = webhook_info
logger.debug("[%s] Sending via webhook", self.name)`,
)
// ── dt-dm-robot-code ────────────────────────────────────────────
patch(
'dt-dm-robot-code',
'# patch:dt-dm-robot-code',
` im_robot_open_deliver_model=(
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
space_type="IM_ROBOT",
)
),`,
` im_robot_open_deliver_model=(
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
space_type="IM_ROBOT",
robot_code=self._robot_code, # patch:dt-dm-robot-code
)
),`,
)
// ── dt-card-autolayout ──────────────────────────────────────────
patch(
'dt-card-autolayout',
'# patch:dt-card-autolayout',
` card_data=dingtalk_card_models.CreateCardRequestCardData(
card_param_map={"content": ""},
),`,
` card_data=dingtalk_card_models.CreateCardRequestCardData(
# patch:dt-card-autolayout — wide-screen via sys_full_json_obj
card_param_map={
"content": "",
"sys_full_json_obj": json.dumps({"config": {"autoLayout": True}}),
},
),`,
)
if (src !== before) {
writeFileSync(dtPath, src)
}
if (existsSync(browserToolPath)) {
console.log(`Patching ${browserToolPath}`)
let browserSrc = readFileSync(browserToolPath, 'utf-8')
const browserBefore = browserSrc
browserSrc = patchText(
browserSrc,
'browser-stdout-decode-fallback',
'# patch:browser-stdout-decode-fallback',
`from hermes_cli.config import cfg_get\n`,
`from hermes_cli.config import cfg_get
# patch:browser-stdout-decode-fallback
def _hermes_read_browser_output(path: str) -> str:
data = Path(path).read_bytes()
for encoding in ("utf-8", "gb18030"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
pass
return data.decode("utf-8", errors="replace")
`,
)
for (const [id, find, replace] of [
[
'browser-fallback-stdout-read',
` with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read().strip()`,
` # patch:browser-fallback-stdout-read
stdout = _hermes_read_browser_output(stdout_path).strip()`,
],
[
'browser-command-stdout-read',
` with open(stdout_path, "r", encoding="utf-8") as f:
stdout = f.read()
with open(stderr_path, "r", encoding="utf-8") as f:
stderr = f.read()`,
` # patch:browser-command-stdout-read
stdout = _hermes_read_browser_output(stdout_path)
stderr = _hermes_read_browser_output(stderr_path)`,
],
]) {
browserSrc = patchText(
browserSrc,
id,
`# patch:${id}`,
find,
replace,
)
}
if (browserSrc !== browserBefore) {
writeFileSync(browserToolPath, browserSrc)
}
}
const brotlicffiCompatMarker = '# patch:brotlicffi-error-compat'
const brotlicffiCompat = `
${brotlicffiCompatMarker}
try:
import brotlicffi as _hermes_brotlicffi
if not hasattr(_hermes_brotlicffi, "error"):
_hermes_brotlicffi.error = (
getattr(_hermes_brotlicffi, "Error", None)
or getattr(_hermes_brotlicffi, "BrotliError", None)
or Exception
)
except Exception:
pass
`
const desktopHiddenSubprocessMarker = '# patch:desktop-hidden-subprocess-defaults'
const desktopHiddenSubprocessDefaults = `
${desktopHiddenSubprocessMarker}
try:
import os as _hermes_os
if _hermes_os.name == "nt" and _hermes_os.environ.get("HERMES_DESKTOP", "").strip().lower() == "true":
import asyncio as _hermes_asyncio
import subprocess as _hermes_subprocess
if not getattr(_hermes_subprocess, "_hermes_desktop_hidden_defaults_installed", False):
_hermes_create_no_window = getattr(_hermes_subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000
def _hermes_apply_hidden_process_options(kwargs):
flags = kwargs.get("creationflags", 0) or 0
try:
kwargs["creationflags"] = int(flags) | _hermes_create_no_window
except Exception:
kwargs["creationflags"] = _hermes_create_no_window
startupinfo = kwargs.get("startupinfo")
if startupinfo is None:
try:
startupinfo = _hermes_subprocess.STARTUPINFO()
except Exception:
return
kwargs["startupinfo"] = startupinfo
try:
startupinfo.dwFlags |= getattr(_hermes_subprocess, "STARTF_USESHOWWINDOW", 1)
startupinfo.wShowWindow = getattr(_hermes_subprocess, "SW_HIDE", 0)
except Exception:
pass
_hermes_original_popen = _hermes_subprocess.Popen
_hermes_original_create_subprocess_exec = _hermes_asyncio.create_subprocess_exec
_hermes_original_create_subprocess_shell = _hermes_asyncio.create_subprocess_shell
class _HermesHiddenPopen(_hermes_original_popen):
def __init__(self, *args, **kwargs):
_hermes_apply_hidden_process_options(kwargs)
super().__init__(*args, **kwargs)
async def _hermes_hidden_create_subprocess_exec(*args, **kwargs):
_hermes_apply_hidden_process_options(kwargs)
return await _hermes_original_create_subprocess_exec(*args, **kwargs)
async def _hermes_hidden_create_subprocess_shell(*args, **kwargs):
_hermes_apply_hidden_process_options(kwargs)
return await _hermes_original_create_subprocess_shell(*args, **kwargs)
_hermes_subprocess.Popen = _HermesHiddenPopen
_hermes_asyncio.create_subprocess_exec = _hermes_hidden_create_subprocess_exec
_hermes_asyncio.create_subprocess_shell = _hermes_hidden_create_subprocess_shell
_hermes_subprocess._hermes_desktop_hidden_defaults_installed = True
except Exception:
pass
`
function appendSitecustomizePatch(id, marker, body) {
const sitecustomize = existsSync(sitecustomizePath) ? readFileSync(sitecustomizePath, 'utf-8') : ''
if (sitecustomize.includes(marker)) {
console.log(` · ${id} (already applied)`)
skipped++
return
}
const nextSitecustomize = `${sitecustomize.replace(/\s*$/, '')}\n${body.trim()}\n`
writeFileSync(sitecustomizePath, nextSitecustomize)
console.log(`${id}`)
applied++
}
appendSitecustomizePatch('brotlicffi-error-compat', brotlicffiCompatMarker, brotlicffiCompat)
appendSitecustomizePatch('desktop-hidden-subprocess-defaults', desktopHiddenSubprocessMarker, desktopHiddenSubprocessDefaults)
console.log(`Done. Applied ${applied}, skipped ${skipped}.`)
+85
View File
@@ -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}`)
+61
View File
@@ -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}`)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Download python-build-standalone for the current (or target) platform/arch
// and extract into resources/python/<os>-<arch>/
import { mkdirSync, existsSync, createWriteStream, rmSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { tmpdir, platform as osPlatform, arch as osArch } from 'node:os'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
// Pin a known-good python-build-standalone release. Bump intentionally.
const PBS_TAG = process.env.PBS_TAG || '20260510'
const PYTHON_VERSION = process.env.PBS_PY || '3.12.13'
const TARGET_OS = process.env.TARGET_OS || osPlatform() // darwin | win32 | linux
const TARGET_ARCH = process.env.TARGET_ARCH || osArch() // arm64 | x64
const TRIPLE_MAP = {
'darwin-arm64': 'aarch64-apple-darwin',
'darwin-x64': 'x86_64-apple-darwin',
'win32-x64': 'x86_64-pc-windows-msvc',
'linux-x64': 'x86_64-unknown-linux-gnu',
'linux-arm64': 'aarch64-unknown-linux-gnu',
}
const key = `${TARGET_OS}-${TARGET_ARCH}`
const triple = TRIPLE_MAP[key]
if (!triple) {
console.error(`Unsupported target: ${key}`)
process.exit(1)
}
// electron-builder uses `mac`/`win`/`linux` for `${os}` — match that
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const OUT_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
const FLAVOR = 'install_only_stripped'
const FILE = `cpython-${PYTHON_VERSION}+${PBS_TAG}-${triple}-${FLAVOR}.tar.gz`
const PBS_BASE_URL = (process.env.PBS_BASE_URL || 'https://github.com/astral-sh/python-build-standalone/releases/download').replace(/\/$/, '')
const URL = `${PBS_BASE_URL}/${PBS_TAG}/${FILE}`
if (existsSync(resolve(OUT_DIR, 'python')) || existsSync(resolve(OUT_DIR, 'bin', 'python3'))) {
console.log(`✓ Python already present at ${OUT_DIR}, skipping`)
process.exit(0)
}
mkdirSync(OUT_DIR, { recursive: true })
const tarPath = resolve(tmpdir(), FILE)
console.log(`→ Fetching ${URL}`)
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', tarPath, URL], { stdio: 'inherit' })
if (curl.status !== 0) {
console.error('curl failed')
process.exit(curl.status ?? 1)
}
console.log(`→ Extracting into ${OUT_DIR}`)
// PBS tarballs unpack to a top-level "python/" directory; --strip-components=1 flattens it
const tar = spawnSync('tar', ['-xzf', tarPath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
if (tar.status !== 0) {
console.error('tar failed')
process.exit(tar.status ?? 1)
}
rmSync(tarPath, { force: true })
console.log(`✓ Python ready at ${OUT_DIR}`)
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env node
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { basename, resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
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 ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const HERMES_VERSION = hermesVersion()
// Match the packaged runtime to the channel list exposed at /hermes/channels.
// Telegram, Discord, and Slack are covered by "messaging". We intentionally
// install Matrix's plaintext deps below instead of using the "matrix" extra:
// that extra pulls mautrix[encryption] -> python-olm, which needs a fragile
// native build on desktop packaging machines. WhatsApp, QQBot, and Weixin do
// not expose dedicated hermes-agent extras; their deps are covered by base or
// the channel extras below.
const HERMES_EXTRAS = [
'mcp',
'messaging',
'slack',
'wecom',
'dingtalk',
'feishu',
].join(',')
const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[${HERMES_EXTRAS}]==${HERMES_VERSION}`
const EXTRA_PYTHON_PACKAGES = splitPackageList(
process.env.HERMES_EXTRA_PYTHON_PACKAGES || [
'websockets',
'mautrix==0.21.0',
'Markdown==3.10.2',
'aiosqlite==0.22.1',
'asyncpg==0.31.0',
'aiohttp-socks==0.11.0',
].join(' '),
)
const BROWSER_PACKAGES = splitPackageList(
process.env.HERMES_BROWSER_PACKAGES || 'agent-browser@^0.26.0 @askjo/camofox-browser@^1.5.2',
)
const SKIP_BROWSER_RUNTIME = process.env.HERMES_SKIP_BROWSER_RUNTIME === '1'
|| process.env.HERMES_SKIP_BROWSER_RUNTIME?.toLowerCase() === 'true'
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 NODE_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
const NODE_PREFIX = resolve(PY_DIR, 'node')
const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser')
const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright')
const pyBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'python.exe')
: resolve(PY_DIR, 'bin', 'python3')
if (!existsSync(pyBin)) {
console.error(`Python not found at ${pyBin}. Run: npm run fetch:python`)
process.exit(1)
}
function hasUv() {
const r = spawnSync('uv', ['--version'], { stdio: 'ignore' })
return r.status === 0
}
function splitPackageList(value) {
return value
.split(/[,\s]+/)
.map(part => part.trim())
.filter(Boolean)
}
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 optionalRun(command, args, options = {}) {
return spawnSync(command, args, { stdio: 'inherit', ...options })
}
function commandInvocation(command) {
if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) {
const cmdCommand = /[\s&()[\]{}^=;!'+,`~]/.test(command) ? `"${command}"` : command
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', cmdCommand] }
}
return { command, argsPrefix: [] }
}
function runInvocation(invocation, args, options = {}) {
return run(invocation.command, [...invocation.argsPrefix, ...args], options)
}
function optionalRunInvocation(invocation, 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) {
if (packages.length === 0) return
const env = pythonBuildEnv()
if (hasUv()) {
console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`)
run('uv', [
'pip', 'install',
'--python', pyBin,
...packages,
], { env })
} else {
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
run(pyBin, [
'-m', 'pip', 'install',
...packages,
'--no-warn-script-location',
'--disable-pip-version-check',
], { env })
}
}
function npmCommand() {
const bundled = TARGET_OS === 'win32'
? resolve(NODE_DIR, 'npm.cmd')
: resolve(NODE_DIR, 'bin', 'npm')
const candidates = TARGET_OS === 'win32'
? [bundled, 'npm.cmd', 'npm.exe', 'npm']
: [bundled, 'npm']
for (const candidate of candidates) {
if (candidate === bundled && !existsSync(candidate)) continue
const invocation = commandInvocation(candidate)
const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore', env: browserRuntimeEnv(false) })
if (result.status === 0) return invocation
}
return null
}
function agentBrowserCommand() {
if (TARGET_OS === 'win32') {
return resolve(NODE_PREFIX, 'agent-browser.cmd')
}
return resolve(NODE_PREFIX, 'bin', 'agent-browser')
}
function browserRuntimeEnv(includeAgentBrowser = true) {
const bundledNodeBin = TARGET_OS === 'win32'
? NODE_DIR
: resolve(NODE_DIR, 'bin')
const nodePath = TARGET_OS === 'win32'
? NODE_PREFIX
: resolve(NODE_PREFIX, 'bin')
const inheritedPath = process.env.PATH || process.env.Path || ''
const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH'
const browserExecutable = includeAgentBrowser ? ensureBundledBrowserExecutable() : null
const pathEntries = includeAgentBrowser
? [nodePath, bundledNodeBin, inheritedPath]
: [bundledNodeBin, inheritedPath]
const env = {
...process.env,
[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,
PLAYWRIGHT_BROWSERS_PATH,
}
if (browserExecutable) env.AGENT_BROWSER_EXECUTABLE_PATH = browserExecutable
return env
}
function bundledBrowserExecutableNames() {
if (TARGET_OS === 'win32') return new Set(['chrome.exe'])
if (TARGET_OS === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome'])
return new Set(['chrome', 'chromium', 'chromium-browser'])
}
function defaultAgentBrowserHomes() {
const candidates = [
process.env.USERPROFILE,
process.env.UserProfile,
process.env.HOME,
process.env.HOMEDRIVE && process.env.HOMEPATH
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
: null,
osHomedir(),
]
return Array.from(new Set(
candidates
.map(home => home?.trim())
.filter(Boolean)
.map(home => resolve(home, '.agent-browser')),
))
}
function findBrowserInstallInHome(home) {
const names = bundledBrowserExecutableNames()
const browsersDir = join(home, 'browsers')
const bundleDirs = []
if (existsSync(browsersDir)) {
try {
for (const entry of readdirSync(browsersDir, { withFileTypes: true })) {
if (entry.isDirectory()) bundleDirs.push(join(browsersDir, entry.name))
}
} catch {}
}
for (const bundleDir of bundleDirs) {
const executable = findBrowserExecutableUnder(bundleDir, names)
if (executable) return { executable, bundleDir }
}
return null
}
function findBrowserExecutableUnder(root, names) {
const stack = [root].filter(existsSync)
const visited = new Set()
while (stack.length > 0) {
const dir = stack.pop()
if (!dir || visited.has(dir)) continue
visited.add(dir)
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isFile() && names.has(entry.name)) return path
if (entry.isDirectory()) stack.push(path)
}
}
return null
}
function findBundledBrowserExecutable() {
return findBrowserInstallInHome(AGENT_BROWSER_HOME)?.executable ?? null
}
function ensureBundledBrowserExecutable() {
const bundled = findBrowserInstallInHome(AGENT_BROWSER_HOME)
if (bundled) return bundled.executable
const searchedHomes = []
for (const fallbackHome of defaultAgentBrowserHomes()) {
if (fallbackHome === AGENT_BROWSER_HOME) continue
searchedHomes.push(fallbackHome)
const fallback = findBrowserInstallInHome(fallbackHome)
if (!fallback) continue
const targetBrowsersDir = join(AGENT_BROWSER_HOME, 'browsers')
const targetBundleDir = join(targetBrowsersDir, basename(fallback.bundleDir))
mkdirSync(targetBrowsersDir, { recursive: true })
cpSync(fallback.bundleDir, targetBundleDir, { recursive: true, force: true, verbatimSymlinks: true })
console.log(`✓ copied Chrome bundle into ${targetBundleDir}`)
return findBundledBrowserExecutable()
}
if (searchedHomes.length > 0) {
console.warn(`! no Chrome bundle found in fallback agent-browser homes: ${searchedHomes.join(', ')}`)
}
return null
}
function sitePackagesDir() {
if (TARGET_OS === 'win32') {
return resolve(PY_DIR, 'Lib', 'site-packages')
}
const libDir = resolve(PY_DIR, 'lib')
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
return resolve(libDir, py, 'site-packages')
}
function pythonModuleExists(moduleName) {
const result = optionalRun(pyBin, [
'-c',
`import importlib.util, sys; sys.exit(0 if importlib.util.find_spec(${JSON.stringify(moduleName)}) else 1)`,
], { stdio: 'ignore' })
return result.status === 0
}
function removeBrokenDashboardAuthPlugin() {
if (pythonModuleExists('hermes_cli.dashboard_auth')) return
const pluginDir = resolve(sitePackagesDir(), 'plugins', 'dashboard_auth', 'nous')
if (!existsSync(pluginDir)) return
rmSync(pluginDir, { recursive: true, force: true })
console.warn(
'! Removed bundled dashboard_auth/nous plugin because hermes_cli.dashboard_auth is missing from the hermes-agent package',
)
}
function installBrowserRuntime() {
if (SKIP_BROWSER_RUNTIME) {
console.warn('! Skipping bundled browser runtime because HERMES_SKIP_BROWSER_RUNTIME is set')
return
}
if (BROWSER_PACKAGES.length === 0) {
console.warn('! Skipping bundled browser runtime because HERMES_BROWSER_PACKAGES is empty')
return
}
const npm = npmCommand()
if (!npm) {
console.error('npm not found; bundled browser runtime requires Node.js/npm')
process.exit(1)
}
console.log(`→ Installing browser runtime via npm prefix ${NODE_PREFIX}`)
runInvocation(npm, [
'install',
'-g',
'--prefix',
NODE_PREFIX,
'--silent',
'--ignore-scripts',
...BROWSER_PACKAGES,
])
const ab = agentBrowserCommand()
if (!existsSync(ab)) {
console.error(`agent-browser binary not found at ${ab} after npm install`)
process.exit(1)
}
console.log(`→ Installing Chromium for bundled agent-browser at ${AGENT_BROWSER_HOME}`)
runInvocation(commandInvocation(ab), ['install'], { env: browserRuntimeEnv() })
const browserExecutable = ensureBundledBrowserExecutable()
if (!browserExecutable) {
console.error(`Bundled Chrome executable not found under ${AGENT_BROWSER_HOME} after agent-browser install`)
process.exit(1)
}
console.log(`✓ bundled Chrome executable available at ${browserExecutable}`)
}
installPythonPackages([HERMES_PACKAGE], 'hermes-agent')
installPythonPackages(EXTRA_PYTHON_PACKAGES, 'extra Python runtime packages')
removeBrokenDashboardAuthPlugin()
installBrowserRuntime()
run(pyBin, [
'-c',
[
'import importlib.util',
'import mcp',
'import tools.mcp_tool as t',
'assert t._MCP_AVAILABLE',
'assert importlib.util.find_spec("websockets") is not None',
].join('; '),
])
const hermesBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
: resolve(PY_DIR, 'bin', 'hermes')
const hermesCheckCommand = TARGET_OS === 'win32' ? pyBin : hermesBin
const hermesCheckArgs = TARGET_OS === 'win32' ? ['-m', 'hermes_cli.main', '--version'] : ['--version']
if (!existsSync(hermesBin)) {
console.error(`hermes binary not found at ${hermesBin} after install`)
process.exit(1)
}
// hermes-web-ui's agent-bridge searches for `run_agent.py` at <python_root>/run_agent.py
// (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface
// it at the venv root with a *relative* symlink so the venv stays portable when copied
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
// is moved to /Applications/...).
function siteRunAgentRelative() {
if (TARGET_OS === 'win32') {
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
}
return `${sitePackagesDir().slice(PY_DIR.length + 1)}/run_agent.py`
}
{
const relSrc = siteRunAgentRelative()
const absSrc = resolve(PY_DIR, relSrc)
const dst = resolve(PY_DIR, 'run_agent.py')
if (existsSync(absSrc)) {
try { lstatSync(dst); unlinkSync(dst) } catch {}
if (TARGET_OS === 'win32') copyFileSync(absSrc, dst)
else symlinkSync(relSrc, dst)
console.log(`✓ run_agent.py linked at venv root (relative → ${relSrc})`)
} else {
console.warn(`! run_agent.py not found at ${absSrc} — agent-bridge may fail`)
}
}
// Relocate: replace the pip-generated launcher (which embeds an absolute
// shebang to the build-time Python path) with a relative wrapper so the
// bundled venv works after being moved into the .app/.exe payload.
if (TARGET_OS === 'win32') {
// Windows: pip generates a .exe launcher that embeds a relative shebang
// already. Add a .cmd wrapper that prefers the colocated python.exe.
const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd')
writeFileSync(
cmdPath,
[
'@echo off',
'set "PY=%~dp0..\\python.exe"',
'"%PY%" -m hermes_cli.main %*',
].join('\r\n'),
)
} else {
const launcher = [
'#!/bin/sh',
'DIR="$(cd "$(dirname "$0")" && pwd)"',
'exec "$DIR/python3" -m hermes_cli.main "$@"',
'',
].join('\n')
writeFileSync(hermesBin, launcher, { mode: 0o755 })
chmodSync(hermesBin, 0o755)
// Same for hermes-agent / hermes-acp (they all just dispatch into modules)
for (const [name, mod] of [
['hermes-agent', 'run_agent'],
['hermes-acp', 'acp_adapter.entry'],
]) {
const p = resolve(PY_DIR, 'bin', name)
if (existsSync(p)) {
writeFileSync(p, launcher.replace('hermes_cli.main', mod), { mode: 0o755 })
chmodSync(p, 0o755)
}
}
}
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
run(hermesCheckCommand, hermesCheckArgs)
if (!SKIP_BROWSER_RUNTIME) {
run(pyBin, [
'-c',
[
'import os, shutil',
`os.environ["PLAYWRIGHT_BROWSERS_PATH"] = ${JSON.stringify(PLAYWRIGHT_BROWSERS_PATH)}`,
'from tools.browser_tool import _chromium_installed',
'assert shutil.which("agent-browser") is not None',
'assert _chromium_installed()',
].join('; '),
], { env: browserRuntimeEnv() })
}
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,74 @@
#!/usr/bin/env node
// Merge two per-arch `latest-mac.yml` manifests (arm64 + x64) into a single
// manifest whose `files:` array lists BOTH dmgs, so electron-updater can pick
// the right architecture.
//
// Why this exists: our Release workflow builds macOS arm64 and x64 in separate
// matrix jobs, each emitting its own `latest-mac.yml`. When the publish job
// flattens the artifacts they collide and only one arch survives — leaving the
// other arch's users served a mismatched dmg (runs under Rosetta / fails the
// updater signature check). Merging the `files` lists fixes that.
//
// Usage: node merge-mac-latest-yml.mjs <a.yml> <b.yml> > latest-mac.yml
//
// The manifest shape electron-builder emits is small and regular, so we parse
// it with a focused extractor rather than pulling in a YAML dependency.
import { readFileSync } from 'node:fs'
function parse(path) {
const text = readFileSync(path, 'utf-8')
const version = (text.match(/^version:\s*(.+)$/m) || [])[1]?.trim()
const releaseDate = (text.match(/^releaseDate:\s*(.+)$/m) || [])[1]?.trim()
// Each entry under `files:` is `- url: ...` then indented sha512/size lines.
const files = []
const re = /- url:\s*(\S+)\s*\n\s*sha512:\s*(\S+)\s*\n\s*size:\s*(\d+)/g
let m
while ((m = re.exec(text)) !== null) {
files.push({ url: m[1], sha512: m[2], size: Number(m[3]) })
}
if (!version || files.length === 0) {
throw new Error(`Could not parse manifest at ${path} (version=${version}, files=${files.length})`)
}
return { version, releaseDate, files }
}
const [, , aPath, bPath] = process.argv
if (!aPath || !bPath) {
console.error('Usage: merge-mac-latest-yml.mjs <a.yml> <b.yml>')
process.exit(1)
}
const a = parse(aPath)
const b = parse(bPath)
if (a.version !== b.version) {
console.error(`Version mismatch: ${aPath}=${a.version} vs ${bPath}=${b.version}`)
process.exit(1)
}
// Dedupe by url, preserving order (a first, then b).
const seen = new Set()
const files = []
for (const f of [...a.files, ...b.files]) {
if (seen.has(f.url)) continue
seen.add(f.url)
files.push(f)
}
// Top-level path/sha512/size are the legacy single-file fields; point them at
// the first entry (arm64 when arm64 is passed first). electron-updater >=6
// selects from `files` by arch; these remain as a fallback for old clients.
const head = files[0]
const releaseDate = a.releaseDate || b.releaseDate
const lines = [`version: ${a.version}`, 'files:']
for (const f of files) {
lines.push(` - url: ${f.url}`)
lines.push(` sha512: ${f.sha512}`)
lines.push(` size: ${f.size}`)
}
lines.push(`path: ${head.url}`)
lines.push(`sha512: ${head.sha512}`)
if (releaseDate) lines.push(`releaseDate: ${releaseDate}`)
process.stdout.write(lines.join('\n') + '\n')
@@ -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 })
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Strip __pycache__, *.pyc, tests, idle, tkinter from bundled Python to shrink the installer.
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readdirSync, statSync, rmSync, existsSync } from 'node:fs'
import { platform as osPlatform, arch as osArch } from 'node:os'
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 PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
if (!existsSync(PY_DIR)) {
console.error(`No bundled python at ${PY_DIR}`)
process.exit(1)
}
const PRUNE_DIR_NAMES = new Set(['__pycache__', 'test', 'tests', 'idle_test', 'idlelib', 'turtledemo', 'tkinter', 'ensurepip'])
const PRUNE_FILE_SUFFIXES = ['.pyc', '.pyo']
let bytesFreed = 0
function walk(dir) {
let entries
try { entries = readdirSync(dir) } catch { return }
for (const name of entries) {
const p = join(dir, name)
let st
try { st = statSync(p) } catch { continue }
if (st.isDirectory()) {
if (PRUNE_DIR_NAMES.has(name)) {
bytesFreed += dirSize(p)
rmSync(p, { recursive: true, force: true })
} else {
walk(p)
}
} else if (PRUNE_FILE_SUFFIXES.some(s => name.endsWith(s))) {
bytesFreed += st.size
rmSync(p, { force: true })
}
}
}
function dirSize(dir) {
let total = 0
try {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
const st = statSync(p)
total += st.isDirectory() ? dirSize(p) : st.size
}
} catch {}
return total
}
walk(PY_DIR)
console.log(`✓ Pruned ~${(bytesFreed / 1024 / 1024).toFixed(1)} MB from ${PY_DIR}`)
@@ -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}`)