Merge pull request #149746 from NousResearch/chore/pnpm-switch

chore(js): switch the workspaces from npm to pnpm
This commit is contained in:
Maciej Lisiewski authored and GitHub committed 2026-11-06 04:28:41 +01:00
commit 53a6ced422
304 files changed
+16243 -20998

No files matched your search

+2 -2
View File
@@ -33,7 +33,7 @@ node_modules
.build/
# Built artifacts that are regenerated inside the image. Excluded so local
# rebuilds on the developer's machine don't invalidate the npm-install layer
# rebuilds on the developer's machine don't invalidate the dependency-install layer
# that now depends on the full ui-tui/packages/hermes-ink/ tree being present.
ui-tui/dist/
ui-tui/packages/hermes-ink/dist/
@@ -78,7 +78,7 @@ runtime/
# Desktop app source (Tauri/Electron); the app is never installed in the container.
# apps/shared is the dashboard↔desktop websocket helper and is linked from
# web/package.json as a file: workspace dep — keep it in the build context.
# web/package.json as a workspace:* dep — keep it in the build context.
# apps/desktop is the Webapp renderer's build input (webapp_build stage); its
# local build outputs stay out.
apps/
+1 -1
View File
@@ -1,5 +1,5 @@
watch_file flake.nix flake.lock hermes pyproject.toml uv.lock
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file pnpm-lock.yaml pnpm-workspace.yaml package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_dir nix
watch_dir pm
+5 -1
View File
@@ -41,12 +41,16 @@ pyproject.toml @NousResearch/hermes-agent-core
package.json @NousResearch/hermes-agent-core
uv.lock @NousResearch/hermes-agent-core
*package-lock.json @NousResearch/hermes-agent-core
/pnpm-lock.yaml @NousResearch/hermes-agent-core
/flake.lock @NousResearch/hermes-agent-core
/pm/lock.json @NousResearch/hermes-agent-core
*Dockerfile @NousResearch/hermes-agent-core
# The npm supply-chain quarantine (min-release-age) lives here.
# A root .npmrc (registry, auth) would apply to every pnpm install.
.npmrc @NousResearch/hermes-agent-core
# The pnpm workspace's quarantine (minimumReleaseAge), its overrides, and the
# dependencies allowed to run build scripts (allowBuilds) live here.
/pnpm-workspace.yaml @NousResearch/hermes-agent-core
# Where PM fetches artifacts from.
/pm/artifact-mirror.json @NousResearch/hermes-agent-core
+1 -1
View File
@@ -55,7 +55,7 @@ outputs:
description: Run the PM project-lock check (pyproject.toml / uv.lock changes only).
value: ${{ steps.classify.outputs.uv_lock }}
npm_lock:
description: Post/update the semantic package-lock.json diff PR comment.
description: Post/update the semantic JS lockfile (pnpm-lock.yaml / package-lock.json) diff PR comment.
value: ${{ steps.classify.outputs.npm_lock }}
bootstrap:
description: Run the bootstrap installer lane (install.sh sandbox + stamp verification).
+1 -1
View File
@@ -1,7 +1,7 @@
name: Retry a flaky command
description: >-
Run a shell command, retrying on non-zero exit. For dependency installs
(npm ci, uv sync) whose only failures are transient network/toolchain
(pnpm install, uv sync) whose only failures are transient network/toolchain
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
instead of needing a manual re-run. Can also capture stdout as a step
output for commands whose result must be consumed by later steps.
+15 -10
View File
@@ -12,11 +12,11 @@ not resolve a version range, install another setup action, or modify the lock.
toolchain: all
extras: '[]'
test-environment: 'true'
- run: python --version && node --version && npm --version
- run: python --version && node --version && pnpm --version
```
`toolchain` defaults to `python` (Python and PM's private installer). `node` installs Node and npm;
`all` installs both pairs. There are no version overrides. `extras` is a JSON
`toolchain` defaults to `python` (Python and PM's private installer). `node` installs Node, npm and
pnpm; `all` installs both sets. There are no version overrides. `extras` is a JSON
array because GitHub action inputs are strings. Omit it for tools only; `[]`
installs the core Python dependencies. Set `test-environment: 'true'` to build an
independent interpreter with the `dev` and `test` dependency groups; any `extras`
@@ -24,8 +24,11 @@ then select runtime features in that interpreter. PM checks `uv.lock`, installs
the requested dependencies, and validates the environment. Only non-test installs
publish an application selection. It does not enable plugins.
Subsequent steps get `python`, `python3`, `node`, `npm` and `npx`
Subsequent steps get `python`, `python3`, `node`, `pnpm`, `npm` and `npx`
for the selected toolchain on PATH. The pinned npm precedes Node's bundled npm.
pnpm installs the workspace; npm serves the standalone npm projects (website/ and
the sidecars). `pnpm_config_store_dir` points pnpm's store into the runner's temp
dir, on the checkout's drive, so pnpm can hard-link from it on Windows too.
On Windows, PM selects the host architecture even if the bootstrap interpreter
runs under x64 emulation. A disposable command environment supplies the missing
`python3.exe` alias without changing the verified interpreter store.
@@ -48,7 +51,7 @@ The official, SHA-pinned `actions/cache` transports three independent caches:
| --- | --- | --- |
| Tools | PM store and installed facts | Native target, toolchain and PM lock hash |
| Python | PM's actual uv download/build cache | Native target, OS version, Python version, prune policy and dependency-file hash |
| Node | `npm config get cache` | Runner OS, native architecture and npm dependency-lock hash |
| Node | pnpm's store (`pnpm_config_store_dir`), or `npm config get cache` | Runner OS, native architecture, package manager and dependency-lock hash |
All restores use the exact primary key, without fallback prefixes, matching
setup-uv and setup-node's npm behavior. Successful jobs save at teardown;
@@ -61,11 +64,13 @@ Dependency caches never contain `node_modules` or virtual environments.
Keep an installed-tree cache in the caller if that job needs one.
`cache`, `cache-python` and `cache-node` independently disable the store, uv,
and npm caches. Each defaults to `true`; language-specific caches run only for
and node caches. Each defaults to `true`; language-specific caches run only for
that toolchain. `python-cache-dependency-glob` defaults to `pyproject.toml` and
`uv.lock`. `node-cache-dependency-path` defaults to `package-lock.json`; use
`website/package-lock.json` for site jobs. Both accept multiline glob strings.
An npm cache without a matching lockfile fails, rather than caching an
`uv.lock`. `node-package-manager` selects whose downloads the node cache holds:
`pnpm` (default) for the workspace, `npm` for the standalone npm projects.
`node-cache-dependency-path` defaults to `pnpm-lock.yaml` and `pnpm-workspace.yaml`;
site jobs use `npm` with `website/package-lock.json`. Both accept multiline glob
strings. A node cache without a matching lockfile fails, rather than caching an
unversioned dependency set.
`prune-python-cache: true` registers PM's lock-exact cache-pruning operation at teardown,
@@ -79,7 +84,7 @@ bundle ship gate enforces) and keeps downloaded wheels the lock still needs —
`uv cache prune --ci` would discard them, leaving a snapshot that warms almost
nothing.
Outputs include `python-version`, `uv-version`, `node-version`, `npm-version`,
Outputs include `python-version`, `uv-version`, `node-version`, `npm-version`, `pnpm-version`,
`python-path`, `venv`, `target`, and the three `*-cache-hit` flags.
Use the version outputs in installed-tree cache keys instead of repeating pins.
+28 -18
View File
@@ -1,8 +1,8 @@
name: Set up the locked PM toolchain
description: Install Python/uv and Node/npm from pm/lock.json; cache tools and dependency downloads.
description: Install Python/uv and Node/npm/pnpm from pm/lock.json; cache tools and dependency downloads.
inputs:
toolchain:
description: python (Python and uv), node (Node and npm), or all.
description: python (Python and uv), node (Node, npm and pnpm), or all.
default: python
packages:
description: Comma-separated extra PM tools to install beyond the toolchain roots (e.g. ffmpeg). Rides the tools cache like npm/uv.
@@ -32,19 +32,24 @@ inputs:
description: Save the uv cache on success. Set false for restore-only jobs.
default: ${{ github.event_name == 'pull_request' && 'false' || 'true' }}
cache-node:
description: Cache npm downloads; never cache node_modules.
description: Cache the package manager's downloads; never cache node_modules.
default: 'true'
save-node-cache:
description: Save the npm cache on success. Set false for restore-only jobs.
description: Save the node cache on success. Set false for restore-only jobs.
default: ${{ github.event_name == 'pull_request' && 'false' || 'true' }}
python-cache-dependency-glob:
description: Dependency files that invalidate the uv cache.
default: |
pyproject.toml
uv.lock
node-package-manager:
description: Whose downloads the node cache holds; pnpm (the workspace) or npm (website/ and the other standalone npm projects).
default: pnpm
node-cache-dependency-path:
description: npm lockfiles that invalidate the npm download cache; supports multiline globs.
default: package-lock.json
description: Files that invalidate the node download cache; supports multiline globs.
default: |
pnpm-lock.yaml
pnpm-workspace.yaml
prune-python-cache:
description: Use uv cache prune --ci before automatic saves. Explicit bundle saves preserve downloaded wheels.
default: 'false'
@@ -64,6 +69,9 @@ outputs:
npm-version:
description: Locked npm version.
value: ${{ steps.prepare.outputs.npm-version }}
pnpm-version:
description: Locked pnpm version.
value: ${{ steps.prepare.outputs.pnpm-version }}
python-path:
description: Interpreter for subsequent steps, including requested extras.
value: ${{ steps.dependencies.outputs.python-path || steps.install.outputs.python-path }}
@@ -80,7 +88,7 @@ outputs:
description: Exact uv dependency cache hit.
value: ${{ steps.python-cache.outputs.cache-hit || steps.python-cache-restore.outputs.cache-hit }}
node-cache-hit:
description: Exact npm dependency cache hit.
description: Exact node dependency cache hit.
value: ${{ steps.node-cache.outputs.cache-hit || steps.node-cache-restore.outputs.cache-hit }}
runs:
using: composite
@@ -179,34 +187,36 @@ runs:
cache: ${{ steps.install.outputs.uv-cache-path }}
lock-source: ${{ github.workspace }}
- name: Require an npm dependency lock for caching
- name: Require a dependency lock for caching
if: inputs.toolchain != 'python' && inputs.cache-node == 'true' && hashFiles(inputs.node-cache-dependency-path) == ''
shell: bash
run: |
printf '%s\n' '::error::No npm lock matched node-cache-dependency-path.'
printf '%s\n' '::error::No lock matched node-cache-dependency-path.'
exit 1
- name: Cache npm dependency downloads
# pnpm's content-addressed store, or npm's download cache for the standalone
# npm projects.
- name: Cache node dependency downloads
id: node-cache
if: inputs.toolchain != 'python' && inputs.cache-node == 'true' && inputs.save-node-cache == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.install.outputs.npm-cache-path }}
key: node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-npm-${{ hashFiles(inputs.node-cache-dependency-path) }}${{ inputs.cache-suffix != '' && format('-{0}', inputs.cache-suffix) || '' }}
path: ${{ inputs.node-package-manager == 'npm' && steps.install.outputs.npm-cache-path || steps.install.outputs.pnpm-store-path }}
key: node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-${{ inputs.node-package-manager }}-${{ hashFiles(inputs.node-cache-dependency-path) }}${{ inputs.cache-suffix != '' && format('-{0}', inputs.cache-suffix) || '' }}
# A lockfile bump misses the exact key, but the previous download set
# is still mostly valid — npm verifies every tarball it reinstalls.
# is still mostly valid — both verify every package they reinstall.
restore-keys: |
node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-npm-
node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-${{ inputs.node-package-manager }}-
- name: Restore npm dependency downloads
- name: Restore node dependency downloads
id: node-cache-restore
if: inputs.toolchain != 'python' && inputs.cache-node == 'true' && inputs.save-node-cache == 'false'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.install.outputs.npm-cache-path }}
key: node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-npm-${{ hashFiles(inputs.node-cache-dependency-path) }}${{ inputs.cache-suffix != '' && format('-{0}', inputs.cache-suffix) || '' }}
path: ${{ inputs.node-package-manager == 'npm' && steps.install.outputs.npm-cache-path || steps.install.outputs.pnpm-store-path }}
key: node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-${{ inputs.node-package-manager }}-${{ hashFiles(inputs.node-cache-dependency-path) }}${{ inputs.cache-suffix != '' && format('-{0}', inputs.cache-suffix) || '' }}
restore-keys: |
node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-npm-
node-cache-${{ runner.os }}-${{ steps.prepare.outputs.arch }}-${{ inputs.node-package-manager }}-
- name: Install the requested Python dependencies through PM
id: dependencies
+4 -4
View File
@@ -3,10 +3,10 @@
# Deliberately scoped to github-actions only.
#
# We do NOT enable Dependabot for pip / npm / any source-dependency ecosystem
# because we pin source dependencies exactly (uv.lock, package-lock.json) as
# part of our supply-chain posture. Automatic version-bump PRs against those
# pins would undermine the strategy — pins are moved deliberately, after
# review, not on a schedule.
# because we pin source dependencies exactly (uv.lock, pnpm-lock.yaml,
# package-lock.json) as part of our supply-chain posture. Automatic
# version-bump PRs against those pins would undermine the strategy — pins are
# moved deliberately, after review, not on a schedule.
#
# github-actions is the exception: action pins (we use full commit SHAs per
# supply-chain policy) must be updated when upstream actions publish
+14 -7
View File
@@ -5,7 +5,7 @@
// plain `check` gives that. This is the same selection rule the old CI matrix
// used, so the set of commands is unchanged. Only the schedule is different.
//
// This is not `npm run --ws check`, because that command is serial and stops
// This is not `pnpm -r run check`, because that command stops
// at the first workspace that fails. This runs every unit and fails at the
// end with the full list.
//
@@ -17,23 +17,30 @@
// `--concurrency N` sets the limit. `--list` prints the units and exits.
import { execFileSync, spawn } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { availableParallelism } from 'node:os'
import { join, relative } from 'node:path'
const IS_CI = Boolean(process.env.GITHUB_ACTIONS)
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const PNPM = 'pnpm'
/** @returns {{pkg: string, script: string}[]} */
function discoverUnits() {
const raw = execFileSync(NPM, ['query', '.workspace'], {
const raw = execFileSync(PNPM, ['ls', '--recursive', '--depth', '-1', '--json'], {
encoding: 'utf-8',
shell: process.platform === 'win32',
})
/** @type {{location: string, scripts?: Record<string,string>}[]} */
const pkgs = JSON.parse(raw)
/** @type {{path: string}[]} */
const projects = JSON.parse(raw)
const root = process.cwd()
/** @type {{pkg: string, script: string}[]} */
const units = []
for (const pkg of pkgs) {
for (const project of projects) {
const location = relative(root, project.path).split('\\').join('/')
// The root's `check` runs every workspace's check: it is not a unit.
if (!location) continue
const pkg = { location, ...JSON.parse(readFileSync(join(project.path, 'package.json'), 'utf-8')) }
const scripts = pkg.scripts || {}
const subs = Object.keys(scripts).filter((s) => /^check:.+$/.test(s))
if (subs.length > 0) {
@@ -49,7 +56,7 @@ function discoverUnits() {
function runUnit(unit) {
return new Promise((resolve) => {
const started = Date.now()
const child = spawn(NPM, ['run', '--prefix', unit.pkg, unit.script], {
const child = spawn(PNPM, ['--dir', unit.pkg, 'run', unit.script], {
// Buffer, and do not inherit. Children that share one stdout
// interleave their lines, and a failure is then hard to read.
stdio: ['ignore', 'pipe', 'pipe'],
+4 -4
View File
@@ -184,7 +184,7 @@ Then a gate step in `ci.yaml` rewrites the result:
|---|---|
| `python` | anything not provably irrelevant to Python (includes `skills/`, `website/docs/`, `website/scripts/`, `website/static/oauth/`) |
| `python_prod` | as `python`, excluding `tests/` |
| `frontend` | `ui-tui/`, `web/`, `apps/`, `tests-js/`, root npm manifests, the WhatsApp bridge, JS under `scripts/build/` |
| `frontend` | `ui-tui/`, `web/`, `apps/`, `tests-js/`, root pnpm manifests (`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`), the WhatsApp bridge, JS under `scripts/build/` |
| `rust` | `.rs`, Cargo files, bootstrap-installer crate paths |
| `catalog` | `plugin-catalog/*.yaml`, `contributors/emails/` (a diff with only these runs catalog tests **instead of** the Python suite) |
| `site` | the Docusaurus site |
@@ -194,7 +194,7 @@ Then a gate step in `ci.yaml` rewrites the result:
| `uv_lock` | `pyproject.toml` or `uv.lock` |
| `deps` | `pyproject.toml` |
| `scan` | Python files, `.pth`, setup hooks |
| `npm_lock` | any `package-lock.json` |
| `npm_lock` | `pnpm-lock.yaml` or any `package-lock.json` |
| `docker_meta` | Dockerfiles and Docker scripts |
| `docker`, `nix` | slow lanes, read by `docker.yml` and `nix.yml` (not by `ci.yaml`) |
| `e2e`, `e2e_upgrade`, `e2e_desktop_core`, `e2e_desktop_update` | computed, then forced off except on release or dispatch |
@@ -233,7 +233,7 @@ of every suite that reads it.
| ↳ Deny unrelated histories (step) | | `pull_request` only | push, dispatch, release |
| ↳ Check contributors (step) | | `python` or `catalog` | neither |
| ↳ Check uv.lock (step) | | `uv_lock` | neither lockfile nor pyproject changed |
| ↳ package-lock.json diff (advisory steps) | | PR and `npm_lock` | push, or no lockfile change |
| ↳ JS lockfile diff, `pnpm-lock.yaml` and `package-lock.json` (advisory steps) | | PR and `npm_lock` | push, or no lockfile change |
| ↳ Prompt surface diff (advisory steps) | | PR and `python` | push, or no Python change |
| ↳ Lint Docker scripts (steps) | | `docker_meta` | no Docker meta change |
| ↳ Supply-chain scan / PyPI upper bounds (steps) | | PR and `scan` / `deps` | push, or neither lane |
@@ -344,7 +344,7 @@ branch protection.
| `skills-index.yml` | 06:00, 18:00 | push, dispatch | See above |
| `skills-index-freshness.yml` | every 4 h | dispatch | Upstream only. Opens an issue if `/docs/api/skills-index.json` is more than 26 h stale, missing, or a source collapsed. Also checks CORS on `/docs/api/plugins.json`. |
| `docs-search-monitor.yml` | every 6 h at :17 | `workflow_run` after every **Deploy Site**; dispatch | Upstream only. Live Algolia and headless-browser probe of docs search. A red run is the alert. |
| `osv-scanner.yml` | Mondays 09:00 | dispatch | OSV scan of `uv.lock` and `package-lock.json`. Results go to the Security tab. Advisory (does not fail on vulnerabilities). Intentionally not per-PR. |
| `osv-scanner.yml` | Mondays 09:00 | dispatch | OSV scan of `uv.lock`, `pnpm-lock.yaml` and the nested `package-lock.json` files. Results go to the Security tab. Advisory (does not fail on vulnerabilities). Intentionally not per-PR. |
---
+18 -13
View File
@@ -5,8 +5,9 @@ name: Bootstrap installer build
# Azure Trusted Signing, macOS arm64 dmg signed with the Developer ID cert and
# notarized. Uploaded as run artifacts only; nothing is published.
#
# No caches of any kind: no actions/cache, no setup-node cache, and fresh
# npm/cargo/electron-builder cache dirs under RUNNER_TEMP.
# No caches of any kind: no actions/cache, no setup-pm cache, and fresh
# pnpm/cargo/electron-builder cache dirs under RUNNER_TEMP (setup-pm keeps
# pnpm's store there too).
on:
workflow_dispatch:
@@ -50,9 +51,11 @@ jobs:
ref: ${{ inputs.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0
- uses: ./.github/actions/setup-pm
with:
node-version: 26.12.1
toolchain: node
cache: false
cache-node: false
- name: Fresh cache dirs
shell: bash
@@ -60,7 +63,7 @@ jobs:
{
echo "CARGO_HOME=$RUNNER_TEMP/cargo-home"
echo "CARGO_TARGET_DIR=$RUNNER_TEMP/target"
echo "npm_config_cache=$RUNNER_TEMP/npm-cache"
echo "pnpm_config_cache_dir=$RUNNER_TEMP/pnpm-cache"
echo "ELECTRON_BUILDER_CACHE=$RUNNER_TEMP/electron-builder-cache"
} >> "$GITHUB_ENV"
echo "$USERPROFILE/.cargo/bin" >> "$GITHUB_PATH"
@@ -87,9 +90,9 @@ jobs:
shell: bash
run: |
rustup target add x86_64-pc-windows-msvc
npm ci --workspace apps/bootstrap-installer --include-workspace-root=false --prefer-online --no-audit --no-fund
pnpm install --frozen-lockfile --filter "{./apps/bootstrap-installer}..."
cd apps/bootstrap-installer
npx tauri build --target x86_64-pc-windows-msvc --no-bundle
pnpm exec tauri build --target x86_64-pc-windows-msvc --no-bundle
exe="$CARGO_TARGET_DIR/x86_64-pc-windows-msvc/release/Hermes-Setup.exe"
mkdir -p "$RUNNER_TEMP/out"
cp "$exe" "$RUNNER_TEMP/out/Hermes-Setup.exe"
@@ -126,7 +129,7 @@ jobs:
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_TOKEN_CREDENTIALS: prod
run: |
npm ci --workspace apps/desktop --include-workspace-root=false --prefer-online --no-audit --no-fund
pnpm install --frozen-lockfile --filter "{./apps/desktop}..."
# shellcheck disable=SC2016 # JS template literal, not shell
EXE="$(cygpath -w "$RUNNER_TEMP/out/Hermes-Setup.exe")" node --input-type=module -e '
import { pathToFileURL } from "node:url"
@@ -160,16 +163,18 @@ jobs:
ref: ${{ inputs.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0
- uses: ./.github/actions/setup-pm
with:
node-version: 26.12.1
toolchain: node
cache: false
cache-node: false
- name: Fresh cache dirs
run: |
{
echo "CARGO_HOME=$RUNNER_TEMP/cargo-home"
echo "CARGO_TARGET_DIR=$RUNNER_TEMP/target"
echo "npm_config_cache=$RUNNER_TEMP/npm-cache"
echo "pnpm_config_cache_dir=$RUNNER_TEMP/pnpm-cache"
} >> "$GITHUB_ENV"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
@@ -236,9 +241,9 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
rustup target add aarch64-apple-darwin
npm ci --workspace apps/bootstrap-installer --include-workspace-root=false --prefer-online --no-audit --no-fund
pnpm install --frozen-lockfile --filter "{./apps/bootstrap-installer}..."
cd apps/bootstrap-installer
npx tauri build --target aarch64-apple-darwin --bundles app,dmg
pnpm exec tauri build --target aarch64-apple-darwin --bundles app,dmg
- name: Sign, notarize and staple the dmg
run: |
+1 -1
View File
@@ -293,7 +293,7 @@ jobs:
# Every cheap repository guard (infographics, profile archives, case collisions, large blobs,
# lazy_deps imports, stale icons, unrelated histories, contributor attribution, uv.lock,
# prompt-surface and package-lock diffs, supply-chain scan, Docker lint) as steps of ONE job.
# prompt-surface and JS lockfile diffs, supply-chain scan, Docker lint) as steps of ONE job.
# Each used to be its own job on the same runner: a full billed minute for 0-13s of work.
# Each step keeps its own lane gate, so the detect outputs it needs are passed down.
repo-guards:
+1
View File
@@ -74,6 +74,7 @@ jobs:
with:
toolchain: all
extras: '[]'
node-package-manager: npm
node-cache-dependency-path: website/package-lock.json
- name: Install ruamel.yaml for skill extraction
+2 -2
View File
@@ -122,7 +122,7 @@ jobs:
- name: Install locked chat driver dependencies
id: dependencies
shell: bash
run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./tests-js}..." --prod --ignore-scripts
- name: Fetch one exact downloadable artifact
id: artifact
@@ -249,7 +249,7 @@ jobs:
# before Playwright attaches (every x64 smoke there failed; the same commit's arm64 MSIX on a
# GitHub-hosted runner chats), as the install/update GUI legs did (f1b290e7734).
runs-on: ${{ inputs.arch == 'arm64' && 'windows-latest-32-arm-core' || 'windows-latest-32-core' }}
# windows-11-arm runners are much slower (npm ci alone runs 12+ minutes and
# windows-11-arm runners are much slower (the dependency install alone runs 12+ minutes and
# the MSIX leg exceeds 30 total); x64 legs complete in ~14.
timeout-minutes: 45
cache-mode: read
+16 -12
View File
@@ -489,8 +489,7 @@ jobs:
run: |
$paths = @(
$env:GITHUB_WORKSPACE,
$env:RUNNER_TEMP,
(npm config get cache)
$env:RUNNER_TEMP
)
foreach ($p in $paths) {
try {
@@ -571,7 +570,7 @@ jobs:
# Universal-bundle assembly reuses two small slices of this leg's preparation
# rather than the multi-GB dependency snapshot: electron-builder's toolset
# downloads (winCodeSign kit, ATS dlib, dotnet runtime; preparation points
# ELECTRON_BUILDER_CACHE at packager/builder) and npm's content cache.
# ELECTRON_BUILDER_CACHE at packager/builder) and pnpm's store.
# Commit builds hold a read-only cache token, so their save is skipped here.
- name: Save the bundle-assembly caches
if: ${{ !cancelled() && steps.prepare.outcome == 'success' && matrix.target.label == 'win32-x64' && inputs.build_commit == '' && inputs.channel == '' }}
@@ -579,8 +578,8 @@ jobs:
with:
path: |
${{ github.workspace }}/.cache/desktop-inputs/packager/builder
${{ github.workspace }}/.cache/desktop-inputs/npm/_cacache
key: win32-assembly-v1-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}
${{ github.workspace }}/.cache/desktop-inputs/pnpm/store
key: win32-assembly-v1-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
- name: Restore verified payload signatures
id: payload-signatures
@@ -670,7 +669,7 @@ jobs:
if: inputs.skip-tests != true
shell: bash
working-directory: apps/desktop
run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/payload-sign-cache.test.mjs scripts/batch-sign-binaries.test.mjs
run: node node_modules/vitest/vitest.mjs run --project electron scripts/payload-sign-cache.test.mjs scripts/batch-sign-binaries.test.mjs
# Commit jobs share these steps but have read-only cache tokens.
- name: Save verified payload signatures
@@ -1394,7 +1393,6 @@ jobs:
HERMES_PAYLOAD_VERSION: ${{ needs.validate.outputs.payload-version }}
# The paths build-win32-x64 saves its bundle-assembly caches from.
ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/desktop-inputs/packager/builder
npm_config_cache: ${{ github.workspace }}/.cache/desktop-inputs/npm
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
@@ -1434,8 +1432,8 @@ jobs:
archive-inputs: ${{ inputs.disposable_run == '' && inputs.disposable_channel == '' }}
cache-python: false
save-tools-cache: false
# npm's cache comes from build-win32-x64 below; nothing ever saves
# setup-pm's per-job npm key for this read-only job.
# pnpm's store comes from build-win32-x64 below; nothing ever saves
# setup-pm's per-job node key for this read-only job.
cache-node: false
- name: Restore the bundle-assembly caches
@@ -1443,15 +1441,19 @@ jobs:
with:
path: |
${{ github.workspace }}/.cache/desktop-inputs/packager/builder
${{ github.workspace }}/.cache/desktop-inputs/npm/_cacache
key: win32-assembly-v1-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}
${{ github.workspace }}/.cache/desktop-inputs/pnpm/store
key: win32-assembly-v1-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
restore-keys: win32-assembly-v1-${{ runner.arch }}-
- name: Install the locked Windows bundle tooling
uses: ./.github/actions/retry
env:
# The store restored above. A step env, because setup-pm's
# GITHUB_ENV export would override a job-level one.
pnpm_config_store_dir: ${{ github.workspace }}/.cache/desktop-inputs/pnpm/store
with:
# No Electron/native postinstalls: only the builder's SDK downloader.
command: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund
command: pnpm install --frozen-lockfile --filter "{./apps/desktop}..." --ignore-scripts
- name: Retrieve Windows packages from R2
shell: bash
@@ -1792,6 +1794,8 @@ jobs:
uses: ./.github/actions/setup-pm
with:
toolchain: python
# scripts/termux/build.py installs and builds the TUI on this host with pnpm.
packages: pnpm
archive-inputs: true
cache-python: false
+1
View File
@@ -24,6 +24,7 @@ jobs:
uses: ./.github/actions/setup-pm
with:
toolchain: all
node-package-manager: npm
node-cache-dependency-path: website/package-lock.json
- name: Install website dependencies
+6 -6
View File
@@ -57,7 +57,7 @@ jobs:
# explicit binary preparation before the native fixtures launch.
- uses: ./.github/actions/retry
with:
command: npm ci
command: pnpm install --frozen-lockfile
- name: Prepare locked Electron runtime
run: node scripts/build/electron-runtime.mjs --source .
@@ -65,18 +65,18 @@ jobs:
- name: Build the desktop app
working-directory: apps/desktop
# The generator uses the runtime's Pillow + resvg-py, not a build-only venv.
run: npm run build
run: pnpm run build
# webapp-gateway-attach.spec.ts drives the Webapp from a real browser: it needs the
# Webapp renderer and a Chromium Playwright can launch.
- name: Build the Webapp renderer
working-directory: apps/desktop
run: npm run build:webapp
run: pnpm run build:webapp
- uses: ./.github/actions/retry
with:
working-directory: apps/desktop
command: npx playwright install --with-deps chromium
command: pnpm exec playwright install --with-deps chromium
# Package the Linux app the way users get it (electron-builder --dir; no
# installer formats, no publish). The packaging preparer acquires its
@@ -84,13 +84,13 @@ jobs:
# on this output (#121097) and boots the packaged binary to a first chat.
- name: Package the desktop app (electron-builder --dir)
working-directory: apps/desktop
run: npm run builder -- --dir --publish never --linux
run: pnpm run builder --dir --publish never --linux
- name: Run the core suite under xvfb
working-directory: apps/desktop
run: |
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test -c e2e/core/playwright.config.ts
pnpm exec playwright test -c e2e/core/playwright.config.ts
env:
CI: 'true'
HERMES_E2E_PYTHON: ${{ steps.pm.outputs.python-path }}
+3 -3
View File
@@ -76,10 +76,10 @@ jobs:
test-environment: 'true'
# The Playwright runner and its Electron driver. The install under test
# runs its own npm install inside the sandbox.
# runs its own dependency install inside the sandbox.
- uses: ./.github/actions/retry
with:
command: npm ci
command: pnpm install --frozen-lockfile
- name: Run the update suite under xvfb
working-directory: apps/desktop
@@ -100,7 +100,7 @@ jobs:
echo "::endgroup::"
done ) &
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test -c e2e/update/playwright.config.ts ${{ matrix.specs }}
pnpm exec playwright test -c e2e/update/playwright.config.ts ${{ matrix.specs }}
env:
CI: 'true'
HERMES_E2E_PYTHON: ${{ steps.pm.outputs.python-path }}
+9 -9
View File
@@ -55,18 +55,18 @@ jobs:
test-environment: 'true'
prune-python-cache: true
# Full npm ci (not --ignore-scripts): native addons need lifecycle scripts;
# Full install (not --ignore-scripts): native addons need lifecycle scripts;
# Electron's binary is prepared explicitly below.
# node-pty's native build is needed for the terminal pane.
- uses: ./.github/actions/retry
with:
command: npm ci
command: pnpm install --frozen-lockfile
- name: Prepare locked Electron runtime
run: node scripts/build/electron-runtime.mjs --source .
# ── Build desktop app ─────────────────────────────────────────────
# The Playwright step below runs `npm run build` before testing so
# The Playwright step below runs `pnpm run build` before testing so
# dist/ is always fresh — no separate build step needed here.
# ── Restore visual baseline screenshots from main ──────────────────
@@ -88,7 +88,7 @@ jobs:
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
# `pnpm run test:e2e` builds dist/ as a pretest hook so the renderer
# is always fresh — no separate build step needed.
- name: Run Playwright E2E tests
working-directory: apps/desktop
@@ -96,12 +96,12 @@ jobs:
# A stable run is on main too, but it tests a candidate. It compares and never rewrites baselines.
if [ "${{ github.ref_name }}" = "main" ] && [ -z "${{ inputs.commit }}" ]; then
echo "On main — generating/updating baseline screenshots"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
pnpm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
pnpm exec playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
pnpm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
pnpm exec playwright test --reporter=list
fi
env:
CI: 'true'
@@ -235,7 +235,7 @@ jobs:
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
fi
echo ""
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`pnpm exec playwright test --update-snapshots\` locally."
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
+2 -2
View File
@@ -134,7 +134,7 @@ jobs:
- name: Install locked chat driver dependencies
shell: bash
run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./tests-js}..." --prod --ignore-scripts
- name: Start screen recording
uses: ./.github/actions/e2e-screen-record
@@ -256,7 +256,7 @@ jobs:
- name: Install locked chat driver dependencies
shell: bash
run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./tests-js}..." --prod --ignore-scripts
- name: Start screen recording
uses: ./.github/actions/e2e-screen-record
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
- name: Install locked chat driver dependencies
shell: bash
run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./tests-js}..." --prod --ignore-scripts
# One recording mechanism on every OS (Xvfb gives headless linux a
# display; the same display serves any app the driver launches).
@@ -86,8 +86,8 @@ on:
required: false
type: number
# Green legs take up to ~40 min; from v2026.9.24 the update alone runs the venv->PM
# takeover plus npm ci and a Desktop rebuild (24 min in 108542725043, 11 of them one
# npm ci), and the app-update driver may wait 30 min. pty-run.py's deadline and the
# takeover plus the JS install and a Desktop rebuild (24 min in 108542725043, 11 of them
# one npm ci), and the app-update driver may wait 30 min. pty-run.py's deadline and the
# per-step budgets bound real hangs, so the job cap only has to clear a slow runner.
default: 90
bundle-manifest-url:
@@ -128,7 +128,7 @@ jobs:
cache-node: false
- name: Install locked chat driver dependencies
shell: bash
run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./tests-js}..." --prod --ignore-scripts
- name: Verify native helpers
shell: pwsh
run: |
@@ -217,7 +217,7 @@ jobs:
- name: Install locked chat driver dependencies
shell: bash
run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./tests-js}..." --prod --ignore-scripts
# A released tag prints its one-shot chat through prompt_toolkit, whose Windows
# output object needs a console screen buffer -- piping the CLI's stdout into the
+1 -1
View File
@@ -256,7 +256,7 @@ jobs:
BUNDLE_MACOS_MANIFEST: ${{ inputs.macos-bundle-manifest }}
BUNDLE_ROUTE: ${{ inputs.route }}
run: |
npm ci --ignore-scripts --no-audit --no-fund
pnpm install --frozen-lockfile --ignore-scripts
node tests/install/e2e-assets/bundle-plan.mjs
windows-bundled:
+10 -10
View File
@@ -1,6 +1,6 @@
name: auto-fix lint issues & formatting
# On push to main (or manual trigger), run `npm run fix` on each workspace
# On push to main (or manual trigger), run `pnpm run fix` on each workspace
# package and apply any changes via a PR.
#
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
@@ -27,7 +27,7 @@ name: auto-fix lint issues & formatting
# 2. apply-patch (privileged, contents: write + pull-requests: write)
# Checks out, downloads the patch artifact, applies it, pushes to the
# bot/js-autofix branch, creates/updates a PR, and enables auto-merge.
# This job never runs npm, never installs anything, never executes any
# This job never runs pnpm, never installs anything, never executes any
# repo code. The only input it trusts is the patch artifact.
# Skipped entirely when generate-patch reports no fixes (has-fixes != true).
# The PR auto-merges (squash) once CI passes. If CI fails or main moves,
@@ -44,7 +44,7 @@ on:
- '**/*.ts'
- '**/*.tsx'
- 'package.json'
- 'package-lock.json'
- 'pnpm-lock.yaml'
workflow_dispatch:
permissions:
@@ -65,7 +65,7 @@ jobs:
steps:
- uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1
- name: Set up locked Node and npm
- name: Set up locked Node and pnpm
id: node
uses: ./.github/actions/setup-pm
with:
@@ -74,14 +74,14 @@ jobs:
# --ignore-scripts: eslint only needs TS sources + eslint packages.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
command: pnpm install --frozen-lockfile --ignore-scripts
- name: npm run fix in all workspaces
- name: pnpm run fix in all workspaces
# continue-on-error: if un-fixable errors exist on main, we still want
# to commit whatever fixes were applied. The PR-time check in
# typecheck.yml is what blocks un-fixable errors from landing.
continue-on-error: true
run: npm run fix
run: pnpm run fix
- name: Produce patch
id: produce-patch
@@ -110,7 +110,7 @@ jobs:
echo "Patch size: $(wc -c < js-fix.patch) bytes"
# Reject patches that touch anything outside JS/TS/JSON sources.
# `npm run fix` should only ever modify those; anything else means
# `pnpm run fix` should only ever modify those; anything else means
# eslint/prettier or a plugin went rogue and we refuse to ship it.
BAD=$(git diff --name-only -- . "${EXCLUDES[@]}" | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true)
if [ -n "$BAD" ]; then
@@ -182,7 +182,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "fmt(js): \`npm run fix\` on merge"
git commit -m "fmt(js): \`pnpm run fix\` on merge"
# Push to the dedicated bot branch. Force-push is safe here:
# bot/js-autofix is a bot-only branch that gets rewritten each run.
@@ -205,7 +205,7 @@ jobs:
# (https://github.com/<org>/<repo>/pull/<number>).
PR_URL=$(gh pr create \
--head "$BOT_BRANCH" --base main \
--title 'fmt(js): `npm run fix` auto-fix' \
--title 'fmt(js): `pnpm run fix` auto-fix' \
--body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.')
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
fi
+19 -9
View File
@@ -43,11 +43,11 @@ jobs:
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
# setup-pm caches npm downloads, not the installed workspace tree.
# The job then extracts the full workspace node_modules
# setup-pm caches pnpm's store, not the installed workspace tree.
# The job then links the full workspace node_modules
# again and runs the native postinstalls again. Electron's binary is
# prepared explicitly below. This caches the installed tree itself, keyed on the
# lockfile, and skips ``npm ci`` on an exact hit. There are no
# lockfile, and skips ``pnpm install`` on an exact hit. There are no
# restore-keys: a partial hit leaves a stale tree, so anything other
# than an exact lockfile match reinstalls from the start.
#
@@ -55,7 +55,7 @@ jobs:
# artifacts and the explicitly prepared Electron dist, both inside the cached tree.
#
# The ~/.cache/electron download cache stays out of the key on purpose.
# ``npm ci`` is skipped on a hit, so nothing reads that cache. It only
# ``pnpm install`` is skipped on a hit, so nothing reads that cache. It only
# makes the archive larger.
- name: Restore node_modules
id: node-modules-cache
@@ -68,7 +68,7 @@ jobs:
ui-tui/packages/*/node_modules
tests-js/node_modules
web/node_modules
key: node-modules-scripts-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-npm${{ steps.node.outputs.npm-version }}-${{ hashFiles('package-lock.json') }}
key: node-modules-scripts-${{ runner.os }}-${{ runner.arch }}-node${{ steps.node.outputs.node-version }}-pnpm${{ steps.node.outputs.pnpm-version }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }}
# Vitest records each file's duration and result under
# node_modules/.vite/vitest and orders the next run from it, longest first.
@@ -79,10 +79,20 @@ jobs:
- name: Drop vitest run history from the restored tree
run: rm -rf node_modules/.vite/vitest apps/*/node_modules/.vite/vitest ui-tui/node_modules/.vite/vitest ui-tui/packages/*/node_modules/.vite/vitest tests-js/node_modules/.vite/vitest web/node_modules/.vite/vitest
# No --trust-lockfile: this install is the gate that checks every locked
# version against pnpm-workspace.yaml's release-age policy.
- uses: ./.github/actions/retry
if: steps.node-modules-cache.outputs.cache-hit != 'true'
with:
command: npm ci
command: pnpm install --frozen-lockfile
# The cached tree is keyed on the lockfile alone, so an exact hit proves
# nothing about the manifests. This checks the lockfile still matches
# every package.json and passes the release-age policy, without
# touching node_modules.
- name: Check the lockfile against the manifests and policy
if: steps.node-modules-cache.outputs.cache-hit == 'true'
run: pnpm install --frozen-lockfile --lockfile-only
- name: Prepare locked Electron runtime before parallel checks
run: node scripts/build/electron-runtime.mjs --source .
@@ -99,9 +109,9 @@ jobs:
run: node --test scripts/whatsapp-bridge/*.test.mjs
# Every check runs at the same time. The step fails only after all of
# them finish. There are two reasons this is not ``npm run --ws check``.
# them finish. There are two reasons this is not ``pnpm -r run check``.
#
# * ``--ws`` is serial and stops at the first workspace that fails. A
# * ``-r`` stops at the first workspace that fails. A
# run then reports one failure, where the matrix this replaced
# reported every failure together.
# * The unit of work is a CHECK, and not a workspace. apps/desktop is
@@ -111,7 +121,7 @@ jobs:
# its lint, ui, electron and plugin suites all run together. That
# is the same selection rule the old matrix job used.
#
# Discovery is ``npm query .workspace``. A new package or a new
# Discovery is ``pnpm ls -r --depth -1 --json``. A new package or a new
# ``check:*`` script needs no change here. An empty list is an error and
# not an empty run, because an empty run reports green after it checks
# nothing.
+27
View File
@@ -73,6 +73,33 @@ jobs:
with:
github-token: ${{ github.token }}
pnpm-deps-hash:
name: pnpm deps hash
needs: [detect]
if: needs.detect.outputs.nix == 'true' || inputs.release == true
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1
with:
ref: ${{ inputs.commit || github.sha }}
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
with:
extra_nix_config: |
experimental-features = nix-command flakes
# fetchPnpmDeps is a fixed-output derivation: a pnpm-lock.yaml change needs a
# new nix/pnpm-deps-hash. flake-check fails too, with the hash buried in its
# log; this job fails fast and names the hash Nix computed.
- name: Check nix/pnpm-deps-hash
run: |
set -o pipefail
if nix build .#pnpm-deps --no-link --print-build-logs 2>&1 | tee "$RUNNER_TEMP/pnpm-deps.log"; then exit 0; fi
got=$(sed -n 's/^ *got: *\(sha256-[A-Za-z0-9+/=]*\)$/\1/p' "$RUNNER_TEMP/pnpm-deps.log")
[ -n "$got" ] && echo "::error file=nix/pnpm-deps-hash::pnpm-lock.yaml changed the pnpm store. Set nix/pnpm-deps-hash to $got (or run: nix run .#update-pnpm-lockfile)"
exit 1
flake-check:
name: nix flake check
needs: [detect]
+2 -2
View File
@@ -1,6 +1,6 @@
name: OSV-Scanner
# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability
# Scans lockfiles (uv.lock, pnpm-lock.yaml, package-lock.json) against the OSV vulnerability
# database. Runs on a weekly schedule against main (and on manual dispatch);
# it is deliberately NOT part of per-PR CI — the findings are the repo-wide
# baseline of pinned-dep CVEs, identical for every PR, and belong in the
@@ -43,7 +43,7 @@ jobs:
# the five sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=pnpm-lock.yaml
--lockfile=website/package-lock.json
--lockfile=plugins/platforms/photon/sidecar/package-lock.json
--lockfile=scripts/whatsapp-bridge/package-lock.json
+9 -8
View File
@@ -47,7 +47,7 @@ on:
type: boolean
default: false
npm_lock:
description: detect's `npm_lock` lane (package-lock.json semantic diff, PRs only).
description: detect's `npm_lock` lane (JS lockfile semantic diff, PRs only).
type: boolean
default: false
uv_lock:
@@ -366,11 +366,12 @@ jobs:
overwrite: true
if-no-files-found: ignore
# ── Pull requests, npm_lock lane: package-lock.json semantic diff (advisory) ──────────
# ── Pull requests, npm_lock lane: JS lockfile semantic diff (advisory) ───────────────
# The raw textual diff of a lockfile is unreadable (npm reorders entries and rewrites
# integrity hashes), so scripts/ci/lockfile_diff.py set-diffs the {install path: version}
# maps at the merge base and at HEAD. Review signal, not enforcement.
# Covers pnpm-lock.yaml and the standalone npm projects' package-lock.json. The raw
# textual diff of a lockfile is unreadable (entries reorder and integrity hashes churn),
# so scripts/ci/lockfile_diff.py set-diffs the {package: version} maps at the merge base
# and at HEAD. Review signal, not enforcement.
- name: Generate semantic lockfile diff
id: lockfile-diff
if: ${{ !cancelled() && inputs.event_name == 'pull_request' && inputs.npm_lock }}
@@ -388,7 +389,7 @@ jobs:
if [ -s "$RUNNER_TEMP/lockfile-diff.md" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
{
echo "## package-lock.json semantic diff"
echo "## JS lockfile semantic diff"
echo ""
cat "$RUNNER_TEMP/lockfile-diff.md"
} >> "$GITHUB_STEP_SUMMARY"
@@ -409,7 +410,7 @@ jobs:
if [ "$CHANGED" = "true" ]; then
CONTENT=$(cat "$RUNNER_TEMP/lockfile-diff.md" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Verify the version changes are expected. package-lock.json changes need a hermes-agent-core review.\"}]}]"
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"JS lockfiles\",\"summary\":\"Locked JS dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Verify the version changes are expected. Lockfile changes need a hermes-agent-core review.\"}]}]"
else
STATUS="[]"
fi
@@ -460,7 +461,7 @@ jobs:
# Three-point diff (base...head) diffs from the merge base to HEAD,
# so only changes introduced by this PR are included — not changes
# that landed on main after the PR branched off.
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!pnpm-lock.yaml' ':!yarn.lock' || true)
FINDINGS=""
+6 -4
View File
@@ -86,13 +86,15 @@ jobs:
# tests-js generates real icons; the wrapper needs core Pillow/resvg.
extras: '[]'
cache-node: false
# tests-js builds desktop fixtures with apps/desktop's own toolchain (vite,
# node-pty): pnpm installs only the selected workspaces, so select it too.
- name: Install the locked JS workspace
run: npm ci --workspace ui-tui --workspace tests-js --include-workspace-root --include=dev --no-fund --no-audit
run: pnpm install --frozen-lockfile --filter "{./ui-tui}..." --filter "{./tests-js}..." --filter "{./apps/desktop}..."
- name: Check and bundle the TUI
run: |
npm run check --workspace tests-js
npm run check --workspace ui-tui
npm run build --workspace ui-tui
pnpm --dir tests-js run check
pnpm --dir ui-tui run check
pnpm --dir ui-tui run build
# A job-level `if` cannot read `secrets`, so the gate is a job that exports
# whether the release-signing environment is provisioned. Without R2 the
+3 -1
View File
@@ -122,7 +122,9 @@ jobs:
# Installer stage tests inherit setup-pm's HERMES_RUNTIME_DIR. Prepare
# the verified Git pin once, before per-file parallelism; otherwise
# each isolated PowerShell stage races to download/extract the archive.
packages: ${{ matrix.marker == 'windows' && 'git,ripgrep' || 'ripgrep' }}
# pnpm (and with it PM's Node): tests that drive the real
# scripts/build/node-deps.mjs and source builds need it on PATH.
packages: ${{ matrix.marker == 'windows' && 'git,ripgrep,pnpm' || 'ripgrep,pnpm' }}
# keychain: the vault-key tests run against the host's real Keychain / Credential Manager.
extras: '["all", "telegram", "anthropic", "mistral", "fal", "modal", "daytona", "parallel-web", "keychain"]'
test-environment: 'true'
+4 -1
View File
@@ -72,6 +72,9 @@ jobs:
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
with:
# Tests that drive the real scripts/build/node-deps.mjs need pnpm (and
# with it PM's Node) on PATH.
packages: pnpm
extras: '["all", "anthropic", "bedrock", "mistral", "fal", "modal", "daytona", "parallel-web", "keychain"]'
test-environment: 'true'
prune-python-cache: true
@@ -173,7 +176,7 @@ jobs:
# HERMES_E2E_REQUIRE_TUI=1 below turns a missing build into a failure.
uses: ./.github/actions/retry
with:
command: npm ci --workspace ui-tui --ignore-scripts --no-audit --no-fund && npm run build --workspace ui-tui
command: pnpm install --frozen-lockfile --filter "{./ui-tui}..." --ignore-scripts && pnpm --dir ui-tui run build
- name: Install ripgrep (prebuilt binary)
run: |
+3 -3
View File
@@ -24,7 +24,7 @@ jobs:
windows-bundle-tools:
name: Cold Windows SDK (${{ matrix.runner }})
runs-on: ${{ matrix.runner }}
# Cold ARM64 npm extraction alone can exceed 15 minutes on the hosted runner.
# Cold ARM64 package extraction alone can exceed 15 minutes on the hosted runner.
timeout-minutes: 45
strategy:
fail-fast: false
@@ -41,7 +41,7 @@ jobs:
cache-python: false
cache-node: false
- name: Install the locked bundle tooling without native postinstalls
run: npm ci --workspace apps/desktop --workspace tests-js --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund
run: pnpm install --frozen-lockfile --filter "{./apps/desktop}..." --filter "{./tests-js}..." --ignore-scripts
- name: Provision from an empty cache and execute the SDK
working-directory: apps/desktop
run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/windows-bundle-tools.test.mjs scripts/msix-shared.test.mjs scripts/sign-msix.test.mjs scripts/batch-sign-binaries.test.mjs
run: pnpm exec vitest run --project electron scripts/windows-bundle-tools.test.mjs scripts/msix-shared.test.mjs scripts/sign-msix.test.mjs scripts/batch-sign-binaries.test.mjs
@@ -38,10 +38,10 @@ jobs:
extras: '[]'
test-environment: 'true'
# Full npm ci (not --ignore-scripts): native addons need their lifecycle scripts.
# Full install (not --ignore-scripts): native addons need their lifecycle scripts.
- uses: ./.github/actions/retry
with:
command: npm ci
command: pnpm install --frozen-lockfile
# Electron 42+ has no postinstall; unpack the binary the attach spec launches.
- name: Prepare locked Electron runtime
@@ -52,14 +52,14 @@ jobs:
working-directory: apps/desktop
shell: bash
timeout-minutes: 15
run: npm run build
run: pnpm run build
- name: Attach spec on the dev tree
id: dev
working-directory: apps/desktop
shell: bash
timeout-minutes: 15
run: npx playwright test -c e2e/core/playwright.config.ts local-gateway-attach.spec.ts
run: pnpm exec playwright test -c e2e/core/playwright.config.ts local-gateway-attach.spec.ts
env:
CI: 'true'
HERMES_E2E_PYTHON: ${{ steps.pm.outputs.python-path }}
@@ -75,7 +75,7 @@ jobs:
working-directory: apps/desktop
shell: bash
timeout-minutes: 15
run: npm run builder -- --dir --publish never --win --x64
run: pnpm run builder --dir --publish never --win --x64
- name: Attach spec on the packaged build
if: always() && steps.dev.outcome != 'skipped'
@@ -89,7 +89,7 @@ jobs:
if (-not $exe) { Get-ChildItem -Recurse release | Select-Object -First 50 FullName; throw 'no packaged exe under release/win*-unpacked' }
"packaged exe: $($exe.FullName)"
$env:HERMES_E2E_PACKAGED_EXE = $exe.FullName
npx playwright test -c e2e/core/playwright.config.ts local-gateway-attach.spec.ts
pnpm exec playwright test -c e2e/core/playwright.config.ts local-gateway-attach.spec.ts
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
env:
CI: 'true'
+1 -1
View File
@@ -128,7 +128,7 @@ apps/desktop/.staging-*/
*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
# `pnpm run sync-assets` (see web/package.json).
web/public/fonts/
web/public/ds-assets/
-75
View File
@@ -1,75 +0,0 @@
# needed to prevent bad npm that has min-release-age but not exclude
engine-strict=true
min-release-age=14
# allow assistant-ui packages & a couple specific deps since they update a LOT.
# remove this when we stabilize (or we haven't updated in 2 wks)
min-release-age-exclude[]=@assistant-ui/*
min-release-age-exclude[]=assistant-cloud
min-release-age-exclude[]=assistant-stream
min-release-age-exclude[]=@radix-ui/*
min-release-age-exclude[]=radix-ui
min-release-age-exclude[]=safe-content-frame
# react-router 8.3.0 includes fixes for vulns. remove this when 8.3.0 is > 2wks old.
min-release-age-exclude[]=react-router
# eslint 10.8.0 includes fixes for vulns. remove this when 10.8.0 is > 2wks old.
min-release-age-exclude[]=eslint
min-release-age-exclude[]=@eslint/*
# tar 7.5.21 includes fixes for vulns. remove this when 7.5.21 is > 2wks old
min-release-age-exclude[]=tar
# concurrently 10.0.4 includes fixes for vulns. remove this when 10.0.4 is > 2wks old
min-release-age-exclude[]=concurrently
# fast-uri 3.1.4 includes fixes for vulns. remove this when 3.1.4 is > 2wks old
min-release-age-exclude[]=fast-uri
# minimatch 10.2.6 includes fixes for vulns. remove this when 10.2.6 is > 2wks old
min-release-age-exclude[]=minimatch
# brace-expansion 5.0.9 includes fixes for vulns. remove this when 5.0.9 is > 2wks old
min-release-age-exclude[]=brace-expansion
# js-yaml 4.3.1 includes fixes for GHSA-5p4m-2wfm-xmqj. remove when > 2wks old (rel 2026-07-31)
min-release-age-exclude[]=js-yaml
# nanoid 3.3.17 includes fixes for GHSA-2v37-7h3g-55p8. remove when > 2wks old (rel 2026-08-03)
min-release-age-exclude[]=nanoid
# dompurify 3.4.17 fixes GHSA-55q2-fjhq-7xh7 follow-ups (low). remove when 3.4.17 is > 2wks old (rel 2026-10-12)
min-release-age-exclude[]=dompurify
# ink needs
min-release-age-exclude[]=lightningcss
min-release-age-exclude[]=postcss
# source-map-js 1.2.2 fixes GHSA-68fv-2mgg-jv7q (high, event-loop DoS). remove when 1.2.2 is > 2wks old (rel 2026-09-30)
min-release-age-exclude[]=source-map-js
# http-cache-semantics 4.3.0 fixes GHSA-ch52-4w7c-c8xp (high, cross-user cached data disclosure).
# remove when 4.3.0 is > 2wks old (rel 2026-10-04)
min-release-age-exclude[]=http-cache-semantics
# ip-address 10.7.4 fixes GHSA-8xj3-p8x2-m3c7-family alerts (medium). remove when 10.7.4 is > 2wks old (rel 2026-10-11)
min-release-age-exclude[]=ip-address
# sanitize-html 2.18.1 fixes GHSA-c9rx-x947-fp3h (medium), introduced by 2.18.0 which fixes
# GHSA-x3q4-9hxx-gx8m/GHSA-cv27-6wvh-8x7j/GHSA-374f-7chj-9948. remove when 2.18.1 is > 2wks old (rel 2026-10-14)
min-release-age-exclude[]=sanitize-html
# vitest 4.1.12 is a patch bump (4.1.11 -> 4.1.12). remove when 4.1.12 is > 2wks old (rel 2026-10-15)
min-release-age-exclude[]=vitest
min-release-age-exclude[]=@vitest/*
# electron 44.9.3 backports upstream Chromium/V8 security fixes, and 44.9.1+ carries the
# eMudhra root distrust per #142533. remove when 44.9.3 is > 2wks old (rel 2026-10-21)
min-release-age-exclude[]=electron
# pele 1.13.0 is the release the desktop diagram swap was measured and parity-checked on (math
# labels need 1.8.0+). remove when 1.13.0 is > 2wks old (rel 2026-10-31)
min-release-age-exclude[]=pele
+1 -1
View File
@@ -111,7 +111,7 @@ hermes pm lock
source ./activate
```
Para JavaScript, ejecuta `npm ci` en el workspace correspondiente. Consulta
Para JavaScript, ejecuta `pnpm install --frozen-lockfile` en la raíz del repositorio. Consulta
[CONTRIBUTING.md](CONTRIBUTING.md) para los requisitos completos.
---
+8 -3
View File
@@ -119,6 +119,7 @@ The rules that most often send a submission back: the plugin must extend Hermes
| **Git** | With the `git-lfs` extension installed |
| **Python 3.14** | The project requires `>=3.14,<3.15`; PM provides the pinned interpreter |
| **Node.js** | Use the PM pin, or a version accepted by root `package.json`: `>=26` |
| **pnpm** | Exactly the root `package.json` `devEngines` pin; `source ./activate` provides it |
### PM developer environment
@@ -201,15 +202,19 @@ than copying production credentials into the checkout.
### JavaScript workspaces and website
From the repository root, run `npm ci` for the desktop, TUI, dashboard, and
shared JS workspaces. The website is separate:
From the repository root, run `pnpm install --frozen-lockfile` for the desktop,
TUI, dashboard, and shared JS workspaces. Root `package.json` pins pnpm in
`devEngines` (`source ./activate` provides that exact version). Hermes's builds and
`hermes update` refuse any other pnpm version; a direct `pnpm install` does not check
it. The root `preinstall` script refuses `npm install`. The website is a separate npm
project:
```bash
npm ci --prefix website
npm run build:fast --prefix website
```
Use a Node/npm version accepted by the corresponding `package.json` engines.
Use a Node version accepted by the corresponding `package.json` engines.
Native desktop dependencies can also require the platform build toolchain.
Logos and icons are generated from `assets/nous-girl-*.svg` and
+29 -22
View File
@@ -314,14 +314,21 @@ RUN /opt/hermes/.venv/bin/python -I scripts/generate_icons.py --source /opt/herm
# Frontend dependencies never enter the runtime layers.
FROM runtime_base AS frontend_build
COPY package.json package-lock.json ./
# pnpm installs the workspaces; nothing in the runtime image runs it, so only
# the build stages (this one and webapp_build) stage it.
RUN python3 -c 'from pathlib import Path; from pm import ensure, installed_package; ensure("pnpm", explicit=True); Path("/usr/local/bin/pnpm").symlink_to(installed_package("pnpm").binary)' && \
pnpm --version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
COPY apps/shared/ apps/shared/
COPY scripts/build/node-deps.mjs scripts/build/electron-runtime.mjs scripts/build/
ENV npm_config_install_links=false
RUN node scripts/build/node-deps.mjs --source /opt/hermes --workspace ui-tui --workspace web
COPY scripts/build/node-deps.mjs scripts/build/electron-runtime.mjs scripts/build/pnpm-lock.mjs scripts/build/
# Root node_modules/typescript is a link into pnpm's virtual store, which never
# reaches the runtime image: stage a dereferenced copy for it.
RUN node scripts/build/node-deps.mjs --source /opt/hermes --workspace ui-tui --workspace web && \
mkdir -p /opt/products && \
cp -RL node_modules/typescript /opt/products/typescript
COPY pyproject.toml uv.lock ./
COPY web/ web/
@@ -337,12 +344,13 @@ RUN node scripts/build/tui.mjs --source /opt/hermes --out /opt/products/tui && \
# which installs Desktop's dependencies in a private workspace. A stage of its
# own so Desktop changes leave the TUI and dashboard layers cached.
FROM runtime_base AS webapp_build
COPY package.json package-lock.json .npmrc ./
RUN python3 -c 'from pathlib import Path; from pm import ensure, installed_package; ensure("pnpm", explicit=True); Path("/usr/local/bin/pnpm").symlink_to(installed_package("pnpm").binary)' && \
pnpm --version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/desktop/package.json apps/desktop/
COPY apps/desktop/scripts/build-webapp.mjs apps/desktop/scripts/
COPY apps/shared/package.json apps/shared/
COPY scripts/build/*.mjs scripts/build/
ENV npm_config_install_links=false
# Dependencies first, keyed only by the manifests and the recipe, so a source
# change reuses this layer and the build below reuses its install.
RUN node apps/desktop/scripts/build-webapp.mjs --deps-only
@@ -351,7 +359,7 @@ RUN node apps/desktop/scripts/build-webapp.mjs
FROM python_deps AS runtime
# Standalone TypeScript linting is a runtime feature; Vite/esbuild are not.
COPY --from=frontend_build /opt/hermes/node_modules/typescript /opt/hermes/node_modules/typescript
COPY --from=frontend_build /opt/products/typescript /opt/hermes/node_modules/typescript
RUN mkdir -p /opt/hermes/node_modules/.bin && \
ln -s ../typescript/bin/tsc /opt/hermes/node_modules/.bin/tsc
@@ -473,22 +481,21 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
# nix/packaged-release path the launcher was designed for.
# Point the TUI launcher at the prebuilt bundle baked at build time (the
# frontend_build stage's scripts/build/tui.mjs product). This makes
# _make_tui_argv take the prebuilt-bundle fast path
# (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip source
# dependency preparation (scripts/build/node-deps.mjs, a pnpm install)
# entirely — exactly the nix/packaged-release path the launcher was designed for.
#
# Why this is required (not just an optimization): the root package-lock.json
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
# never `npm install`ed here). So the actualized node_modules permanently
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
# every launch, and the runtime `npm install` it triggers (a) can never
# converge against the partial monorepo and (b) races itself across concurrent
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
# check. (A separate launcher hardening is tracked independently.)
# Why this is required (not just an optimization): the runtime image carries
# no workspace node_modules (frontend dependencies stay in frontend_build; only
# a TypeScript copy ships) and no pnpm. So the launcher's install check fails on
# every launch, and the install it triggers (a) can never converge here and
# (b) races itself across concurrent embedded-chat (/api/pty) connections →
# ENOTEMPTY → the chat tab dies with a 502 / "[session ended]". Pointing at the
# prebuilt bundle sidesteps the whole check. (A separate launcher hardening is
# tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
+1 -1
View File
@@ -50,7 +50,7 @@ Ejecuta esto en PowerShell:
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
El instalador de código fuente usa PM para Python 3.14, Node.js, npm,
El instalador de código fuente usa PM para Python 3.14, Node.js, npm, pnpm,
ripgrep, FFmpeg y las dependencias de Python. Si falta Git, descarga el archivo
verificado de Git for Windows en el almacén de Hermes, sin reemplazar el Git
del sistema. MSIX/App Installer es una distribución separada.
+1 -1
View File
@@ -50,7 +50,7 @@ Run this in PowerShell:
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
The source installer delegates Python 3.14, Node.js, npm, ripgrep, FFmpeg,
The source installer delegates Python 3.14, Node.js, npm, pnpm, ripgrep, FFmpeg,
and Python dependencies to PM. If Git is absent, it stages the verified Git
for Windows archive in Hermes' tool store. It does not replace your system Git.
See [installation methods](https://hermes-agent.nousresearch.com/docs/getting-started/installation)
+1 -1
View File
@@ -57,7 +57,7 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1)
</div>
سورس انسٹالر Python 3.14، Node.js، npm، ripgrep، FFmpeg اور Python کی
سورس انسٹالر Python 3.14، Node.js، npm، pnpm، ripgrep، FFmpeg اور Python کی
ڈیپینڈینسیز کے لیے PM استعمال کرتا ہے۔ اگر Git موجود نہ ہو تو Git for Windows
کا تصدیق شدہ آرکائیو ہرمیس کے ٹول اسٹور میں نصب کرتا ہے۔ سسٹم کا Git تبدیل نہیں
ہوتا۔ MSIX/App Installer ایک الگ پیکیج ہے۔
+2 -2
View File
@@ -13,10 +13,10 @@
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit",
"check": "npm run typecheck && npm run lint",
"check": "pnpm run typecheck && pnpm run lint",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix"
"fix": "pnpm run lint:fix"
},
"dependencies": {
"@nanostores/react": "1.1.0",
@@ -4,9 +4,9 @@
"version": "0.0.0",
"identifier": "com.nousresearch.hermes.setup",
"build": {
"beforeDevCommand": "npm run dev",
"beforeDevCommand": "pnpm run dev",
"devUrl": "http://127.0.0.1:5175",
"beforeBuildCommand": "npm run build",
"beforeBuildCommand": "pnpm run build",
"frontendDist": "../dist"
},
"app": {
+1 -1
View File
@@ -364,7 +364,7 @@ export async function openLogDir(): Promise<void> {
// Dev-only isolated preview ("fake boot")
//
// Synthesises the manifest + stage/log events Rust normally streams, so the
// whole reskin can be reviewed in a plain browser (`npm run dev`):
// whole reskin can be reviewed in a plain browser (`pnpm run dev`):
// ?fake=install welcome → [ INSTALL ] → success
// ?fake=update auto-runs the granular update flow
// ?fake=failure install that fails partway
+4 -4
View File
@@ -118,8 +118,8 @@ python scripts/bundles/desktop.py --tag=vX.Y.Z
Replace `vX.Y.Z` with an actual immutable tag. Stable tags must match the
version in `pyproject.toml`. Canary tags use the release script's tag grammar.
Start with host Python 3.11+ and Git. Use `python3` if that is your host's command.
Preparation asks PM for the pinned Python, Node, npm and private installer;
preinstalling a separate Node/npm/uv toolchain is not required. Native dependency
Preparation asks PM for the pinned Python, Node, pnpm and private installer;
preinstalling a separate Node/pnpm/uv toolchain is not required. Native dependency
builds still need the platform's compiler, SDK and libraries. Windows ARM64 uses
the shared Visual Studio/Clang/Rust/static OpenSSL preparation provider, which
can require Administrator permissions for missing system components. macOS
@@ -378,8 +378,8 @@ separate development home. Activation supplies the toolchain, not `node_modules`
From the repository root:
```sh
npm ci
npm run dev --workspace apps/desktop
pnpm install --frozen-lockfile
pnpm --dir apps/desktop run dev
```
For an ordinary package of that desktop build, use the workspace's
+3 -3
View File
@@ -272,10 +272,10 @@ Verify scheduling changes with native hidden/minimized windows and real preview
navigation, typing and capture. Browser automation's focus emulation can keep
hidden frames awake and conceal regressions; disable it for these checks.
Software-rendered VM timings are not hardware GPU or battery measurements.
With a graphical session (or Xvfb), `npm run test:preview-activity-native
--workspace apps/desktop` exercises real hidden-frame navigation (including
With a graphical session (or Xvfb), `pnpm --dir apps/desktop run
test:preview-activity-native` exercises real hidden-frame navigation (including
lease-only rAF and capture), overlapping operations, cancellation and plugin
completion values in a disposable profile. Add `-- --minimized` with a running
completion values in a disposable profile. Add `--minimized` with a running
window manager to exercise actual iconification rather than hiding the window.
## Nous free tier: state is pulled, never latched in the renderer
+16 -16
View File
@@ -84,28 +84,28 @@ shortcut helper. Prebuilt installers include it; no compiler is needed at runtim
Want to hack on the app itself? Install workspace deps from the repo root once, then run the dev server from this directory:
```bash
npm install # from repo root — links apps/desktop, web, apps/shared
pnpm install # from repo root — links apps/desktop, web, apps/shared
cd apps/desktop
npm run dev # Vite renderer + Electron, which boots the Python backend
pnpm run dev # Vite renderer + Electron, which boots the Python backend
```
Point the app at a specific source checkout, or sandbox it away from your real config:
```bash
# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock
../../scripts/dev-sandbox.sh npm run dev
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
HERMES_HOME=$HOME/.hermes/cache/scratch/throwaway npm run dev
npm run dev:fake-boot # exercise the startup overlay with deterministic delays
../../scripts/dev-sandbox.sh pnpm run dev
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone pnpm run dev
HERMES_HOME=$HOME/.hermes/cache/scratch/throwaway pnpm run dev
pnpm run dev:fake-boot # exercise the startup overlay with deterministic delays
```
### Building installers
```bash
npm run dist:mac # DMG + zip
npm run dist:win # MSIX
npm run dist:linux # AppImage + deb + rpm
npm run pack # unpacked app under release/ (no installer)
pnpm run dist:mac # DMG + zip
pnpm run dist:win # MSIX
pnpm run dist:linux # AppImage + deb + rpm
pnpm run pack # unpacked app under release/ (no installer)
```
These are ordinary packaging commands, not complete tagged payload builds.
@@ -217,14 +217,14 @@ and retained background sockets continue receiving events from running jobs.
Run before opening a PR (lint may surface pre-existing warnings but must exit cleanly):
```bash
npm run fix
npm run typecheck
npm run lint
npm run test:ui
npm run test:desktop:platforms
pnpm run fix
pnpm run typecheck
pnpm run lint
pnpm run test:ui
pnpm run test:desktop:platforms
```
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
Run `pnpm run test:desktop:all` for install, boot, update, packaging, or other
release-path changes.
### Troubleshooting
@@ -67,7 +67,7 @@
* does) instead of quietly writing plaintext. See the branch comments in each
* test for the reasoning.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import * as fs from 'node:fs'
+1 -1
View File
@@ -6,7 +6,7 @@
* resolution fails with a controlled error message. The app should show the
* BootFailureOverlay with retry/repair actions.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { allowErrorBanners, test } from './test'
+2 -2
View File
@@ -7,9 +7,9 @@
*
* electron → hermes serve (python) → mock provider → renderer
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
* Run from the nix devshell:
* npm exec playwright test e2e/boot.spec.ts --reporter=list
* pnpm exec playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from './test'
+1 -1
View File
@@ -5,7 +5,7 @@
* provider). The mock server returns a canned reply, so we verify the
* response text shows up in the chat transcript.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { runDesktopChatSmoke } from '../../../tests-js/scripts/desktop-chat-smoke'
+2 -2
View File
@@ -94,11 +94,11 @@ by the turn's own marker), no visual baselines, `retries: 0`, one worker,
sandboxed `HOME`/`HERMES_HOME`/user-data per test, all `HERMES_*` and
credential env stripped from the spawned app.
Run locally (Linux, after `npm run build` in `apps/desktop`):
Run locally (Linux, after `pnpm run build` in `apps/desktop`):
```sh
cd apps/desktop
xvfb-run -a npx playwright test -c e2e/core/playwright.config.ts
xvfb-run -a pnpm exec playwright test -c e2e/core/playwright.config.ts
```
`HERMES_E2E_CORE_ROOT` picks the sandbox parent dir (default: OS tmpdir);
+1 -1
View File
@@ -204,7 +204,7 @@ export function webappLaunchLink(sandbox: CoreSandbox, profile?: string): string
export async function launchCoreApp(env: Record<string, string>): Promise<{ app: ElectronApplication; page: Page }> {
if (!fs.existsSync(path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs'))) {
throw new Error("Desktop dist not built: run 'npm run build' in apps/desktop first")
throw new Error("Desktop dist not built: run 'pnpm run build' in apps/desktop first")
}
const app = await _electron.launch({
+1 -1
View File
@@ -129,7 +129,7 @@ function packagedBuildOrSkip(): PackagedBuild {
if (missing.length > 0) {
const reason =
`no packaged build (missing ${missing.join(', ')}): run ` +
"'npm run build && npm run builder -- --dir --publish never --linux' in apps/desktop"
"'pnpm run build && pnpm run builder --dir --publish never --linux' in apps/desktop"
if (process.env.CI && process.env.HERMES_E2E_REQUIRE_PACKAGED === '1') {
throw new Error(`HERMES_E2E_REQUIRE_PACKAGED=1 but ${reason}`)
@@ -16,8 +16,8 @@
* Webapp turn raised, and the Webapp stops a turn Electron started. A third puts the Webapp behind
* the sign-in gate: `hermes webapp --sign-out` signs out a browser that signed in with a password.
*
* Linux (the /proc ancestry walk); needs the Webapp renderer (`npm run build:webapp`) and a
* Chromium Playwright can launch (`npx playwright install chromium`, or HERMES_E2E_CHROMIUM).
* Linux (the /proc ancestry walk); needs the Webapp renderer (`pnpm run build:webapp`) and a
* Chromium Playwright can launch (`pnpm exec playwright install chromium`, or HERMES_E2E_CHROMIUM).
*/
import { spawnSync } from 'node:child_process'
@@ -100,7 +100,7 @@ test('the gateway serves the Webapp and runs every turn its browser sends', asyn
test.skip(process.platform !== 'linux', 'walks /proc to prove which process ran the tool')
expect(
fs.existsSync(path.join(DESKTOP_ROOT, 'dist-webapp', 'index.html')),
'the Webapp renderer is built (npm run build:webapp)'
'the Webapp renderer is built (pnpm run build:webapp)'
).toBe(true)
const provider = await startScriptedProvider()
@@ -218,7 +218,7 @@ test('a Webapp tab opened on a named profile runs and reads that profile', async
test.setTimeout(420_000)
expect(
fs.existsSync(path.join(DESKTOP_ROOT, 'dist-webapp', 'index.html')),
'the Webapp renderer is built (npm run build:webapp)'
'the Webapp renderer is built (pnpm run build:webapp)'
).toBe(true)
const provider = await startScriptedProvider()
@@ -298,7 +298,7 @@ test("the Webapp and Electron Desktop answer and stop each other's turns on one
test.setTimeout(480_000)
expect(
fs.existsSync(path.join(DESKTOP_ROOT, 'dist-webapp', 'index.html')),
'the Webapp renderer is built (npm run build:webapp)'
'the Webapp renderer is built (pnpm run build:webapp)'
).toBe(true)
const provider = await startScriptedProvider()
@@ -448,7 +448,7 @@ test('signing out of a Webapp behind sign-in signs the browser out', async () =>
test.skip(process.platform !== 'linux', 'runs with the other Webapp journeys (Linux CI)')
expect(
fs.existsSync(path.join(DESKTOP_ROOT, 'dist-webapp', 'index.html')),
'the Webapp renderer is built (npm run build:webapp)'
'the Webapp renderer is built (pnpm run build:webapp)'
).toBe(true)
const provider = await startScriptedProvider()
@@ -10,7 +10,7 @@
* the only copy of the streamed reply, and the reopened chat shows it.
*
* Real gateway, real Webapp renderer, real browser storage; only the model is scripted. Linux, like
* the other Webapp journeys; needs `npm run build:webapp` and a Chromium Playwright can launch.
* the other Webapp journeys; needs `pnpm run build:webapp` and a Chromium Playwright can launch.
*/
import * as fs from 'node:fs'
@@ -226,7 +226,7 @@ async function withWebapp(
test.skip(process.platform !== 'linux', 'runs with the other Webapp journeys (Linux CI)')
expect(
fs.existsSync(path.join(DESKTOP_ROOT, 'dist-webapp', 'index.html')),
'the Webapp renderer is built (npm run build:webapp)'
'the Webapp renderer is built (pnpm run build:webapp)'
).toBe(true)
const provider = await startScriptedProvider()
+1 -1
View File
@@ -14,7 +14,7 @@
*
* CROSS_GATEWAY_LEG=main records the picker (the repro: Homelab's bot absent)
* and stops. CROSS_GATEWAY_SHOTS=<dir> saves screenshots; PROBE_RESULT lines
* carry the evidence. Needs `npm run build` (dist/) first.
* carry the evidence. Needs `pnpm run build` (dist/) first.
*/
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
+7 -9
View File
@@ -7,15 +7,14 @@
*
* Three rules the previous single-path probe got wrong:
*
* 1. The binary is not always under the REPO ROOT. This is an npm workspaces
* repo, and npm only hoists a dependency to the root when nothing conflicts
* — otherwise `electron` installs into `apps/desktop/node_modules`. Both
* layouts are normal, so both have to be searched, nearest package first.
* 1. The binary is not under the REPO ROOT. pnpm links `electron` into
* `apps/desktop/node_modules`, the workspace that declares it, and never
* hoists it to the root.
* 2. The binary is `electron.exe` on Windows. A bare `electron` never exists
* there, so the probe could only ever miss.
* 3. `which` is not a command on Windows. The PATH fallback spawned it
* unconditionally, so on Windows the fallback failed for the wrong reason
* and the error message blamed a missing `npm install`.
* and the error message blamed a missing install.
*/
import { spawnSync } from 'node:child_process'
@@ -29,8 +28,7 @@ export function electronBinaryName(platform: NodeJS.Platform = process.platform)
}
/**
* Where an npm install can leave the binary, in probe order: nearest package
* first, so a workspace-local install wins over a stale hoisted one.
* Where an install leaves the binary under each root, in probe order.
*/
export function electronDistCandidates(roots: string[], platform: NodeJS.Platform = process.platform): string[] {
return roots.map((root) => path.join(root, 'node_modules', 'electron', 'dist', electronBinaryName(platform)))
@@ -64,7 +62,7 @@ export function electronPackagePath(from: string): null | string {
/**
* Resolve the Electron binary, or throw with the layouts that were searched.
*
* `roots` are searched in order; pass the desktop package before the repo root.
* `roots` are searched in order.
*/
export function resolveElectronBinary(roots: string[]): string {
for (const root of roots) {
@@ -95,6 +93,6 @@ export function resolveElectronBinary(roots: string[]): string {
throw new Error(
`Electron binary not found. Searched ${electronDistCandidates(roots).join(', ')} and PATH. ` +
'Run "npm install" from the repo root to install devDependencies.',
'Run "pnpm install --frozen-lockfile" from the repo root to install devDependencies.',
)
}
@@ -12,11 +12,10 @@ describe('electronDistCandidates', () => {
const desktop = path.join('repo', 'apps', 'desktop')
const repo = 'repo'
it('probes the workspace-local install before the hoisted one', () => {
// npm only hoists `electron` to the repo root when nothing conflicts, so
// apps/desktop/node_modules is an ordinary outcome of `npm install`, not a
// broken tree. Probing only the repo root is what makes the suite refuse to
// start with "run npm install" on a tree that has electron installed.
it('probes each root in the order given', () => {
// The nearest package comes first: probing only the repo root is what made
// the suite refuse to start on a tree whose electron lives in the desktop
// workspace, where pnpm links it.
expect(electronDistCandidates([desktop, repo], 'linux')).toEqual([
path.join(desktop, 'node_modules', 'electron', 'dist', 'electron'),
path.join(repo, 'node_modules', 'electron', 'dist', 'electron'),
+11 -12
View File
@@ -17,7 +17,7 @@
* `electron-builder --dir` step and matches `hermes desktop --source`. The
* packaged-binary path is already covered by `launch.spec.ts`.
*
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
* Prerequisite: `pnpm run build` must have been run so that `dist/` exists.
*/
import * as fs from 'node:fs'
@@ -246,14 +246,14 @@ function assertDistBuilt(): void {
if (!fs.existsSync(electronMain)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Desktop dist not built. Run 'cd apps/desktop && pnpm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
if (!fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
`Desktop dist/index.html not found. Run 'cd apps/desktop && pnpm run build' first.\n` +
`Missing: ${indexHtml}`,
)
}
@@ -261,18 +261,17 @@ function assertDistBuilt(): void {
/**
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
* As a fallback, use the node_modules/electron install from either package.
* As a fallback, use the desktop package's node_modules/electron install.
*/
export function findElectron(): string {
// In dev mode, we use the `electron` binary directly (not the packaged app).
// The dev:electron script in package.json does exactly this: `electron .`
// after building. We replicate that here.
//
// The desktop package is searched first: npm workspaces only hoist
// `electron` to the repo root when nothing conflicts, so a workspace-local
// install is just as ordinary an outcome as a hoisted one. The rules live in
// ./electron-binary so they can be unit-tested per platform.
return resolveElectronBinary([DESKTOP_ROOT, REPO_ROOT])
// pnpm links `electron` into the desktop package, the workspace that
// declares it. The rules live in ./electron-binary so they can be
// unit-tested per platform.
return resolveElectronBinary([DESKTOP_ROOT])
}
/**
@@ -489,19 +488,19 @@ export interface PackagedAppFixture {
}
/**
* Launch the *packaged* Electron binary (from `npm run pack` →
* Launch the *packaged* Electron binary (from `pnpm run pack` →
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
* progress without spawning a real Hermes backend.
*
* Uses the same sandbox isolation (credential stripping, isolated
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
*
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
* Skips if the packaged binary doesn't exist — run `pnpm run pack` first.
*/
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
if (!packagedBinaryExists()) {
throw new Error(
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'pnpm run pack' first.`,
)
}
+1 -1
View File
@@ -8,7 +8,7 @@
* therefore performs the same dial → commit → re-home the statusbar switcher
* does, against a real backend — not a stub.
*
* Prerequisite: `npm run build` must have been run so dist/ exists, and the
* Prerequisite: `pnpm run build` must have been run so dist/ exists, and the
* repo's Python venv (`.venv`) must exist for both backends.
*/
+1 -1
View File
@@ -34,7 +34,7 @@
* The test pins that converged single-message shape: every text
* appears exactly once, inside a single assistant message root.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { expect, type Page, test } from '@playwright/test'
+1 -1
View File
@@ -6,7 +6,7 @@
* should detect the unconfigured state and show the DesktopOnboardingOverlay
* with provider options / API key form.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { test } from './test'
@@ -8,7 +8,7 @@
* poll stops once A's pane is hidden. Before the fix the row kept its
* "Background task running" state until the user clicked back into A.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
@@ -9,7 +9,7 @@
* shared word must surface that profile's marker and never the other one's,
* across default → ssprobe-b → default.
*
* Prerequisite: `npm run build` (dist/) and the repo's `.venv`.
* Prerequisite: `pnpm run build` (dist/) and the repo's `.venv`.
*/
import { spawnSync } from 'node:child_process'
+1 -1
View File
@@ -6,7 +6,7 @@
* real (trivial commands + real subagent delegations). The tests assert the
* sidebar states driven by real gateway events.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { expect, test, type Page } from '@playwright/test'
+1 -1
View File
@@ -22,7 +22,7 @@
* 5. Open session A → dot clears.
* 6. Restart once more → the cleared state must persist too (no zombie dot).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { expect, type Page, test } from '@playwright/test'
+1 -1
View File
@@ -27,7 +27,7 @@
* must not rebuild the kept-alive transcript: zero additive bursts and zero
* reconciles.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Prerequisite: `pnpm run build` must have been run so dist/ exists.
*/
import { writeEnvFile, writeMockProviderConfig } from '../../../tests-js/scripts/mock-provider-config'
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for electron/backend-probes.ts.
*
* Run with: node --test electron/backend-probes.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
* (Wired into pnpm run test:desktop:platforms in package.json.)
*/
import assert from 'node:assert/strict'
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests for electron/backend-ready.ts.
*
* Run with: node --test electron/backend-ready.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
* (Wired into pnpm run test:desktop:platforms in package.json.)
*
* Covers the cold-start port-announcement deadline (issue #50209): the clock
* starts before the backend binds its port, so a tight 45s deadline killed a
@@ -2,7 +2,7 @@
* Tests for electron/composer-image-name.ts.
*
* Run with: vitest run --project electron composer-image-name
* (Wired into npm test:desktop:platforms in package.json.)
* (Wired into pnpm run test:desktop:platforms in package.json.)
*
* Regression (issue #96403): pasted composer screenshots were named with a
* UTC timestamp (Date.toISOString()), so the filename disagreed with the
@@ -2,7 +2,7 @@
* Tests for electron/connection-config.ts.
*
* Run with: node --test electron/connection-config.test.ts
* (Wire into npm test:desktop:platforms in package.json.)
* (Wire into pnpm run test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
@@ -2,7 +2,7 @@
* Tests for electron/dashboard-token.ts.
*
* Run with: node --test electron/dashboard-token.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
* (Wired into pnpm run test:desktop:platforms in package.json.)
*/
import assert from 'node:assert/strict'
@@ -10,7 +10,7 @@
*
* ``apps/desktop/package.json`` declared ``electronVersion: 40.9.3`` (the tested,
* JS-extract build) but pinned the dependency loosely as ``electron: ^40.9.3``.
* ``npm ci`` then resolved 40.10.3/40.10.4 — the new *native* extract-zip whose
* npm then resolved 40.10.3/40.10.4 — the new *native* extract-zip whose
* win32 binding imports VCRUNTIME140.dll and fails to ``dlopen`` on hosts without
* the VC++ runtime (``ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node``).
* scripts/build/electron-runtime.mjs now supplies that DLL.
@@ -20,8 +20,8 @@
*
* 1. the Electron dependency is an *exact* version (Electron Builder needs the
* installed binary to match ``electronVersion`` / ``electronDist``), and
* 2. the dependency and the independently resolved lockfile entry agree — so ``npm ci`` installs exactly what
* the build packages.
* 2. the dependency and the independently resolved lockfile entry agree — so a frozen install installs exactly
* what the build packages.
*/
import assert from 'node:assert/strict'
@@ -29,10 +29,11 @@ import fs from 'node:fs'
import path from 'node:path'
import { test } from 'vitest'
import { parse } from 'yaml'
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..')
const DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json')
const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json')
const ROOT_LOCK = path.join(REPO_ROOT, 'pnpm-lock.yaml')
// An exact semver: digits.digits.digits with an optional prerelease/build tag,
// but NO range operators (^ ~ > < = * x || spaces || -range).
@@ -63,33 +64,43 @@ test('electron dependency is exactly pinned', () => {
spec,
EXACT_SEMVER,
`electron must be pinned to an exact version, got "${spec}". ` +
'A range (^/~) lets npm ci resolve a newer Electron whose postinstall ' +
'A range (^/~) lets a lockfile update resolve a newer Electron whose postinstall ' +
'may differ from the one the build was validated against.'
)
})
interface LockDependency {
specifier: string
version: string
}
interface Lockfile {
importers?: Record<string, Partial<Record<'dependencies' | 'devDependencies', Record<string, LockDependency>>>>
packages?: Record<string, unknown>
}
test('lockfile resolves the pinned electron', () => {
if (!fs.existsSync(ROOT_LOCK)) {
return
} // skip if lockfile not present
const spec = electronSpec(desktopPkg())
const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8'))
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>
const lock = parse(fs.readFileSync(ROOT_LOCK, 'utf-8')) as Lockfile
const importer = lock.importers?.['apps/desktop']
const entry = importer?.devDependencies?.electron ?? importer?.dependencies?.electron
const resolved = Object.entries(packages)
.filter(([key]) => key.endsWith('node_modules/electron'))
.map(([, meta]) => meta.version)
.filter((v): v is string => !!v)
assert.ok(entry, 'no apps/desktop electron entry found in pnpm-lock.yaml')
assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json')
// A locked version carries its peer resolution as a suffix: 44.9.3(supports-color@10.2.2).
const resolved = [
entry.version.replace(/\(.*$/, ''),
...Object.keys(lock.packages ?? {})
.filter(key => key.startsWith('electron@'))
.map(key => key.slice('electron@'.length))
]
for (const v of resolved) {
assert.equal(
v,
spec,
`package-lock.json resolves electron to ${v}, but the pin is "${spec}"; ` +
'run `npm install --package-lock-only` so `npm ci` stays consistent.'
`pnpm-lock.yaml resolves electron to ${v}, but the pin is "${spec}"; ` +
'run `pnpm install --lockfile-only` so frozen installs stay consistent.'
)
}
})
@@ -2,7 +2,7 @@
* Tests for electron/desktop-uninstall.ts.
*
* Run with: node --test electron/desktop-uninstall.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
* (Wired into pnpm run test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
* mode → CLI-flag mapping, the running-app-bundle resolution per OS, and the
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Tests for electron/dev-cdp.ts.
*
* Run with: npx vitest run --project electron electron/dev-cdp.test.ts
* Run with: pnpm exec vitest run --project electron electron/dev-cdp.test.ts
*/
import assert from 'node:assert/strict'
@@ -12,7 +12,7 @@ import { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort } from './dev-c
const DEV_SERVER = 'http://127.0.0.1:5174'
/** The ordinary `npm run dev` / `hgui` run. */
/** The ordinary `pnpm run dev` / `hgui` run. */
const devRun = { env: {}, isPackaged: false, devServer: DEV_SERVER }
test('a dev-server run opens the default port with no opt-in', () => {
+1 -1
View File
@@ -59,7 +59,7 @@ function resolveDevCdpPort({ env, isPackaged, devServer }: DevCdpInput): DevCdpD
return { port: null, reason: 'packaged' }
}
// A dev server means a source-tree run (`npm run dev` / `hgui`).
// A dev server means a source-tree run (`pnpm run dev` / `hgui`).
if (!devServer) {
return { port: null, reason: 'no-dev-server' }
}
+1 -1
View File
@@ -75,7 +75,7 @@ const args = wslgLaunchArgs(
)
if (args) {
// Keep the launcher alive until the child exits: npm's concurrently must not
// Keep the launcher alive until the child exits: the dev script's concurrently must not
// tear down Vite during this handoff. No backend, windows or single-instance
// lock are created in this parent. The child has an explicit platform flag,
// so it goes straight into main on its first pass.
@@ -2,7 +2,7 @@
* Tests for electron/gateway-ws-probe.ts.
*
* Run with: node --test electron/gateway-ws-probe.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
* (Wired into pnpm run test:desktop:platforms in package.json.)
*
* The probe drives a real WebSocket handshake for the "Test remote" button.
* Here we inject a fake socket so we can deterministically replay each handshake
+1 -1
View File
@@ -1,6 +1,6 @@
// Type declarations for the get-windows optionalDependency.
//
// get-windows ships no bundled types and is an optionalDependency. `npm ci`
// get-windows ships no bundled types and is an optionalDependency. The install
// can skip it when its native install fails, including Linux and Windows ARM64
// where 9.3.0 has no prebuilt, so it can legitimately be absent from
// node_modules. Declaring the module here keeps typecheck independent of
+1 -1
View File
@@ -1,7 +1,7 @@
// install-stamp.ts — the typed build-time install stamp.
//
// scripts/write-build-stamp.mjs writes build/install-stamp.json during
// `npm run build`.
// `pnpm run build`.
// bundle-electron-main.mjs bakes that file into the
// production bundle by defining the __HERMES_INSTALL_STAMP__ global as
// the stamp. The stamp is a constant of the artifact.
+4 -4
View File
@@ -794,7 +794,7 @@ let f12Blocked = false
// Preload must be plain JS — Electron's sandbox can't run .ts, and tsx's
// ESM loader is broken on Electron 40's Node (ERR_INVALID_RETURN_PROPERTY_VALUE).
// Dev (`npm run dev`) and prod both load the esbuild output from dist/.
// Dev (`pnpm run dev`) and prod both load the esbuild output from dist/.
const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js')
const PREVIEW_GUEST_PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'preview-guest-preload.js')
@@ -867,7 +867,7 @@ if (IS_WINDOWS) {
}
}
// Renderer debugging port. On for dev-server runs (`hgui` / `npm run dev`) so
// Renderer debugging port. On for dev-server runs (`hgui` / `pnpm run dev`) so
// the CDP tooling in scripts/ can attach; never for a packaged build — see
// electron/dev-cdp.ts. Must run before app `ready` like the switches above;
// Chromium binds it at launch.
@@ -4853,7 +4853,7 @@ async function resolveHermesBackend(backendArgs: string[]): Promise<ResolvedHerm
}
}
// 2. Development source -- when running `npm run dev` from a checkout, the
// 2. Development source -- when running `pnpm run dev` from a checkout, the
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
// installed `hermes` on PATH so local Python edits are actually exercised.
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
@@ -17135,7 +17135,7 @@ ipcMain.handle('hermes:version', async (_event, scope?: { connectionId?: string;
// True when the bundle on disk is not the one this process loaded — a
// plain app restart (no rebuild, no installer) clears the skew above.
// Packaged only: a dev `--build-only` rewrites build/install-stamp.json
// under a running `npm start`, which is a rebuild the developer asked for,
// under a running `pnpm start`, which is a rebuild the developer asked for,
// not a torn install to offer a restart for.
bundleSwapPending: IS_PACKAGED && detectBundleSwap(INSTALL_STAMP, readBundleSwapStamp(process.resourcesPath))
}
+1 -1
View File
@@ -3,7 +3,7 @@
// so a non-executable helper fails every terminal spawn with
// `Error: posix_spawnp failed.`. Packaged builds are covered because
// stage-native-deps.mjs chmods the staged copy, but the dev flow
// (`npm run dev` → `electron .`) resolves node-pty straight from
// (`pnpm run dev` → `electron .`) resolves node-pty straight from
// `node_modules/`, which nobody chmods. This restores the exec bits at runtime,
// best-effort, so both dev and any environment that stripped the bit keep
// working. Idempotent: files that are already executable are left untouched.
+5 -3
View File
@@ -171,10 +171,12 @@ export function registerTerminalIpc({
function terminalShellEnv() {
const env = { ...process.env }
// Electron is commonly launched through `npm run dev`; do not leak npm's
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
// Electron is commonly launched through `pnpm run dev`; do not leak the
// package manager's settings into a user's interactive shell (nvm/proto warn
// loudly, and the user's own pnpm would inherit them). Package managers inject
// lowercase names: a user's own NPM_CONFIG_REGISTRY stays.
for (const key of Object.keys(env)) {
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
if (key.startsWith('npm_config_') || key.startsWith('npm_package_') || key.startsWith('pnpm_config_')) {
delete env[key]
}
}
+1 -1
View File
@@ -175,7 +175,7 @@ export const resolveOutsideAsar = (specifier: string): string =>
let getWindowsModule: Promise<GetWindowsModule | EnumerationFailure> | null = null
const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
// get-windows is an optionalDependency: `npm ci` can skip it when its native
// get-windows is an optionalDependency: the install can skip it when its native
// install fails, including Linux and Windows ARM64 where 9.3.0 has no
// prebuilt. A missing module is therefore a normal state on those targets,
// so the lazy import resolves to null instead of rejecting; enumeration then
+24 -24
View File
@@ -15,34 +15,34 @@
"node": ">=26"
},
"scripts": {
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
"clean": "pnpm run clean:e2e && pnpm run clean:renderer && pnpm run clean:electron",
"clean:e2e": "tsc --build tsconfig.e2e.json --clean",
"clean:renderer": "tsc --build tsconfig.json --clean ",
"clean:electron": "tsc --build tsconfig.electron.json --clean",
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev": "concurrently -k \"pnpm:dev:renderer\" \"pnpm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 pnpm run dev",
"dev:mock": "node ../../tests-js/scripts/mock-server.ts",
"dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174",
"dev:renderer": "node scripts/assert-root-install.mjs && pnpm run clean:renderer && vite --host 127.0.0.1 --port 5174",
"dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"start": "pnpm run build && electron .",
"build:webapp": "node scripts/build-webapp.mjs",
"build": "node scripts/build.mjs",
"i18n:keys": "node scripts/i18n-keys.mjs",
"smoke:browser-host": "node scripts/smoke-browser-host.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
"builder": "node scripts/run-electron-builder.mjs",
"pack": "npm run build && npm run builder -- --dir --publish never",
"dist": "npm run build && npm run builder",
"pack": "pnpm run build && pnpm run builder --dir --publish never",
"dist": "pnpm run build && pnpm run builder",
"payload": "node ../../scripts/build/python.mjs ../../scripts/bundles/stage.py --out build/agent-payload",
"dist:bundled": "npm run payload && cross-env HERMES_DESKTOP_VARIANT=bundled npm run dist",
"dist:mac": "npm run build && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run builder -- --win msix",
"dist:win:msix": "npm run build && npm run builder -- --win msix",
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
"dist:bundled": "pnpm run payload && cross-env HERMES_DESKTOP_VARIANT=bundled pnpm run dist",
"dist:mac": "pnpm run build && pnpm run builder --mac",
"dist:mac:dmg": "pnpm run build && pnpm run builder --mac dmg",
"dist:mac:zip": "pnpm run build && pnpm run builder --mac zip",
"dist:win": "pnpm run build && pnpm run builder --win msix",
"dist:win:msix": "pnpm run build && pnpm run builder --win msix",
"dist:linux": "pnpm run build && pnpm run builder --linux AppImage deb rpm",
"perf": "node scripts/perf/run.mjs",
"update:shim": "bash ../../scripts/desktop-update/repro.sh shim",
"update:shim:fail": "bash ../../scripts/desktop-update/repro.sh shim-fail",
@@ -63,21 +63,21 @@
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt",
"fix": "pnpm run lint:fix && pnpm run fmt",
"test:ui": "vitest run --project ui",
"test:desktop:platforms": "vitest run --project electron",
"test:find-in-page-native": "electron electron/find-in-page-native-fixture",
"test:preview-activity-native": "electron electron/preview-activity-native-fixture",
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check:test:ui": "npm run test:ui",
"check:test:desktop:platforms": "npm run test:desktop:platforms",
"check:test:desktop:all": "npm run test:desktop:all",
"check:lint": "npm run typecheck && npm run lint",
"check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all",
"test:e2e": "npm run build && playwright test e2e/",
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots",
"check:test:ui": "pnpm run test:ui",
"check:test:desktop:platforms": "pnpm run test:desktop:platforms",
"check:test:desktop:all": "pnpm run test:desktop:all",
"check:lint": "pnpm run typecheck && pnpm run lint",
"check": "pnpm run check:lint && pnpm run test:ui && pnpm run test:desktop:platforms && pnpm run test:desktop:all",
"test:e2e": "pnpm run build && playwright test e2e/",
"test:e2e:visual": "pnpm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- pnpm exec playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "pnpm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- pnpm exec playwright test e2e/ --reporter=list --update-snapshots",
"repro:short-session-hang": "node scripts/run-short-session-hang-repro.mjs"
},
"dependencies": {
@@ -94,7 +94,7 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@hermes/shared": "file:../shared",
"@hermes/shared": "workspace:*",
"@icons-pack/react-simple-icons": "13.15.1",
"@lezer/highlight": "1.2.5",
"@nanostores/react": "1.1.0",
+1 -1
View File
@@ -14,7 +14,7 @@ import { defineConfig, type ReporterDescription } from '@playwright/test'
* artifacts for human review.
*
* To update baselines after an intentional UI change:
* npx playwright test --update-snapshots
* pnpm exec playwright test --update-snapshots
*/
const reporters: ReporterDescription[] = [
['list'],
+5 -5
View File
@@ -1,14 +1,14 @@
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
// `pnpm run pack` / `pnpm run dist*` are `pnpm run build && pnpm run builder`.
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
// or npm not short-circuiting `&&` in some shells — electron-builder happily
// or a shell not short-circuiting `&&` — electron-builder happily
// packages an app with an empty or missing `dist/`. The result launches but
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
//
// The desktop compiler (scripts/build/desktop.mjs) runs it on the scratch
// product before publishing dist/, so every `npm run build` path inherits it
// product before publishing dist/, so every `pnpm run build` path inherits it
// once. It is deliberately not also a `postbuild` hook: that re-ran the same
// check on the bytes just verified, doubling its cost in every update.
// It fails loud and early instead of shipping a broken bundle.
@@ -174,14 +174,14 @@ function main() {
console.error(" completion. On Windows that is usually a stalled process creation")
console.error(" (antivirus/EDR interference, memory pressure) and clears on a retry.")
console.error(" If it repeats, check the node binary and any AV exclusion for the")
console.error(` build tree, then re-run: cd ${desktopRoot} && npm run build\n`)
console.error(` build tree, then re-run: cd ${desktopRoot} && pnpm run build\n`)
process.exit(1)
}
console.error(" The renderer bundle is missing or incomplete, so packaging")
console.error(" would produce an app that launches to a blank page.")
console.error(" Re-run the build and check the tsc/vite output above for the")
console.error(" real failure, then package again:")
console.error(` cd ${desktopRoot} && npm run build\n`)
console.error(` cd ${desktopRoot} && pnpm run build\n`)
process.exit(1)
}
+12 -12
View File
@@ -1,12 +1,12 @@
// Build-time guard: refuse to start a build the installed tree cannot finish.
//
// The desktop workspace's dependencies are hoisted to the repo-root
// `node_modules`, so a root install that only covers *part* of the workspace
// The desktop workspace's dependencies are linked into its own `node_modules`
// by the root install, so an install that only covers *part* of the workspace
// graph leaves this app importable-looking but unbuildable. The guard exists to
// turn that into one actionable line ("run npm ci from the repo root") instead
// of a failure deep inside vite.
// turn that into one actionable line ("run pnpm install --frozen-lockfile from
// the repo root") instead of a failure deep inside vite.
//
// It runs from `prebuild`, ahead of `npm run clean`, so a tree that cannot
// It runs from `prebuild`, ahead of `pnpm run clean`, so a tree that cannot
// build is rejected before the build starts deleting its own outputs. `build`
// re-runs it for anyone invoking the build steps directly; the check is pure
// filesystem lookups, so paying for it twice costs nothing.
@@ -25,7 +25,7 @@ import { isMain } from "./utils.mjs"
// the CSS transform fails before a single chunk is emitted.
// electron — the runtime electron-builder packages; without it `pack`
// cannot produce an unpacked app at all.
// electron-builder — the packager `npm run builder` shells out to.
// electron-builder — the packager `pnpm run builder` shells out to.
//
// Checking only `vite` (the original guard) passes a tree missing any of the
// others, which is how an incomplete install reached `vite build` and died on
@@ -41,7 +41,7 @@ import { isMain } from "./utils.mjs"
// lands, so `checkRootInstall` unions the floor with the manifest's declared
// `dependencies` + `devDependencies` — a partial install is refused whichever
// package it happened to drop. `optionalDependencies` are excluded by design:
// npm legitimately skips them (platform-gated natives like `get-windows`).
// pnpm legitimately skips them (platform-gated natives like `get-windows`).
const BUILD_CRITICAL_PACKAGES = ["vite", "katex", "electron", "electron-builder"]
export { BUILD_CRITICAL_PACKAGES }
@@ -88,15 +88,15 @@ export function checkRootInstall(appDir, rootDir) {
`the desktop build needs ${missing.join(", ")}, which the current install ` +
`does not provide. A partial root install leaves the workspace looking ` +
`present while the build cannot complete. Reinstall from the repo root: ` +
`cd ${rootDir} && npm ci`
`cd ${rootDir} && pnpm install --frozen-lockfile`
}
}
// `vite.config.ts` aliases react/react-dom to whatever this workspace resolves,
// and React refuses to run when the two come from different installed copies
// ("Minified React error #527" — it throws before the first paint, so the app
// window stays blank). npm stays silent about the split because the hoisted
// react still satisfies react-dom's caret peer range. Fail the build loudly
// window stays blank). npm stayed silent about the split because the hoisted
// react still satisfied react-dom's caret peer range. Fail the build loudly
// instead of shipping a white screen.
const requireFromApp = createRequire(join(appDir, "package.json"))
const installedVersion = pkg =>
@@ -113,7 +113,7 @@ export function checkRootInstall(appDir, rootDir) {
// rather than an absent one. Report it as such instead of throwing.
return {
ok: false,
error: `could not read the installed react/react-dom versions (${err.message}). Reinstall from the repo root: cd ${rootDir} && npm ci`
error: `could not read the installed react/react-dom versions (${err.message}). Reinstall from the repo root: cd ${rootDir} && pnpm install --frozen-lockfile`
}
}
@@ -123,7 +123,7 @@ export function checkRootInstall(appDir, rootDir) {
error:
`react@${react} / react-dom@${reactDom} version mismatch — React would fail ` +
`with error #527 and render a blank window. Pin both to the same version ` +
`in ${join(appDir, "package.json")}, then reinstall: cd ${rootDir} && npm ci`
`in ${join(appDir, "package.json")}, then reinstall: cd ${rootDir} && pnpm install --frozen-lockfile`
}
}
@@ -7,21 +7,21 @@ import { test } from 'vitest'
import { BUILD_CRITICAL_PACKAGES as BUILD_CRITICAL, checkRootInstall, requiredPackages } from '../scripts/assert-root-install.mjs'
// Build a throwaway repo shaped like this one: an app workspace whose
// dependencies are hoisted to the repo root, which is what the guard walks.
// `manifest` is merged into the app's package.json so tests can declare
// dependencies pnpm links into its own node_modules, which is what the guard
// walks. `manifest` is merged into the app's package.json so tests can declare
// dependencies the guard is expected to read.
function makeTree({ rootPackages = BUILD_CRITICAL, react = '19.2.7', reactDom = '19.2.7', manifest = {} } = {}) {
function makeTree({ appPackages = BUILD_CRITICAL, react = '19.2.7', reactDom = '19.2.7', manifest = {} } = {}) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-root-'))
const appDir = path.join(tempRoot, 'apps', 'desktop')
fs.mkdirSync(appDir, { recursive: true })
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'desktop', ...manifest }), 'utf8')
const writePackage = (name, version) => {
const dir = path.join(tempRoot, 'node_modules', name)
const dir = path.join(appDir, 'node_modules', name)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version }), 'utf8')
}
for (const name of rootPackages) writePackage(name, '1.0.0')
for (const name of appPackages) writePackage(name, '1.0.0')
if (react !== null) writePackage('react', react)
if (reactDom !== null) writePackage('react-dom', reactDom)
@@ -42,20 +42,20 @@ test('checkRootInstall passes on a complete root install', () => {
// the build died on an unresolved `katex/dist/katex.min.css` (#86443).
test('checkRootInstall fails when katex is missing but vite is present', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
appPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
})
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /katex/)
assert.match(result.error, /npm ci/)
assert.match(result.error, /pnpm install --frozen-lockfile/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkRootInstall reports every missing package at once', () => {
const { tempRoot, appDir } = makeTree({ rootPackages: ['vite'] })
const { tempRoot, appDir } = makeTree({ appPackages: ['vite'] })
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
@@ -71,7 +71,7 @@ test('checkRootInstall reports every missing package at once', () => {
// drop the case it already handled.
test('checkRootInstall still fails when vite is missing', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'vite')
appPackages: BUILD_CRITICAL.filter(name => name !== 'vite')
})
try {
const result = checkRootInstall(appDir, tempRoot)
@@ -93,16 +93,16 @@ test('checkRootInstall fails on a react/react-dom version split', () => {
}
})
// A package installed into the app's own node_modules rather than hoisted to the
// root is still installed. The guard walks upward like Node does, so it must not
// insist on the hoisted location.
test('checkRootInstall accepts a package nested in the app workspace', () => {
// A package the repo root installs is still installed for the app: Node's lookup
// walks upward, and so does the guard, so it must not insist on the app's own
// node_modules.
test('checkRootInstall accepts a package installed at the repo root', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
appPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
})
const nested = path.join(appDir, 'node_modules', 'katex')
fs.mkdirSync(nested, { recursive: true })
fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ name: 'katex' }), 'utf8')
const hoisted = path.join(tempRoot, 'node_modules', 'katex')
fs.mkdirSync(hoisted, { recursive: true })
fs.writeFileSync(path.join(hoisted, 'package.json'), JSON.stringify({ name: 'katex' }), 'utf8')
try {
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
} finally {
@@ -118,7 +118,7 @@ test('checkRootInstall accepts a package nested in the app workspace', () => {
test('checkRootInstall fails when a declared devDependency outside the floor is missing', () => {
const { tempRoot, appDir } = makeTree({
manifest: { devDependencies: { '@rolldown/plugin-babel': '1.0.0', esbuild: '1.0.0' } },
rootPackages: [...BUILD_CRITICAL, 'esbuild']
appPackages: [...BUILD_CRITICAL, 'esbuild']
})
try {
const result = checkRootInstall(appDir, tempRoot)
@@ -143,7 +143,7 @@ test('checkRootInstall fails when a declared runtime dependency is missing', ()
}
})
// npm skips optionalDependencies legitimately (platform-gated natives), so an
// pnpm skips optionalDependencies legitimately (platform-gated natives), so an
// absent optional package is not a partial install.
test('checkRootInstall ignores missing optionalDependencies', () => {
const { tempRoot, appDir } = makeTree({
@@ -159,7 +159,7 @@ test('checkRootInstall ignores missing optionalDependencies', () => {
test('checkRootInstall passes when every declared package is installed', () => {
const { tempRoot, appDir } = makeTree({
manifest: { dependencies: { '@scope/pkg': '1.0.0' }, devDependencies: { esbuild: '1.0.0' } },
rootPackages: [...BUILD_CRITICAL, '@scope/pkg', 'esbuild']
appPackages: [...BUILD_CRITICAL, '@scope/pkg', 'esbuild']
})
try {
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
@@ -171,7 +171,7 @@ test('checkRootInstall passes when every declared package is installed', () => {
// The floor is unconditional: a manifest the guard cannot parse must not turn
// the check off.
test('checkRootInstall keeps the floor when the manifest is unreadable', () => {
const { tempRoot, appDir } = makeTree({ rootPackages: ['vite'] })
const { tempRoot, appDir } = makeTree({ appPackages: ['vite'] })
fs.writeFileSync(path.join(appDir, 'package.json'), '{not json', 'utf8')
try {
assert.deepEqual(requiredPackages(appDir), [])
+1 -1
View File
@@ -10,7 +10,7 @@
* ---------------
* electron-builder's final packaging step copies the stock `electron`
* binary into `release/<platform>-unpacked/` and then renames it to the
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
* product name (`Hermes`). If a PREVIOUS `pnpm run pack` was interrupted
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
+9 -17
View File
@@ -1,11 +1,11 @@
// npm's source-development composition for the browser-hosted renderer.
// The source-development composition for the browser-hosted renderer.
//
// Dependencies are installed into a private workspace, never the checkout:
// npm ci would prune the native Desktop install (node-pty, Electron), and
// headless hosts have no toolchain for its lifecycle scripts. The workspace
// mirrors exactly the inputs the Webapp receipt covers and keeps its
// node_modules between builds, so npm ci reruns only when the lock changes.
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
// a frozen install there would relink the native Desktop install (node-pty,
// Electron), and headless hosts have no toolchain for its lifecycle scripts.
// The workspace mirrors exactly the inputs the Webapp receipt covers and keeps
// its node_modules between builds, so pnpm reinstalls only when the lock changes.
import { copyFileSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { isMain, repoRoot } from '../../../scripts/build/frontend-common.mjs'
@@ -13,7 +13,8 @@ import { sourceEntries } from '../../../scripts/build/freshness.mjs'
import { prepareNodeDependencies } from '../../../scripts/build/node-deps.mjs'
import { buildWebapp } from '../../../scripts/build/webapp.mjs'
/** Make the workspace hash exactly like the checkout for the Webapp product. */
/** Make the workspace hash exactly like the checkout for the Webapp product. The filtered
* frozen install needs no other workspace's manifest, so none is copied. */
export function mirrorWebappInputs(source, workspace) {
const wanted = new Map(sourceEntries(source, 'webapp').map(({ name, kind }) => [name, kind]))
// Children before parents. Generated trees (node_modules) are never listed, so never removed.
@@ -26,15 +27,6 @@ export function mirrorWebappInputs(source, workspace) {
mkdirSync(kind === 'directory' ? target : dirname(target), { recursive: true })
if (kind === 'file') copyFileSync(join(source, name), target)
}
// npm ci validates the whole locked workspace graph, so every workspace
// manifest must exist even though only Desktop and shared are compiled.
const lock = JSON.parse(readFileSync(join(source, 'package-lock.json'), 'utf8'))
for (const entry of Object.values(lock.packages || {})) {
const manifest = entry.link && join(entry.resolved, 'package.json')
if (!manifest || !existsSync(join(source, manifest))) continue
mkdirSync(dirname(join(workspace, manifest)), { recursive: true })
copyFileSync(join(source, manifest), join(workspace, manifest))
}
}
/** Mirror the inputs into the private workspace and install its dependencies, without compiling.
@@ -48,7 +40,7 @@ export function prepareWebappWorkspace({
mirrorWebappInputs(source, workspace)
prepare({
source: workspace, workspaces: ['apps/desktop'], reuse: true, install,
env: { ...process.env, npm_config_ignore_scripts: 'true' },
env: { ...process.env, pnpm_config_ignore_scripts: 'true' },
// The browser bundle never runs Electron, so its binary is not downloaded.
electron: false,
})
+1 -1
View File
@@ -1,4 +1,4 @@
// npm's source-development composition; the compiler consumes prepared inputs.
// The source-development composition (`pnpm run build`); the compiler consumes prepared inputs.
import { execFileSync } from 'node:child_process'
import { cpSync } from 'node:fs'
import { join, resolve } from 'node:path'
+3 -3
View File
@@ -8,8 +8,8 @@ import { withCompilerCache } from './compiler-cache.mjs'
function setup() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'compiler-cache-'))
const lock = path.join(root, 'package-lock.json')
fs.writeFileSync(lock, '{"v":1}')
const lock = path.join(root, 'pnpm-lock.yaml')
fs.writeFileSync(lock, "lockfileVersion: '9.0'")
const calls = []
const plugin = () => ({
transform: {
@@ -54,7 +54,7 @@ test('a toolchain or NODE_ENV change starts a new generation; a corrupt entry is
await t.cached().transform.handler('<App/>', id)
await t.cached({ env: { NODE_ENV: 'development' } }).transform.handler('<App/>', id)
assert.equal(t.calls.length, 2)
fs.writeFileSync(t.lock, '{"v":2}')
fs.writeFileSync(t.lock, "lockfileVersion: '6.0'")
await t.cached().transform.handler('<App/>', id)
assert.equal(t.calls.length, 3)
for (const name of t.entries()) fs.writeFileSync(path.join(t.root, 'cache', String(name)), '{torn')
+2 -2
View File
@@ -3,7 +3,7 @@
// node scripts/eval.mjs "document.title"
// HERMES_DESKTOP_CDP_PORT=9333 node scripts/eval.mjs "document.title"
//
// Needs a renderer with a debugging port: launch `hgui` / `npm run dev` with
// Needs a renderer with a debugging port: launch `hgui` / `pnpm run dev` with
// HERMES_DESKTOP_CDP_PORT set (see electron/dev-cdp.ts).
const port = Number(process.env.HERMES_DESKTOP_CDP_PORT || 9222)
let targets
@@ -13,7 +13,7 @@ try {
} catch {
console.error(
`no renderer debugging port on 127.0.0.1:${port}. ` +
'Dev-server runs (`npm run dev` / `hgui`) open one automatically — check the app is running, ' +
'Dev-server runs (`pnpm run dev` / `hgui`) open one automatically — check the app is running, ' +
'and that HERMES_DESKTOP_CDP_PORT is not set to "off" or another port.'
)
process.exit(1)
+1 -1
View File
@@ -1,5 +1,5 @@
// Throwaway generator: deterministic fake star-map graphs → real share codes
// (runs the actual encoder, so every string round-trips). Run with `npx tsx`.
// (runs the actual encoder, so every string round-trips). Run with `pnpm exec tsx`.
import { writeFileSync } from 'node:fs'
import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes'
+2 -2
View File
@@ -5,7 +5,7 @@
// `locales/_keys.desktop.json` (repo root, committed). This script derives that
// file from `src/i18n/en.ts` — every leaf's dotted path, sorted; function-
// valued entries are keys too (a pack overrides them with a positional
// `{0}`/`{1}` string). Run via `npm run i18n:keys` and commit the result (`--check` verifies);
// `{0}`/`{1}` string). Run via `pnpm run i18n:keys` and commit the result (`--check` verifies);
// the build never writes it — a dirty checkout would break `hermes update`.
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
@@ -84,7 +84,7 @@ export async function emitDesktopKeys({ source = repoRoot, out, check = false }
// A missing file is drift too.
}
if (current !== rendered) {
throw new Error(`${out} is out of date with src/i18n/en.ts — run \`npm run i18n:keys\` in apps/desktop and commit it`)
throw new Error(`${out} is out of date with src/i18n/en.ts — run \`pnpm run i18n:keys\` in apps/desktop and commit it`)
}
return out
}
+1 -1
View File
@@ -15,7 +15,7 @@ test('flattenKeys lists every leaf as a sorted dotted path, functions included,
assert.equal(renderKeysFile(['a']), `{\n "surface": "${SURFACE}",\n "keys": [\n "a"\n ]\n}\n`)
})
test('the committed locales/_keys.desktop.json matches the English catalog (run `npm run i18n:keys`)', async () => {
test('the committed locales/_keys.desktop.json matches the English catalog (run `pnpm run i18n:keys`)', async () => {
const out = join(repoRoot, 'locales', `_keys.${SURFACE}.json`)
const committed = JSON.parse(readFileSync(out, 'utf8'))
assert.equal(committed.surface, SURFACE)
+6 -6
View File
@@ -10,20 +10,20 @@ client, arg parsing, stats, and output (and never had a baseline).
```bash
# Isolated instance (recommended) — no running app or LLM credits needed.
# Its own --user-data-dir + HERMES_HOME means it never collides with `hgui`.
npm run perf -- --spawn
pnpm run perf --spawn
# Or: launch an isolated instance once, attach repeatedly (faster iteration).
npm run perf:serve # leaves an instance on :9222
npm run perf # attaches, runs the CI suite, gates on baseline
pnpm run perf:serve # leaves an instance on :9222
pnpm run perf # attaches, runs the CI suite, gates on baseline
# One scenario, with a CPU profile:
npm run perf -- stream --cpuprofile --tokens 800
pnpm run perf stream --cpuprofile --tokens 800
# Representative PRODUCTION numbers (minified React, not the ~3x-slower dev build):
npm run perf -- cold-start stream keystroke transcript --spawn --prod
pnpm run perf cold-start stream keystroke transcript --spawn --prod
# Re-capture the baseline on your reference device, then commit baseline.json:
npm run perf -- cold-start stream keystroke transcript --spawn --prod --update-baseline
pnpm run perf cold-start stream keystroke transcript --spawn --prod --update-baseline
```
## Profiling an existing workspace
+1 -1
View File
@@ -215,7 +215,7 @@ export async function requireDriver(cdp) {
if (!ok) {
throw new Error(
'__PERF_DRIVE__ not on window. The perf harness needs a DEV renderer ' +
'(perf-probe.tsx is excluded from production builds). Launch with `npm run perf:serve`.'
'(perf-probe.tsx is excluded from production builds). Launch with `pnpm run perf:serve`.'
)
}
}
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff. Show more