From 49dd307dc6648510b17fd2a630853c8a1f491dda Mon Sep 17 00:00:00 2001 From: HeftyKoo Date: Tue, 13 Oct 2026 18:29:20 +0800 Subject: [PATCH] feat(terminal): add safe updates and release host 0.4.1 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 10 ++ README.md | 2 + README.zh-CN.md | 2 + apps/terminal-surface/src/index.html | 1 + apps/terminal-surface/src/main.ts | 15 +++ apps/terminal-surface/src/style.css | 2 +- docs/one-command-setup.md | 15 ++- docs/releasing.md | 18 ++-- docs/store-site/privacy.md | 6 +- docs/store-site/support.md | 4 +- packages/terminal-host/README.md | 20 +++- packages/terminal-host/package.json | 4 +- packages/terminal-host/src/cli.ts | 74 +++++++++++++- .../src/host.integration.test.ts | 50 +++++++++- packages/terminal-host/src/management.test.ts | 22 +++++ packages/terminal-host/src/management.ts | 56 +++++++++++ packages/terminal-host/src/service.test.ts | 45 ++++++++- packages/terminal-host/src/service.ts | 54 +++++++++- packages/terminal-host/src/updates.test.ts | 72 ++++++++++++++ packages/terminal-host/src/updates.ts | 98 +++++++++++++++++++ packages/terminal-host/src/upgrade.test.ts | 63 ++++++++++++ packages/terminal-host/src/upgrade.ts | 69 +++++++++++++ packages/terminal-host/src/version.ts | 9 ++ pnpm-lock.yaml | 18 ++++ release-contract.json | 4 +- scripts/install.sh | 2 +- scripts/install.test.mjs | 2 +- 28 files changed, 709 insertions(+), 30 deletions(-) create mode 100644 packages/terminal-host/src/management.test.ts create mode 100644 packages/terminal-host/src/management.ts create mode 100644 packages/terminal-host/src/updates.test.ts create mode 100644 packages/terminal-host/src/updates.ts create mode 100644 packages/terminal-host/src/upgrade.test.ts create mode 100644 packages/terminal-host/src/upgrade.ts create mode 100644 packages/terminal-host/src/version.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22d3a69..f92836a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: description: Existing release tag to publish or retry required: true type: choice - options: [dsh-v0.4.0, terminal-v0.4.0, ext-v0.4.0, dsh-v0.3.0, terminal-v0.3.0, ext-v0.3.0, dsh-v0.2.0, terminal-v0.2.0, terminal-v0.2.1, ext-v0.2.0, ext-v0.2.1] + options: [terminal-v0.4.1, dsh-v0.4.0, terminal-v0.4.0, ext-v0.4.0, dsh-v0.3.0, terminal-v0.3.0, ext-v0.3.0, dsh-v0.2.0, terminal-v0.2.0, terminal-v0.2.1, ext-v0.2.0, ext-v0.2.1] push: tags: ["ext-v*", "dsh-v*", "terminal-v*"] diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e3d8e..56c9364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Terminal host 0.4.1 — 2026-10-13 + +- Add terminal-host version reporting, daily asynchronous npm update checks with caching/backoff, persistent opt-out, and reminders in the private management page and service log. +- Add `aow update` for explicit global npm upgrades in the original prefix, retaining CLI integrations and enrolled browser origins. Add `aow service restart` and running-version verification. Both protect live shells unless `--force` is explicit and pause new shells during maintenance. +- Keep automatic checks separate from installation; version-pinned release installers and the extension/DSH update paths remain independent. + +### Upgrade + +For the first upgrade from 0.4.0 or earlier, finish terminal work, run `aow service uninstall`, and rerun the installer. This installs 0.4.1 and starts the update-capable host. Subsequent upgrades use `aow update` in a separate macOS Terminal after exiting or closing live shells. `aow service restart` reloads already-installed code; explicit `--force` ends live tasks. Chrome/Firefox/Safari extensions and the DSH plugin remain at 0.4.0 with connector protocol 1. + ## 0.4.0 — 2026-10-13 - Add Pi session status, approval and completion notifications alongside Codex and Claude Code. diff --git a/README.md b/README.md index 68564b7..9f873fd 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ For Pi 1.1+, run `pi` in the same terminal. Setup adds `extensions/agentonweb.ts Use **+** in the terminal tab bar to open another project, click a tab to switch, double-click its name to rename it, or click **×** to close it. Hiding the workspace or refreshing the page keeps your sessions running. +Terminal host 0.4.1 checks for stable npm updates daily and shows reminders in its private management page. Use `aow check-update` to check now or `aow check-update --disable` to turn automatic checks off. After closing live terminals, run `aow update` in a separate macOS Terminal; `aow service restart` reloads already-installed code. For the first upgrade from 0.4.0 or earlier, finish terminal work, run `aow service uninstall`, then rerun the installer. + [Setup and troubleshooting](docs/one-command-setup.md) · [Terminal guide](packages/terminal-host/README.md) ## Use DeepSeek Harness diff --git a/README.zh-CN.md b/README.zh-CN.md index 2d3dd46..7079c40 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -71,6 +71,8 @@ codex 点击终端标签栏的 **+** 打开另一个项目,点击标签切换,双击名称重命名,点击 **×** 关闭。隐藏工作区或刷新网页不会中断正在运行的会话。 +终端主机 0.4.1 每天检查 npm 正式版,并在本地管理页显示更新提醒。运行 `aow check-update` 可立即检查,`aow check-update --disable` 可关闭自动检查。退出或关闭所有终端后,在 Mac 的独立“终端”窗口运行 `aow update` 升级;`aow service restart` 用于加载已安装的新代码。从 0.4.0 或更早版本首次升级时,请先完成终端任务,运行 `aow service uninstall`,再重新运行安装命令。 + [安装与常见问题](docs/one-command-setup.md) · [终端指南](packages/terminal-host/README.md) ## 使用 DeepSeek Harness diff --git a/apps/terminal-surface/src/index.html b/apps/terminal-surface/src/index.html index e042ccd..7c675aa 100644 --- a/apps/terminal-surface/src/index.html +++ b/apps/terminal-surface/src/index.html @@ -25,6 +25,7 @@

+
('#updates'); + const message = document.querySelector('#update-status'); + if (!panel || !message) return; + panel.hidden = false; + message.textContent = data.installedVersion !== data.runningVersion + ? `Installed ${data.installedVersion} · Running ${data.runningVersion}. Close terminals, then run aow service restart in a separate macOS Terminal.` + : data.enabled && data.updateAvailable + ? `AgentOnWeb ${data.latest} is available · Running ${data.runningVersion}. Close terminals, then run aow update in a separate macOS Terminal.` + : `AgentOnWeb ${data.runningVersion} · Automatic update checks ${data.enabled ? 'enabled' : 'disabled'}.${data.error ? ' Last check failed; try aow check-update later.' : ''}`; + } catch { /* Only the private management page has access to version status. */ } +} if (parent === window) { void showConnections(); setInterval(() => { diff --git a/apps/terminal-surface/src/style.css b/apps/terminal-surface/src/style.css index 7ddc677..d8cf874 100644 --- a/apps/terminal-surface/src/style.css +++ b/apps/terminal-surface/src/style.css @@ -85,7 +85,7 @@ main { height: 100%; } -#connections { +#connections, #updates { background: #202630; padding: 12px 16px; border-bottom: 1px solid #455164; diff --git a/docs/one-command-setup.md b/docs/one-command-setup.md index 9d65f56..c657314 100644 --- a/docs/one-command-setup.md +++ b/docs/one-command-setup.md @@ -77,14 +77,21 @@ Run `aow pi-extension install`, then start Pi 1.1+ in an AgentOnWeb terminal or ## Update or repair -Rerun the installation command to install the current AgentOnWeb terminal package and repair its configuration. Installation leaves active terminal sessions running. To load an updated service, finish your work, then run: +The host automatically checks the stable npm version at most once a day and displays updates in its private management page and service log. Checks do not install code or restart your terminals. Run these commands in a separate macOS Terminal: ```sh -aow service uninstall -aow setup +aow --version +aow service status +aow check-update +aow update +aow service restart ``` -Stopping the service ends its live shells. Your saved CLI history and browser connections remain. If you move or remove the Node.js installation used by AgentOnWeb, rerun the installer under the Node version you intend to keep, then restart the service as above. +`aow update` retains the existing global npm prefix, installs the checked stable version, and restarts a running macOS service. Exit or close every terminal first; an idle live shell also blocks maintenance. `aow service restart` reloads already-installed code without reinstalling the package. Both commands reject execution inside an AgentOnWeb terminal; use your Mac's Terminal app. `--force` explicitly permits ending all live shells and their tasks. Restart confirms the new process and version before reporting success. Saved CLI history, browser grants, selected integrations and enrolled extension IDs remain. + +Use `aow check-update --disable` to persistently turn automatic checks off, and `--enable` to restore them. Setting `AOW_UPDATE_CHECK=0` in the host environment also disables them; CI skips automatic checks. Explicit checks and upgrades still work. A network failure leaves terminals running, uses cached results and retries with increasing delays. Automatic requests query only npm package metadata, with a three-second timeout. See the [privacy policy](store-site/privacy.md). + +These commands require a host version that includes update support. For an older installed host, finish terminal work, run `aow service uninstall`, then rerun the installation command once. The installer remains version-pinned and repairs configuration. Development/npx/other package-manager installations require their original upgrade workflow. If you move or remove Node, rerun the installer under the Node version you intend to keep. npm failure does not trigger a service restart; if the package installed but the service is not ready, inspect `~/.agentonweb/terminal/service.log` and run `aow setup`. ## Uninstall diff --git a/docs/releasing.md b/docs/releasing.md index 1fa1617..f90c2ad 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,16 +1,16 @@ # Release AgentOnWeb -## 0.4.0 scope +## Terminal host 0.4.1 scope -This release adds Pi session status alongside Codex and Claude Code, detects installed CLIs during setup and prompts for integrations, recovers disconnected runtime sessions, and preserves independent browser-tab state. Terminal mode shortcuts work from native terminal input: Focus moves input to the terminal, Chill preserves focus, and Watch restores website input. See [CHANGELOG](../CHANGELOG.md). +Terminal host 0.4.1 adds asynchronous daily npm version checks, private management-page reminders, opt-out, explicit `aow update`, and guarded `aow service restart` with running-version verification. This is a terminal-host-only publication; publish `terminal-v0.4.1`. See [CHANGELOG](../CHANGELOG.md). -The extension, DSH plugin and terminal host use **0.4.0**. Safari targets use marketing version **0.4.0**, build **9**. `release-contract.json` records the artifact versions and filenames. Connector protocol remains **1**, the matching extension and terminal host are both required for the terminal shortcut and focus changes. The DSH compatibility baseline remains **0.1.7-rc.2**; the `dsh-latest` CI job checks the currently published upstream version. +The terminal host uses **0.4.1**; the extension and DSH plugin remain **0.4.0**. Safari targets remain marketing version **0.4.0**, build **9**. `release-contract.json` records each artifact's independent version and filename. Connector protocol remains **1**. Terminal host 0.4.1 works with the 0.4.0 extension; no browser-store submission or DSH publication is needed for this update. The DSH compatibility baseline remains **0.1.7-rc.2**; the `dsh-latest` CI job checks the currently published upstream version. | Artifact | Version field | Delivery | | --- | --- | --- | | Chrome/Firefox extension ZIPs and source | `extensionVersion` | `ext-v0.4.0`, then store submission | | DSH plugin TGZ | `pluginVersion` | `dsh-v0.4.0`, npm + GitHub release | -| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.0`, npm + GitHub release | +| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.1`, npm + GitHub release | A merged branch or GitHub release does not mean a browser store has approved the update. Live terminal acceptance currently covers macOS/Chrome. Firefox/Safari builds and Linux CI do not establish live terminal support on those platforms. Automatic setup targets macOS + Chrome only. @@ -52,24 +52,26 @@ The `dsh-latest` CI job installs `@deepseek-ai/dsh@latest`, builds the actual pl After the relevant release is public, users can run: ```sh -npm install -g @agentonweb/terminal-host@0.4.0 +npm install -g @agentonweb/terminal-host@0.4.1 # Repair setup or run it explicitly when lifecycle scripts were blocked: aow setup ``` -The release installer at `https://github.com/HeftyKoo/AgentOnWeb/releases/download/terminal-v0.4.0/install.sh` pins the package to 0.4.0 and explicitly calls setup. The user-facing command downloads `https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh`. Both that script and its pinned npm package must be live before announcing the installation flow. The installer uses the user-owned `~/.local` npm prefix, configures zsh/bash/fish startup paths, and links `aow` beside Node when that directory is writable and already on PATH. Existing AgentOnWeb npm links are updated; unrelated commands are preserved. The versioned release URL is a publication target, not proof that the asset exists. Download and inspect it before running it; do not advertise it until the release and clean-install test succeed. +The release installer at `https://github.com/HeftyKoo/AgentOnWeb/releases/download/terminal-v0.4.1/install.sh` pins the package to 0.4.1 and explicitly calls setup. The user-facing command downloads `https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh`. Both that script and its pinned npm package must be live before announcing the installation flow. The installer uses the user-owned `~/.local` npm prefix, configures zsh/bash/fish startup paths, and links `aow` beside Node when that directory is writable and already on PATH. Existing AgentOnWeb npm links are updated; unrelated commands are preserved. The versioned release URL is a publication target, not proof that the asset exists. Download and inspect it before running it; do not advertise it until the release and clean-install test succeed. ## Local installation and upgrades Before publishing, use the audited archive with the source-built extension: ```sh -AOW_SKIP_SETUP=1 npm install -g ./release/agentonweb-terminal-host-0.4.0.tgz +AOW_SKIP_SETUP=1 npm install -g ./release/agentonweb-terminal-host-0.4.1.tgz aow setup --extension-id YOUR_UNPACKED_CHROME_EXTENSION_ID ``` For manual pairing without Chrome/CLI integration, use `aow service install` and the private setup page instead. Setup registers absolute Node/package paths; rerun it when paths change. The official native host allowlist includes the official Chrome extension ID; unpacked IDs must be enrolled explicitly. -Installing over a running service does not reload its code. Finish active shell work, run `aow service uninstall`, then `aow setup` (include the unpacked ID when needed) to activate the installed version. Stopping the service ends live shells; saved CLI history, browser grants and pairing receipts remain. Restart `dsh web` separately after updating its plugin. +Installing over a running service does not reload its code. With update-capable hosts, close all live shells and run `aow update` from a separate macOS Terminal; it retains the original npm prefix, skips setup to preserve integrations/enrolled extension IDs, installs the checked stable version and verifies the restarted service. `aow service restart` activates code already installed. Both refuse live shells unless `--force` is explicit. Hosts without the maintenance interface need `aow service uninstall`, then the installer or `aow setup` (include the unpacked ID when needed) once. Saved CLI history, browser grants and pairing receipts remain. Restart `dsh web` separately after updating its plugin. + +Automatic host checks query the public npm metadata endpoint, cache successful results for 24 hours and back off on failures. They never install code. Publish and verify npm before advancing the public installer pin; versioned release installers remain immutable/pinned. Validate active-shell refusal, original-prefix installation, npm failure, disabled/offline checks, installed-versus-running status and restart readiness with every updater change. Terminal-host checks/commands and its bundled private management page can ship without an extension protocol change. Reminders in extension settings require a separate extension release. To remove integrations, run `aow uninstall` **before** `npm uninstall -g @agentonweb/terminal-host`. This stops service terminals, removes native registration and AgentOnWeb hooks, and restores the previous notify command while preserving user settings/history/backups. See [setup and recovery](one-command-setup.md). diff --git a/docs/store-site/privacy.md b/docs/store-site/privacy.md index a088c7d..513e3a6 100644 --- a/docs/store-site/privacy.md +++ b/docs/store-site/privacy.md @@ -4,9 +4,9 @@ title: Privacy Policy --- # AgentOnWeb Privacy Policy -Effective date: September 29, 2026 +Effective date: October 13, 2026 -AgentOnWeb is an open-source browser extension for Chrome, Firefox, and Safari that displays local workspaces over the website you are using. Version 0.2.0 supports DeepSeek Harness and a separate local terminal host. This policy covers the browser extensions, the Safari macOS containing app, the DSH integration and the local terminal host. +AgentOnWeb is an open-source browser extension for Chrome, Firefox, and Safari that displays local workspaces over the website you are using. The 0.4.0 extension supports DeepSeek Harness and a separate local terminal host for Codex, Claude Code and Pi. Terminal host 0.4.1 adds version checks and explicit upgrade/restart commands. This policy covers the browser extensions, the Safari macOS containing app, the DSH integration and the local terminal host. ## Data handled on your computer @@ -24,6 +24,8 @@ The shell installer installs the terminal package under `~/.local` and adds its ## Collection and sharing +The update-capable local terminal host automatically queries public npm metadata for `@agentonweb/terminal-host`, at most once every 24 hours after successful checks. Failed checks use increasing retry delays. npm's infrastructure receives normal request/network information, such as your IP address; the query contains no terminal text, prompts, browsing data, directories, connection credentials or user identifier. Results and the on/off preference are stored locally in `~/.agentonweb/terminal/update-cache.json` and `updates.json`. Checks do not install packages or restart processes. Disable them with `aow check-update --disable`, or `AOW_UPDATE_CHECK=0` in the host environment; CI skips automatic checks. Explicit `aow check-update` and `aow update` still contact npm. An explicit update uses npm to install the checked package and its dependencies under the original global prefix. Live shells block upgrade/restart unless you explicitly use `--force` to end their tasks. + AgentOnWeb does not operate an analytics service, advertising service, or developer-hosted data backend. The extension and containing app do not send your browsing history, website contents, prompts, or connection credentials to the AgentOnWeb developer. AgentOnWeb does not sell personal information or track users across apps and websites for advertising. In the DSH workspace, anything you type, attach or authorize is handled by DSH and the model providers or plugins you configure. In Local terminal, commands run on your computer with your user permissions. Those commands may read files, use the system clipboard or send data to their configured services; terminal text is delivered locally between the host and browser. Those services may process information outside your computer under their own policies. AgentOnWeb does not promise that your AI conversations remain offline. The extension itself does not automatically scrape or transmit the underlying webpage to a model. diff --git a/docs/store-site/support.md b/docs/store-site/support.md index c30b95a..9300646 100644 --- a/docs/store-site/support.md +++ b/docs/store-site/support.md @@ -36,13 +36,13 @@ If no runtime is found, check that `dsh web` is running and restart it after ins [Setup guide](index.html) · [Privacy policy](privacy.html) · [Source and releases](https://github.com/HeftyKoo/AgentOnWeb) -## Local terminal (0.2.0) +## Local terminal Use Codex or Claude Code on your websites with macOS + Chrome. Install the Chrome store extension and follow the [one-command setup guide](https://github.com/HeftyKoo/AgentOnWeb/blob/main/docs/one-command-setup.md). The installer configures the terminal service and Chrome connection, detects installed CLIs on `PATH`, and asks which Codex/Claude Code/Pi notification integrations to install. If Local terminal is missing, check `aow service status` or keep `aow terminal` running. Use `aow service open` to reopen private pairing/connection management. Type `cd` and your CLI commands inside the browser shell. All connected views can type into the same shell; focus its view to control terminal dimensions. If the shell exited, use **+** to create another one. Refreshing a view preserves a running shell; a host or computer restart ends live shell processes. -Reinstalling a running service does not load new host code immediately. Finish your terminal work before stopping and reinstalling the service. Never include the private setup URL or raw service logs in public issues. +Terminal host 0.4.1 checks the stable npm version daily without installing code or interrupting tasks. Its private management page shows available updates and installed versus running versions. Use `aow check-update` to check now or `aow check-update --disable` to turn automatic checks off. Run `aow update` or `aow service restart` in a separate macOS Terminal after exiting or closing all live shells. Explicit `--force` ends their tasks. For the first upgrade from 0.4.0 or earlier, finish terminal work, run `aow service uninstall`, then rerun the installer. Saved history, browser grants and integration settings remain. Never include the private setup URL or raw service logs in public issues. ## Setup, notifications and removal diff --git a/packages/terminal-host/README.md b/packages/terminal-host/README.md index b01a763..01ddd65 100644 --- a/packages/terminal-host/README.md +++ b/packages/terminal-host/README.md @@ -48,10 +48,28 @@ aow service # Reopen the private browser setup/connection-management aow service open # Explicit form of the same command aow service --help aow service status +aow service restart # Reload installed code after exiting or closing terminals aow service uninstall # Stop the service and remove automatic startup ``` -Uninstalling stops its live terminals but does not delete saved CLI history. The service is installed only for your macOS user. It uses `~/Library/LaunchAgents/com.agentonweb.terminal.plist`; diagnostics are at `~/.agentonweb/terminal/service.log`. Keep the installed Node and package paths available; reinstall the service after moving/removing them. Re-running install while loaded updates the next-login configuration without interrupting live terminals. New host code takes effect after the host restarts. To update immediately, finish your shell work, run `aow service uninstall`, then `aow service install`; uninstall stops live processes. Browser grants and CLI history remain. +Uninstalling stops its live terminals but does not delete saved CLI history. The service is installed only for your macOS user. It uses `~/Library/LaunchAgents/com.agentonweb.terminal.plist`; diagnostics are at `~/.agentonweb/terminal/service.log`. Keep the installed Node and package paths available; reinstall the service after moving/removing them. Re-running install while loaded updates the next-login configuration without interrupting live terminals. New host code takes effect after the host restarts. Run `aow service restart` in a separate macOS Terminal after exiting or closing every live shell. It refuses while any shell is alive, including an idle shell; `--force` explicitly ends their tasks. Restart waits for the old process to exit and verifies the new process's version. Browser grants and CLI history remain. + +## Version checks and upgrades + +```sh +aow --version # Installed CLI version +aow service status # Installed CLI and running host versions +aow check-update # Query the latest stable npm version now +aow check-update --disable # Persistently disable automatic checks +aow check-update --enable # Re-enable automatic checks +aow update # Install a newer version and restart safely +``` + +The host checks npm asynchronously after startup, caches successful checks for 24 hours, and retries failed checks with increasing delays. Each request times out after three seconds. Automatic checks only query public package metadata; they never download/install packages or restart the service. They are disabled in CI and when `AOW_UPDATE_CHECK=0` is set in the host environment. Explicit `check-update` and `update` still query npm when automatic checks are disabled. The private management page and service log show available updates; the page also identifies a newer installed version waiting for restart. + +Run `aow update` from a separate macOS Terminal, outside AgentOnWeb. It supports global npm installations, retains the original installation prefix and Node executable, and installs the exact stable version it checked. Development checkouts, npx and other package-manager layouts must be updated through their original workflow. It never downgrades to an older `latest` or selects a prerelease. An incompatible Node requirement stops the update before installation. Active terminals block installation/restart unless you explicitly pass `--force`; new terminal creation is paused during maintenance. An npm failure does not restart the service. If installation succeeds but restart fails, inspect `service.log` and run `aow setup` to repair the service. + +Update skips automatic setup so it preserves chosen CLI integrations, enrolled unpacked extension IDs, pairing and shell profiles. The existing integrations keep referring to the same package paths. Without a running service, update installs the package and leaves service setup to you. Old hosts without the maintenance interface require finishing terminal work, stopping them manually and rerunning the installer once to acquire these commands. Versioned release installers remain pinned. Browser extensions and DSH plugins follow their own release/update paths. Only one host may use a state directory. A dead PID is recovered automatically, with lock updates serialized through `host.pid.guard`. If `host.pid` is invalid or an interrupted update leaves `host.pid.guard`, the startup error identifies the affected path. Confirm that no host is running or starting for that directory before removing the reported file or empty guard directory. Do not remove a live host's lock. diff --git a/packages/terminal-host/package.json b/packages/terminal-host/package.json index 31f0c3d..b88e2ae 100644 --- a/packages/terminal-host/package.json +++ b/packages/terminal-host/package.json @@ -1,6 +1,6 @@ { "name": "@agentonweb/terminal-host", - "version": "0.4.0", + "version": "0.4.1", "type": "module", "license": "MIT", "description": "Your local interactive shell in AgentOnWeb", @@ -25,6 +25,7 @@ "@xterm/addon-serialize": "^0.13.0", "@xterm/headless": "^5.5.0", "node-pty": "^1.1.0", + "semver": "^7.8.5", "ws": "^8.18.3" }, "devDependencies": { @@ -32,6 +33,7 @@ "@agentonweb/connector-host": "workspace:*", "@agentonweb/terminal-core": "workspace:*", "@agentonweb/terminal-surface": "workspace:*", + "@types/semver": "^7.8.0", "@types/ws": "^8.18.1", "smol-toml": "1.8.0" }, diff --git a/packages/terminal-host/src/cli.ts b/packages/terminal-host/src/cli.ts index 5f8d472..bb074a8 100644 --- a/packages/terminal-host/src/cli.ts +++ b/packages/terminal-host/src/cli.ts @@ -21,8 +21,37 @@ import { installClaudeHooks, uninstallClaudeHooks } from "./claude-hooks.js"; import { installPiExtension, uninstallPiExtension } from "./pi-hooks.js"; import { reportAgentEvent } from "./agent-events.js"; import type { NativeSurface } from "@agentonweb/connector-contract"; +import { VERSION, installedVersion } from './version.js'; +import { Updates } from './updates.js'; +import { Maintenance } from './management.js'; +import { updateCommand } from './upgrade.js'; const inputArgs = process.argv.slice(2); +if (inputArgs.length === 1 && inputArgs[0] === '--version') { + console.log(VERSION); process.exit(0); +} +if (inputArgs[0] === 'update' || inputArgs[0] === 'check-update') { + try { + if (inputArgs[0] === 'update') await updateCommand(inputArgs.slice(1)); + else { + const option = inputArgs[1]; + if (inputArgs.length > 2 || option && !['--enable', '--disable', '--help', '-h'].includes(option)) throw new Error('Usage: aow check-update [--enable|--disable]'); + const updates = new Updates(serviceDirectory); + if (option === '--help' || option === '-h') console.log('Usage: aow check-update [--enable|--disable]\nCheck the stable npm version, or enable/disable automatic checks. Explicit checks still work when disabled.'); + else if (option) { + await updates.configure(option === '--enable'); + console.log(`Automatic update checks ${option === '--enable' ? 'enabled' : 'disabled'}.`); + } else { + const status = await updates.check(true); + if (status.error) throw new Error(status.error); + console.log(`Installed CLI: ${VERSION}\nLatest stable: ${status.latest}\n${status.updateAvailable ? 'Update available. Run aow update from a separate macOS Terminal.' : 'No newer stable version available.'}`); + } + } + } catch (error) { + console.error(`aow: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); + } + process.exit(0); +} if (inputArgs[0] === "native-host") { await runNativeHost(inputArgs[1] ?? "", serviceDirectory); process.exit(0); @@ -87,7 +116,7 @@ if (inputArgs[0] === "service") { } if (inputArgs.length === 1 && inputArgs[0] === "--help") { console.log( - "Usage: aow setup | aow uninstall | aow terminal | aow codex-hooks install|uninstall | aow claude-hooks install|uninstall | aow pi-extension install|uninstall | aow service [open|install|status|uninstall] (default: open)", + "Usage: aow setup | aow uninstall | aow terminal | aow --version | aow check-update [--enable|--disable] | aow update [--force] | aow codex-hooks install|uninstall | aow claude-hooks install|uninstall | aow pi-extension install|uninstall | aow service [open|install|status|restart [--force]|uninstall] (default: open)", "\nRun aow setup --help to select CLI integrations with --agents.", ); process.exit(0); @@ -109,6 +138,8 @@ const agentSessions = new AgentSessions(); let agentEndpoint = ""; type HostedSession = { token: string; exited?: boolean; session: TerminalSession; name?: string; directory: string; directoryRefresh?: Promise }; const sessions = new Map(); +const maintenance = new Maintenance(); +const updates = new Updates(stateDirectory); function sessionNames() { return [...sessions].map(([id, value]) => ({ id, name: value.name ?? (basename(value.directory) || value.directory) })); } @@ -152,6 +183,7 @@ async function readBody(req: IncomingMessage, limit: number): Promise { return Buffer.concat(chunks).toString("utf8"); } function newSession() { + if (maintenance.active) throw new RequestError(409, 'An update or restart is in progress. Try again when the service is ready.'); if (sessions.size >= 8) throw new RequestError(409, "Close an unused terminal before opening another (maximum 8)."); const id = randomUUID(); const token = randomBytes(32).toString("base64url"); @@ -201,6 +233,8 @@ async function saveEndpoint() { temp, JSON.stringify({ pid: process.pid, + version: VERSION, + managedService: inputArgs.includes('--service'), nativeToken, launchUrl: `${origin}/launch?token=${launchToken}`, }), @@ -211,6 +245,33 @@ async function saveEndpoint() { const publicRoot = new URL("./public/", import.meta.url); const server = createServer(async (req, res) => { try { + if (req.url === '/service-control') { + // Local CLI only: never accept a browser Origin or delegated/admin cookie. + if (req.headers.origin || req.headers.host !== new URL(origin).host + || !matchesSecret((req.headers.authorization ?? '').replace(/^Bearer /u, ''), nativeToken)) { + res.writeHead(403).end(); return; + } + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'application/json'); + if (req.method === 'GET') { + res.end(JSON.stringify({ version: VERSION, activeTerminals: [...sessions.values()].filter(item => !item.exited).length, + managedService: inputArgs.includes('--service') })); return; + } + if (req.method !== 'POST') { res.writeHead(405).end(); return; } + let action; + try { action = JSON.parse(await readBody(req, 4096)); } + catch { throw new RequestError(400, 'Invalid service action.'); } + if (!action || typeof action !== 'object' || Array.isArray(action)) throw new RequestError(400, 'Invalid service action.'); + if (action.action === 'prepare' && (action.force === undefined || typeof action.force === 'boolean')) { + try { + const token = maintenance.prepare([...sessions.values()].filter(item => !item.exited).length, action.force === true); + res.end(JSON.stringify({ token })); + } catch (error) { throw new RequestError(409, (error as Error).message); } + } else if (action.action === 'release' && typeof action.token === 'string') { + maintenance.release(action.token); res.end('{}'); + } else throw new RequestError(400, 'Invalid service action.'); + return; + } if (req.url === "/native-pair") { if (req.method !== "POST" || req.headers.origin || req.headers.host !== new URL(origin).host || !matchesSecret((req.headers.authorization ?? "").replace(/^Bearer /u, ""), nativeToken)) { @@ -265,6 +326,13 @@ const server = createServer(async (req, res) => { "Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors http: https: chrome-extension: moz-extension: safari-web-extension:", ); + if (url.pathname === '/api/updates') { + if (!isAdmin(req.headers.cookie) || req.headers['x-agentonweb-terminal'] !== '1' + || req.headers.origin && req.headers.origin !== origin) { res.writeHead(403).end(); return; } + if (req.method !== 'GET') { res.writeHead(405).end(); return; } + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ...await updates.status(), installedVersion: installedVersion(), runningVersion: VERSION })); return; + } if (url.pathname === "/api/terminals" || url.pathname === "/api/connections") { const admin = isAdmin(req.headers.cookie); const permitted = url.pathname === "/api/connections" ? admin : authenticated(req.headers.cookie); @@ -497,10 +565,14 @@ console.log( `AgentOnWeb · Local shell\nExecutable: ${executable}\nInitial directory: ${cwd}\nConnector: ${connector.port}\nOpen once: ${origin}/launch?token=${launchToken}\nApprove your browser through the private setup page.`, ); await saveEndpoint(); +const stopUpdateChecks = updates.start(status => { + console.log(`AgentOnWeb ${status.latest} is available (running ${VERSION}). Run aow update after closing terminal sessions.`); +}); let stopping = false; async function stop() { if (stopping) return; stopping = true; + stopUpdateChecks(); await cleanup([ ...[...sessions.values()].map(({ session }) => () => session.dispose()), () => unlink(join(stateDirectory, "endpoint.json")).catch((error: NodeJS.ErrnoException) => { diff --git a/packages/terminal-host/src/host.integration.test.ts b/packages/terminal-host/src/host.integration.test.ts index 377261c..6a60145 100644 --- a/packages/terminal-host/src/host.integration.test.ts +++ b/packages/terminal-host/src/host.integration.test.ts @@ -5,10 +5,12 @@ import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { once } from "node:events"; import { request } from "node:http"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import WebSocket from "ws"; +import { VERSION } from './version.js'; let host: ChildProcess | undefined; let directory = ""; +beforeEach(() => { vi.stubEnv('AOW_UPDATE_CHECK', '0'); }); afterEach(async () => { if (host && host.exitCode === null) { const stopped = once(host, "exit"); @@ -18,6 +20,52 @@ afterEach(async () => { if (directory) await rm(directory, { recursive: true, force: true }); directory = ""; host = undefined; + vi.unstubAllEnvs(); +}); + +it('restricts update status to admins and maintenance to the local CLI, preserving shells when busy', async () => { + directory = await mkdtemp(join(tmpdir(), 'aow-maintenance-test-')); + host = spawn(process.execPath, [resolve('packages/terminal-host/lib/cli.js'), 'terminal', '--service'], { + env: { ...process.env, HOME: directory, SHELL: '/bin/sh', ENV: undefined, BASH_ENV: undefined, ZDOTDIR: undefined }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let output = ''; host.stdout!.on('data', chunk => { output += chunk; }); + await expect.poll(() => output.includes('Open once:'), { timeout: 10000 }).toBe(true); + const launchUrl = /Open once: (http:\/\/[^\s]+)/u.exec(output)![1]!; + const origin = new URL(launchUrl).origin; + let endpoint: { nativeToken: string }; + await expect.poll(async () => { + try { endpoint = JSON.parse(await readFile(join(directory, '.agentonweb/terminal/endpoint.json'), 'utf8')); return true; } + catch { return false; } + }).toBe(true); + const launch = await fetch(launchUrl); + const cookies = launch.headers.getSetCookie().map(value => value.split(';')[0]!); + await launch.text(); + const headers = { Cookie: cookies.join('; '), 'X-AgentOnWeb-Terminal': '1', Origin: origin }; + const delegated = cookies.find(cookie => !cookie.startsWith('aow_admin_'))!; + expect((await fetch(origin + '/api/updates', { headers: { ...headers, Cookie: delegated } })).status).toBe(403); + expect((await fetch(origin + '/api/updates')).status).toBe(403); + const update = await fetch(origin + '/api/updates', { headers }); + expect(await update.json()).toMatchObject({ enabled: false, installedVersion: VERSION, runningVersion: VERSION }); + const authorization = `Bearer ${endpoint!.nativeToken}`; + expect((await fetch(origin + '/service-control', { headers })).status).toBe(403); + expect((await fetch(origin + '/service-control', { headers: { authorization, Origin: origin } })).status).toBe(403); + const control = (action?: object) => fetch(origin + '/service-control', { + headers: { authorization }, ...(action ? { method: 'POST', body: JSON.stringify(action) } : {}), + }); + expect(await (await control()).json()).toMatchObject({ activeTerminals: 1, managedService: true }); + expect((await control({ action: 'prepare' })).status).toBe(409); + expect(await (await control()).json()).toMatchObject({ activeTerminals: 1 }); + const { token } = await (await control({ action: 'prepare', force: true })).json(); + expect((await control({ action: 'prepare', force: true })).status).toBe(409); + const create = () => fetch(origin + '/api/terminals', { headers, method: 'POST', body: JSON.stringify({ action: 'new' }) }); + expect((await create()).status).toBe(409); + await control({ action: 'release', token: 'wrong' }); expect((await create()).status).toBe(409); + await control({ action: 'release', token }); expect((await create()).status).toBe(200); + const terminals = await (await fetch(origin + '/api/terminals', { headers })).json(); + for (const item of terminals) await fetch(origin + '/api/terminals', { headers, method: 'POST', body: JSON.stringify({ action: 'close', id: item.id }) }); + await expect.poll(async () => (await (await control()).json()).activeTerminals).toBe(0); + expect((await control({ action: 'prepare' })).status).toBe(200); }); it("preserves Unicode terminal names split across HTTP body chunks and publishes terminal registry changes", async () => { directory = await mkdtemp(join(tmpdir(), "aow-unicode-test-")); diff --git a/packages/terminal-host/src/management.test.ts b/packages/terminal-host/src/management.test.ts new file mode 100644 index 0000000..1a81820 --- /dev/null +++ b/packages/terminal-host/src/management.test.ts @@ -0,0 +1,22 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { Maintenance, outsideTerminal } from './management.js'; +afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); }); +it('protects idle live shells, freezes new terminals and serializes maintenance until released', () => { + const maintenance = new Maintenance(); + expect(() => maintenance.prepare(1, false)).toThrow('active terminal'); + expect(maintenance.active).toBe(false); + const token = maintenance.prepare(0, false); + expect(maintenance.active).toBe(true); + expect(() => maintenance.prepare(0, true)).toThrow('in progress'); + maintenance.release('different-token'); expect(maintenance.active).toBe(true); + maintenance.release(token); expect(maintenance.active).toBe(false); +}); +it('allows explicit force and expires a lease after an interrupted CLI', () => { + vi.useFakeTimers(); + const maintenance = new Maintenance(); maintenance.prepare(2, true); + vi.advanceTimersByTime(10 * 60 * 1000); expect(maintenance.active).toBe(false); +}); +it('refuses to kill the terminal running the upgrade command itself', () => { + vi.stubEnv('AOW_AGENT_ENDPOINT', 'http://127.0.0.1:1/agent-event'); + expect(outsideTerminal).toThrow('separate macOS Terminal'); +}); diff --git a/packages/terminal-host/src/management.ts b/packages/terminal-host/src/management.ts new file mode 100644 index 0000000..0945a04 --- /dev/null +++ b/packages/terminal-host/src/management.ts @@ -0,0 +1,56 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export type HostStatus = { version: string; activeTerminals: number; managedService: boolean }; +type Endpoint = { pid: number; nativeToken: string; launchUrl: string }; +export class Maintenance { + private lease: { token: string; expires: number } | undefined; + get active() { return !!this.lease && this.lease.expires > Date.now(); } + prepare(activeTerminals: number, force: boolean) { + if (this.active) throw new Error('Another update or restart is in progress.'); + if (activeTerminals && !force) throw new Error(`${activeTerminals} active terminal(s). Exit or close them first, or use --force to end their tasks.`); + const token = randomUUID(); + this.lease = { token, expires: Date.now() + 10 * 60 * 1000 }; + return token; + } + release(token: string) { if (this.lease?.token === token) this.lease = undefined; } +} +export async function liveEndpoint(directory: string): Promise { + let value: Endpoint; + try { value = JSON.parse(await readFile(join(directory, 'endpoint.json'), 'utf8')) as Endpoint; } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; } + if (!Number.isSafeInteger(value.pid) || value.pid < 1) throw new Error('Invalid host PID. Run aow setup to repair the service.'); + try { process.kill(value.pid, 0); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return undefined; throw error; } + const url = new URL(value.launchUrl); + if (url.protocol !== 'http:' || url.hostname !== 'localhost' || url.pathname !== '/launch' || !url.port + || typeof value.nativeToken !== 'string') throw new Error('Invalid local service endpoint. Run aow setup to repair it.'); + return value; +} +export async function hostRequest(endpoint: Endpoint, action?: object) { + const response = await fetch(new URL('/service-control', endpoint.launchUrl), { + ...(action ? { method: 'POST', body: JSON.stringify(action) } : {}), redirect: 'error', + signal: AbortSignal.timeout(3000), headers: { authorization: `Bearer ${endpoint.nativeToken}`, 'content-type': 'application/json' }, + }); + if (!response.ok) { + const body = await response.json().catch(() => undefined) as { error?: string } | undefined; + throw new Error(body?.error ?? 'The running host cannot safely prepare an update. Finish terminal work and upgrade/restart it manually.'); + } + return response.json(); +} +export function outsideTerminal() { + if (process.env.AOW_AGENT_ENDPOINT) throw new Error('Run this command in a separate macOS Terminal, outside AgentOnWeb.'); + if (process.getuid?.() === 0) throw new Error('Run this command as your own user, without sudo.'); +} +export async function prepareHost(directory: string, force: boolean) { + outsideTerminal(); + const endpoint = await liveEndpoint(directory); + if (!endpoint) return { status: undefined, release: async () => {} }; + const status = await hostRequest(endpoint) as HostStatus; + if (typeof status.version !== 'string' || !Number.isInteger(status.activeTerminals) || typeof status.managedService !== 'boolean') + throw new Error('Invalid host status. Restart the host manually after finishing terminal work.'); + if (!status.managedService) throw new Error('A foreground host is running. Stop aow terminal before updating or restarting the service.'); + const { token } = await hostRequest(endpoint, { action: 'prepare', force }) as { token: string }; + return { status, release: async () => { await hostRequest(endpoint, { action: 'release', token }).catch(() => {}); } }; +} diff --git a/packages/terminal-host/src/service.test.ts b/packages/terminal-host/src/service.test.ts index 92327f3..bfbd441 100644 --- a/packages/terminal-host/src/service.test.ts +++ b/packages/terminal-host/src/service.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ writeFile: vi.fn(async (..._args: unknown[]) => {}), readFile: vi.fn(), execFileSync: vi.fn(() => '') })); +const mocks = vi.hoisted(() => ({ writeFile: vi.fn(async (..._args: unknown[]) => {}), readFile: vi.fn(), execFileSync: vi.fn<(...args: any[]) => string>(() => '') })); vi.mock('node:fs/promises', () => ({ mkdir: async () => {}, writeFile: mocks.writeFile, readFile: mocks.readFile, unlink: vi.fn() })); vi.mock('node:child_process', () => ({ execFileSync: mocks.execFileSync })); import { serviceCommand } from './service.js'; +import { VERSION } from './version.js'; beforeEach(() => { vi.clearAllMocks(); + mocks.readFile.mockReset(); + mocks.execFileSync.mockReset().mockReturnValue(''); vi.stubGlobal('process', { ...process, platform: 'darwin', getuid: () => 501, kill: vi.fn() }); }); afterEach(() => { @@ -51,3 +54,43 @@ it('explains how to repair a missing or unready service', async () => { await vi.runAllTimersAsync(); await result; }); + +it('refuses a restart with live terminals before calling bootout or writing configuration', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => options?.method === 'POST' + ? Response.json({ error: '1 active terminal' }, { status: 409 }) + : Response.json({ version: '0.4.0', activeTerminals: 1, managedService: true }))); + await expect(serviceCommand(['restart'])).rejects.toThrow('active terminal'); + expect(mocks.execFileSync).not.toHaveBeenCalled(); expect(mocks.writeFile).not.toHaveBeenCalled(); +}); + +it('restarts after maintenance and confirms the new PID and running version', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + let bootedOut = false, bootstrapped = false; + const endpoint = (pid: number) => JSON.stringify({ pid, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' }); + mocks.readFile.mockImplementation(async () => endpoint(bootstrapped ? 888 : 777)); + mocks.execFileSync.mockImplementation((_file: string, args: string[]) => { + if (args[0] === 'bootout') bootedOut = true; + if (args[0] === 'bootstrap') bootstrapped = true; + if (args[0] === 'print' && bootedOut && !bootstrapped) throw new Error('unloaded'); + return ''; + }); + vi.stubGlobal('process', { ...process, platform: 'darwin', getuid: () => 501, kill: (pid: number) => { + if (pid === 777 && bootedOut) throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + } }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + if (options?.method === 'POST') return Response.json({ token: 'lease' }); + return Response.json({ version: bootstrapped ? VERSION : '0.4.0', activeTerminals: 0, managedService: true }); + })); + await serviceCommand(['restart']); + expect(bootedOut).toBe(true); expect(bootstrapped).toBe(true); + expect(log).toHaveBeenCalledWith(`AgentOnWeb service is running version ${VERSION}.`); +}); + +it('reports readiness failure instead of claiming a successful restart', async () => { + vi.useFakeTimers(); + mocks.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + mocks.execFileSync.mockImplementation((_file: string, args: string[]) => { if (args[0] === 'print') throw new Error('unloaded'); return ''; }); + const result = expect(serviceCommand(['restart'])).rejects.toThrow('did not confirm version'); + await vi.runAllTimersAsync(); await result; +}); diff --git a/packages/terminal-host/src/service.ts b/packages/terminal-host/src/service.ts index 85dd699..1c5b9ac 100644 --- a/packages/terminal-host/src/service.ts +++ b/packages/terminal-host/src/service.ts @@ -3,6 +3,8 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { hostRequest, liveEndpoint, prepareHost } from './management.js'; +import { VERSION } from './version.js'; const label = 'com.agentonweb.terminal'; export const serviceDirectory = join(homedir(), '.agentonweb', 'terminal'); @@ -20,14 +22,14 @@ export function launchAgent(node: string, entry: string, home: string, path: str 'StandardErrorPath' + string(join(home, '.agentonweb/terminal/service.log')) + '\n'; } export async function serviceCommand(args: string[]): Promise { - const usage = 'Usage: aow service [open | install [--no-open] | status | uninstall] (default: open)'; + const usage = 'Usage: aow service [open | install [--no-open] | status | restart [--force] | uninstall] (default: open)'; if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { console.log(usage); return; } const action = args[0] ?? 'open'; - if (!['install', 'open', 'status', 'uninstall'].includes(action) || - (args.length > 1 && !(action === 'install' && args.length === 2 && args[1] === '--no-open'))) { + if (!['install', 'open', 'status', 'restart', 'uninstall'].includes(action) || + (args.length > 1 && !(args.length === 2 && (action === 'install' && args[1] === '--no-open' || action === 'restart' && args[1] === '--force')))) { throw new Error(usage); } if (process.platform !== 'darwin') throw new Error('Automatic service installation currently supports macOS. Run aow terminal on this platform.'); @@ -48,12 +50,58 @@ export async function serviceCommand(args: string[]): Promise { else if (action === 'status') { try { execFileSync('/bin/launchctl', ['print', domain + '/' + label], { stdio: 'ignore' }); console.log('AgentOnWeb terminal service is loaded.'); } catch { console.log('AgentOnWeb terminal service is not loaded.'); } + console.log(`Installed CLI: ${VERSION}`); + const endpoint = await liveEndpoint(serviceDirectory); + if (endpoint) { + try { + const status = await hostRequest(endpoint); + console.log(`Running host: ${status.version} · ${status.activeTerminals} active terminal(s).`); + if (status.version !== VERSION) console.log('Installed version differs from the running host. Run aow service restart after closing terminals.'); + } catch { console.log('Running host version unavailable. Finish terminal work and restart the older host manually.'); } + } + } else if (action === 'restart') { + const prepared = await prepareHost(serviceDirectory, args.includes('--force')); + try { await reloadService(VERSION); } + finally { await prepared.release(); } } else if (action === 'uninstall') { try { execFileSync('/bin/launchctl', ['bootout', domain + '/' + label], { stdio: 'pipe' }); } catch {} await unlink(plist).catch(error => { if (error.code !== 'ENOENT') throw error; }); console.log('Automatic startup removed. Service terminals stopped; saved CLI history is unchanged.'); } } +export function serviceLoaded(): boolean { + try { execFileSync('/bin/launchctl', ['print', `gui/${process.getuid!()}/${label}`], { stdio: 'ignore' }); return true; } + catch { return false; } +} +/** Caller holds the host's maintenance lease before reaching this point. */ +export async function reloadService(expectedVersion: string) { + const old = await liveEndpoint(serviceDirectory); + if (serviceLoaded()) { + if (!old) throw new Error('The loaded service is not ready. Check service.log before restarting; no service was stopped.'); + execFileSync('/bin/launchctl', ['bootout', `gui/${process.getuid!()}/${label}`], { stdio: 'pipe', timeout: 10000 }); + let stopped = false; + for (let i = 0; i < 100; i++) { + try { process.kill(old.pid, 0); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') { stopped = true; break; } throw error; } + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (!stopped) throw new Error('The previous host did not stop. Check service.log before starting another host.'); + } + await serviceCommand(['install', '--no-open']); + for (let i = 0; i < 50; i++) { + try { + const endpoint = await liveEndpoint(serviceDirectory); + if (endpoint && endpoint.pid !== old?.pid) { + const status = await hostRequest(endpoint); + if (status.version === expectedVersion && status.managedService) { + console.log(`AgentOnWeb service is running version ${expectedVersion}.`); return; + } + } + } catch { /* Wait for the new host to publish its endpoint. */ } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Service restart did not confirm version ${expectedVersion}. Check ~/.agentonweb/terminal/service.log and run aow setup to repair it.`); +} async function openService() { for (let attempt = 0; attempt < 50; attempt++) { try { diff --git a/packages/terminal-host/src/updates.test.ts b/packages/terminal-host/src/updates.test.ts new file mode 100644 index 0000000..4b0a634 --- /dev/null +++ b/packages/terminal-host/src/updates.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Updates } from './updates.js'; + +let directory: string; +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'aow-updates-')); + vi.stubEnv('CI', ''); vi.stubEnv('AOW_UPDATE_CHECK', ''); +}); +afterEach(async () => { vi.unstubAllEnvs(); vi.useRealTimers(); await rm(directory, { recursive: true, force: true }); }); +function fixture(version = '0.4.0', latest = '0.5.0') { + const request = vi.fn(async () => Response.json({ name: '@agentonweb/terminal-host', version: latest, engines: { node: '>=22.19.0' } })); + let now = 100000; + const updates = new Updates(directory, { fetch: request, version, now: () => now }); + return { updates, request, advance: (ms: number) => { now += ms; } }; +} +it('caches successful checks across processes for 24 hours and explicit checks bypass the cache', async () => { + const f = fixture(); + expect(await f.updates.check()).toMatchObject({ latest: '0.5.0', updateAvailable: true }); + await new Updates(directory, { fetch: f.request, version: '0.4.0', now: () => 100001 }).check(); + expect(f.request).toHaveBeenCalledOnce(); + await f.updates.check(true); + expect(f.request).toHaveBeenCalledTimes(2); + f.advance(24 * 60 * 60 * 1000); + await f.updates.check(); + expect(f.request).toHaveBeenCalledTimes(3); +}); +it.each([['0.9.0', '0.10.0', true], ['0.5.0', '0.4.0', false], ['0.4.0', '0.4.0', false], ['0.5.0-beta.1', '0.5.0', true]])('compares semantic versions without downgrading: %s → %s', async (current, latest, expected) => { + expect((await fixture(current, latest).updates.check()).updateAvailable).toBe(expected); +}); +it.each(['0.5.0-beta.1', 'garbage', '0.5.0; touch /tmp/x'])('rejects invalid/prerelease latest metadata: %s', async latest => { + expect(await fixture('0.4.0', latest).updates.check(true)).toMatchObject({ error: expect.any(String), updateAvailable: false }); +}); +it('keeps the last successful result on network failure and backs off across restarts', async () => { + const f = fixture(); + await f.updates.check(); + f.request.mockRejectedValue(new Error('offline')); + expect(await f.updates.check(true)).toMatchObject({ latest: '0.5.0', checkedAt: 100000, failures: 1, nextCheck: 100000 + 15 * 60000 }); + await f.updates.check(); expect(f.request).toHaveBeenCalledTimes(2); + f.advance(15 * 60000); + expect(await f.updates.check()).toMatchObject({ failures: 2, nextCheck: 100000 + 45 * 60000 }); +}); +it('persists opt-out while allowing an explicit check, and honors environment/CI opt-outs', async () => { + const f = fixture(); + await f.updates.configure(false); + expect((await f.updates.check()).enabled).toBe(false); + expect(f.request).not.toHaveBeenCalled(); + await f.updates.check(true); expect(f.request).toHaveBeenCalledOnce(); + await f.updates.configure(true); + vi.stubEnv('AOW_UPDATE_CHECK', '0'); expect((await f.updates.status()).enabled).toBe(false); + vi.stubEnv('AOW_UPDATE_CHECK', ''); vi.stubEnv('CI', 'true'); expect((await f.updates.status()).enabled).toBe(false); +}); +it('recovers from corrupt cache, sends no runtime data, and bounds the network wait', async () => { + await writeFile(join(directory, 'update-cache.json'), '{broken'); + const f = fixture(); await f.updates.check(); + const [url, options] = f.request.mock.calls[0]!; + expect(url).toBe('https://registry.npmjs.org/@agentonweb%2Fterminal-host/latest'); + expect(options).toMatchObject({ redirect: 'error', headers: { accept: 'application/json' } }); + expect(options?.signal).toBeInstanceOf(AbortSignal); + expect(options?.body).toBeUndefined(); + expect(JSON.parse(await readFile(join(directory, 'update-cache.json'), 'utf8'))).toMatchObject({ latest: '0.5.0' }); +}); +it('coalesces checks and stops background timers', async () => { + const f = fixture(); + const first = f.updates.check(); const second = f.updates.check(); + expect(first).toBe(second); await first; + vi.useFakeTimers(); + const stop = f.updates.start(); expect(vi.getTimerCount()).toBe(2); + stop(); expect(vi.getTimerCount()).toBe(0); +}); diff --git a/packages/terminal-host/src/updates.ts b/packages/terminal-host/src/updates.ts new file mode 100644 index 0000000..0ba0b76 --- /dev/null +++ b/packages/terminal-host/src/updates.ts @@ -0,0 +1,98 @@ +import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import semver from 'semver'; +import { VERSION } from './version.js'; + +const DAY = 24 * 60 * 60 * 1000; +const registry = 'https://registry.npmjs.org/@agentonweb%2Fterminal-host/latest'; +type Cache = { latest?: string; node?: string; checkedAt?: number; nextCheck?: number; failures?: number; error?: string }; +export type UpdateStatus = Cache & { enabled: boolean; currentVersion: string; updateAvailable: boolean }; + +async function readJson(path: string): Promise { + try { return JSON.parse(await readFile(path, 'utf8')); } + catch (error) { + if (error instanceof SyntaxError || (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} +async function saveJson(path: string, value: unknown) { + const temp = `${path}.${randomUUID()}.tmp`; + try { await writeFile(temp, JSON.stringify(value) + '\n', { mode: 0o600 }); await rename(temp, path); } + finally { await unlink(temp).catch(() => {}); } +} +function cacheValue(value: unknown): Cache { + if (!value || typeof value !== 'object') return {}; + const v = value as Cache; + const result: Cache = {}; + if (typeof v.latest === 'string' && semver.valid(v.latest) && !semver.prerelease(v.latest)) result.latest = v.latest; + if (typeof v.node === 'string' && semver.validRange(v.node)) result.node = v.node; + for (const key of ['checkedAt', 'nextCheck', 'failures'] as const) + if (typeof v[key] === 'number' && Number.isFinite(v[key]) && v[key] >= 0) result[key] = v[key]; + if (typeof v.error === 'string') result.error = 'Could not check npm for updates. Try aow check-update later.'; + return result; +} + +/** Version queries only; never installs packages or restarts terminal processes. */ +export class Updates { + private pending: Promise | undefined; + constructor(private readonly directory: string, private readonly options: { + fetch?: typeof fetch; now?: () => number; version?: string; + } = {}) {} + async status(): Promise { + const config = await readJson(join(this.directory, 'updates.json')) as { enabled?: boolean } | undefined; + const cache = cacheValue(await readJson(join(this.directory, 'update-cache.json'))); + const version = this.options.version ?? VERSION; + return { ...cache, enabled: !process.env.CI && process.env.AOW_UPDATE_CHECK !== '0' && config?.enabled !== false, + currentVersion: version, updateAvailable: !!cache.latest && semver.gt(cache.latest, version) }; + } + async configure(enabled: boolean) { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + await saveJson(join(this.directory, 'updates.json'), { enabled }); + } + check(manual = false): Promise { + if (this.pending) return this.pending; + this.pending = this.query(manual).finally(() => { this.pending = undefined; }); + return this.pending; + } + private async query(manual: boolean) { + const status = await this.status(); + const now = (this.options.now ?? Date.now)(); + if (!manual && (!status.enabled || now < (status.nextCheck ?? 0))) return status; + let cache: Cache; + try { + const response = await (this.options.fetch ?? fetch)(registry, { + signal: AbortSignal.timeout(3000), redirect: 'error', headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error('Registry unavailable'); + const metadata = await response.json() as { name?: string; version?: string; engines?: { node?: string } }; + if (metadata.name !== '@agentonweb/terminal-host' || typeof metadata.version !== 'string' + || !semver.valid(metadata.version) || semver.prerelease(metadata.version)) throw new Error('Invalid stable version'); + if (metadata.engines?.node !== undefined && !semver.validRange(metadata.engines.node)) throw new Error('Invalid Node requirement'); + cache = { latest: metadata.version, ...(metadata.engines?.node ? { node: metadata.engines.node } : {}), + checkedAt: now, nextCheck: now + DAY, failures: 0 }; + } catch { + const failures = Math.min((status.failures ?? 0) + 1, 8); + cache = { ...(status.latest ? { latest: status.latest } : {}), ...(status.node ? { node: status.node } : {}), + ...(status.checkedAt !== undefined ? { checkedAt: status.checkedAt } : {}), failures, + nextCheck: now + Math.min(DAY, 15 * 60 * 1000 * 2 ** (failures - 1)), + error: 'Could not check npm for updates. Try aow check-update later.' }; + } + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + await saveJson(join(this.directory, 'update-cache.json'), cache); + return { ...cache, enabled: status.enabled, currentVersion: status.currentVersion, + updateAvailable: !!cache.latest && semver.gt(cache.latest, status.currentVersion) }; + } + start(notify: (status: UpdateStatus) => void = () => {}) { + let notified = ''; + const check = () => { void this.check().then(status => { + if (status.enabled && status.updateAvailable && status.latest !== notified) { + notified = status.latest!; notify(status); + } + }).catch(() => { /* An unwritable cache must not interfere with terminals. */ }); }; + const first = setTimeout(check, 1000); first.unref(); + // Cache enforces daily checks and exponential failure backoff across restarts. + const timer = setInterval(check, 15 * 60 * 1000); timer.unref(); + return () => { clearTimeout(first); clearInterval(timer); }; + } +} diff --git a/packages/terminal-host/src/upgrade.test.ts b/packages/terminal-host/src/upgrade.test.ts new file mode 100644 index 0000000..6383676 --- /dev/null +++ b/packages/terminal-host/src/upgrade.test.ts @@ -0,0 +1,63 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const f = vi.hoisted(() => ({ directory: '', root: '/tmp/aow prefix/lib/node_modules/@agentonweb/terminal-host', version: '0.4.0', + check: vi.fn(), prepare: vi.fn(), release: vi.fn(), reload: vi.fn(), spawn: vi.fn(), loaded: vi.fn() })); +vi.mock('./version.js', () => ({ packageDirectory: '/unused', VERSION: '0.4.0', installedVersion: () => f.version })); +vi.mock('./updates.js', () => ({ Updates: class { check = f.check; } })); +vi.mock('./management.js', () => ({ outsideTerminal: vi.fn(), prepareHost: f.prepare, liveEndpoint: vi.fn(async () => undefined) })); +vi.mock('./service.js', () => ({ get serviceDirectory() { return f.directory; }, reloadService: f.reload, serviceLoaded: f.loaded })); +vi.mock('node:child_process', () => ({ spawn: f.spawn })); +vi.mock('node:fs/promises', async importOriginal => ({ ...await importOriginal(), + realpath: async () => f.root, access: vi.fn(async () => {}) })); +import { npmPrefix, updateCommand } from './upgrade.js'; +beforeEach(async () => { + vi.clearAllMocks(); + f.directory = await mkdtemp(join(tmpdir(), 'aow-upgrade-')); f.version = '0.4.0'; + f.root = '/tmp/aow prefix/lib/node_modules/@agentonweb/terminal-host'; + vi.stubGlobal('process', { ...process, platform: 'darwin' }); + f.check.mockResolvedValue({ currentVersion: '0.4.0', latest: '0.5.0', node: '>=22.19.0', updateAvailable: true }); + f.prepare.mockResolvedValue({ status: { version: '0.4.0', managedService: true, activeTerminals: 0 }, release: f.release }); + f.loaded.mockReturnValue(true); + f.spawn.mockImplementation(() => { + const child = new EventEmitter(); queueMicrotask(() => { f.version = '0.5.0'; child.emit('exit', 0); }); return child; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); +afterEach(async () => { vi.unstubAllGlobals(); vi.restoreAllMocks(); await rm(f.directory, { recursive: true, force: true }); }); +it('installs the exact checked version in the original prefix, skips setup and verifies/reloads the host', async () => { + await updateCommand([]); + expect(f.spawn.mock.calls[0]?.[1]).toEqual(expect.arrayContaining(['--prefix', '/tmp/aow prefix', '@agentonweb/terminal-host@0.5.0'])); + expect(f.spawn.mock.calls[0]?.[2]).toMatchObject({ env: { AOW_SKIP_SETUP: '1' }, timeout: 9 * 60000 }); + expect(f.reload).toHaveBeenCalledWith('0.5.0'); expect(f.release).toHaveBeenCalledOnce(); +}); +it('does not install or restart while terminals are active, and releases the update guard', async () => { + f.prepare.mockRejectedValue(new Error('1 active terminal')); + await expect(updateCommand([])).rejects.toThrow('active terminal'); + expect(f.spawn).not.toHaveBeenCalled(); expect(f.reload).not.toHaveBeenCalled(); + // A retry reaches prepare rather than a stale guard. + await expect(updateCommand([])).rejects.toThrow('active terminal'); +}); +it('leaves the service running on npm failure and releases maintenance', async () => { + f.spawn.mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit('exit', 1)); return child; }); + await expect(updateCommand([])).rejects.toThrow('not restarted'); + expect(f.reload).not.toHaveBeenCalled(); expect(f.release).toHaveBeenCalledOnce(); +}); +it('refuses unsupported install layouts, incompatible Node and failed version queries before npm runs', async () => { + expect(() => npmPrefix('/tmp/workspace/packages/terminal-host')).toThrow('global npm'); + f.check.mockResolvedValue({ updateAvailable: true, latest: '0.5.0', node: '>=999.0.0' }); + await expect(updateCommand([])).rejects.toThrow('requires Node'); + f.check.mockResolvedValue({ error: 'offline' }); await expect(updateCommand([])).rejects.toThrow('offline'); + expect(f.spawn).not.toHaveBeenCalled(); +}); +it('does not downgrade or restart when there is no newer version', async () => { + f.check.mockResolvedValue({ currentVersion: '0.6.0', updateAvailable: false }); + await updateCommand([]); expect(f.prepare).not.toHaveBeenCalled(); expect(f.spawn).not.toHaveBeenCalled(); +}); +it('does not restart after an unexpected installed version and only forwards explicit force', async () => { + f.spawn.mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit('exit', 0)); return child; }); + await expect(updateCommand(['--force'])).rejects.toThrow('expected version'); + expect(f.prepare).toHaveBeenCalledWith(f.directory, true); expect(f.reload).not.toHaveBeenCalled(); +}); diff --git a/packages/terminal-host/src/upgrade.ts b/packages/terminal-host/src/upgrade.ts new file mode 100644 index 0000000..cebe78c --- /dev/null +++ b/packages/terminal-host/src/upgrade.ts @@ -0,0 +1,69 @@ +import { access, mkdir, realpath, rmdir } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import semver from 'semver'; +import { packageDirectory, installedVersion } from './version.js'; +import { outsideTerminal, prepareHost, liveEndpoint } from './management.js'; +import { serviceDirectory, serviceLoaded, reloadService } from './service.js'; +import { Updates } from './updates.js'; + +export function npmPrefix(root: string) { + const prefix = dirname(dirname(dirname(dirname(root)))); + if (join(prefix, 'lib', 'node_modules', '@agentonweb', 'terminal-host') !== root) + throw new Error('aow update requires a global npm installation. Reinstall with the official installer, or update through your original package manager.'); + return prefix; +} +async function install(prefix: string, version: string) { + const npm = join(dirname(process.execPath), 'npm'); + await access(npm, constants.X_OK); + await new Promise((resolve, reject) => { + const child = spawn(npm, ['install', '--global', '--prefix', prefix, '--foreground-scripts', '--prefer-online', + '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org', `@agentonweb/terminal-host@${version}`], { + stdio: 'inherit', env: { ...process.env, AOW_SKIP_SETUP: '1', PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ''}` }, + // Finish before the ten-minute maintenance lease expires. + timeout: 9 * 60 * 1000, + }); + child.once('error', reject); + child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`npm installation failed (${signal ?? code}). The service was not restarted; inspect npm output before retrying.`))); + }); +} +export async function updateCommand(args: string[]) { + if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) { + console.log('Usage: aow update [--force]\nInstall the newer stable npm version into the existing prefix and restart the macOS service. Close terminals first. --force ends their tasks.'); return; + } + if (args.length > 1 || args.length === 1 && args[0] !== '--force') throw new Error('Usage: aow update [--force]'); + outsideTerminal(); + if (process.platform !== 'darwin') throw new Error('aow update currently supports global npm installations on macOS. Update through your package manager on this platform.'); + const root = await realpath(packageDirectory); + const prefix = npmPrefix(root); + await access(root, constants.W_OK); + const updates = new Updates(serviceDirectory); + const status = await updates.check(true); + if (status.error) throw new Error(status.error); + if (!status.updateAvailable) { console.log(`AgentOnWeb ${status.currentVersion} is up to date; no package was changed.`); return; } + if (status.node && !semver.satisfies(process.versions.node, status.node)) throw new Error(`Version ${status.latest} requires Node.js ${status.node}. Update Node and rerun the installer.`); + await mkdir(serviceDirectory, { recursive: true, mode: 0o700 }); + const guard = join(serviceDirectory, 'update.guard'); + try { await mkdir(guard, { mode: 0o700 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`Another update is in progress. If an earlier update was interrupted, remove ${guard} only after confirming it has stopped.`); + throw error; + } + try { + const prepared = await prepareHost(serviceDirectory, args.includes('--force')); + try { + if (!prepared.status && serviceLoaded()) throw new Error('The service is loaded but cannot be checked. No package was changed; repair the service first.'); + console.log(`Updating AgentOnWeb ${status.currentVersion} → ${status.latest} in ${prefix}.`); + await install(prefix, status.latest!); + if (installedVersion() !== status.latest) throw new Error('npm did not install the expected version. The service was not restarted.'); + // Hooks/native registration keep the same absolute paths. Skipping setup + // preserves the selected CLIs and enrolled unpacked extension origins. + if (prepared.status) await reloadService(status.latest!); + else { + if (await liveEndpoint(serviceDirectory)) throw new Error('A host started during installation. Close its terminals, then run aow service restart to activate the installed version.'); + console.log(`Installed ${status.latest}. Run aow setup if you want to configure the service.`); + } + } finally { await prepared.release(); } + } finally { await rmdir(guard); } +} diff --git a/packages/terminal-host/src/version.ts b/packages/terminal-host/src/version.ts new file mode 100644 index 0000000..e317887 --- /dev/null +++ b/packages/terminal-host/src/version.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +export const packageDirectory = fileURLToPath(new URL('../', import.meta.url)); +export function installedVersion(): string { + return (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version; +} +// Capture at process startup: npm can replace package.json while this host lives. +export const VERSION = installedVersion(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32539b2..18064b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + semver: + specifier: ^7.8.5 + version: 7.8.5 ws: specifier: ^8.18.3 version: 8.21.3 @@ -119,6 +122,9 @@ importers: '@agentonweb/terminal-surface': specifier: workspace:* version: link:../../apps/terminal-surface + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -699,6 +705,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -1211,6 +1220,11 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1865,6 +1879,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/semver@7.8.0': {} + '@types/tough-cookie@4.0.5': {} '@types/ws@8.18.1': @@ -2434,6 +2450,8 @@ snapshots: scule@1.3.0: {} + semver@7.8.5: {} + siginfo@2.0.0: {} smol-toml@1.8.0: {} diff --git a/release-contract.json b/release-contract.json index c13ef91..6e34015 100644 --- a/release-contract.json +++ b/release-contract.json @@ -1,7 +1,7 @@ { "extensionVersion": "0.4.0", "pluginVersion": "0.4.0", - "terminalHostVersion": "0.4.0", + "terminalHostVersion": "0.4.1", "dsh": "0.1.7-rc.2", "connectorProtocol": 1, "artifacts": { @@ -9,6 +9,6 @@ "firefoxExtension": "agentonweb-extension-firefox-0.4.0.zip", "extensionSource": "agentonweb-extension-source-0.4.0.zip", "dshPlugin": "agentonweb-dsh-surface-0.4.0.tgz", - "terminalHost": "agentonweb-terminal-host-0.4.0.tgz" + "terminalHost": "agentonweb-terminal-host-0.4.1.tgz" } } diff --git a/scripts/install.sh b/scripts/install.sh index d2935cb..9b93883 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -23,7 +23,7 @@ install_agentonweb() { aow_node_bin="$(dirname "$(command -v node)")" export PATH="$aow_prefix/bin:$PATH" echo 'Installing AgentOnWeb terminal…' - AOW_SKIP_SETUP=1 npm install --global --prefix "$aow_prefix" --foreground-scripts --prefer-online --no-audit --no-fund '@agentonweb/terminal-host@0.4.0'