diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dd3da3..c4cd72d 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: [terminal-v0.4.2, ext-v0.4.1, 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] + options: [terminal-v0.4.3, terminal-v0.4.2, ext-v0.4.1, 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 17e828d..fb75df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Terminal host 0.4.3 — 2026-10-22 + +- Recognize an older running terminal host after the CLI package has been upgraded. Report its missing maintenance interface and allow explicit `aow update --force` or `aow service restart --force` only after confirming the host PID belongs to the macOS service. Preserve the running service if npm installation fails. +- Make `aow update --force` reinstall and restart even when the CLI already matches the stable npm version, while still refusing downgrades. +- Give manual npm update checks a 15-second request timeout and one retry for transient failures. Keep automatic checks at three seconds with existing backoff, and report HTTP failures separately from timeouts. + +### Upgrade + +CLI 0.4.1/0.4.2 cannot recover an older running host through the new force path until the updated CLI is installed. Finish terminal work, run `aow service uninstall`, then rerun the installer to install 0.4.3 and start its host. CLI 0.4.3 can use `aow update --force` to reinstall the stable version and restart a verified older macOS service. Saved history, browser grants and integration settings remain. Extension 0.4.1, DSH 0.4.0 and connector protocol 1 are unchanged. + ## Terminal host 0.4.2 / Extension 0.4.1 — 2026-10-20 - Select terminal text with an ordinary mouse or trackpad drag, including inside TUIs with mouse reporting. Right-click to copy, or use the existing copy shortcut. Preserve app clicks, scrolling, native word/line selection and scrollbar interaction. diff --git a/README.md b/README.md index afde2ae..d288134 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ 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. +Terminal host 0.4.3 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. CLI 0.4.3 also supports `aow update --force` to reinstall and restart a verified older service, ending its live tasks. If an older CLI cannot prepare an update, finish terminal work, run `aow service uninstall`, then rerun the installer once. [Setup and troubleshooting](docs/one-command-setup.md) · [Terminal guide](packages/terminal-host/README.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index e4da6a1..6f633f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -71,7 +71,7 @@ codex 点击终端标签栏的 **+** 打开另一个项目,点击标签切换,双击名称重命名,点击 **×** 关闭。隐藏工作区或刷新网页不会中断正在运行的会话。 -终端主机 0.4.1 每天检查 npm 正式版,并在本地管理页显示更新提醒。运行 `aow check-update` 可立即检查,`aow check-update --disable` 可关闭自动检查。退出或关闭所有终端后,在 Mac 的独立“终端”窗口运行 `aow update` 升级;`aow service restart` 用于加载已安装的新代码。从 0.4.0 或更早版本首次升级时,请先完成终端任务,运行 `aow service uninstall`,再重新运行安装命令。 +终端主机 0.4.3 每天检查 npm 正式版,并在本地管理页显示更新提醒。运行 `aow check-update` 可立即检查,`aow check-update --disable` 可关闭自动检查。退出或关闭所有终端后,在 Mac 的独立“终端”窗口运行 `aow update` 升级;`aow service restart` 用于加载已安装的新代码。CLI 0.4.3 还支持通过 `aow update --force` 重新安装并重启已核实归属的旧服务,这会结束其活动任务。如果旧 CLI 提示无法准备更新,请先完成终端任务,运行 `aow service uninstall`,再重新运行安装命令一次。 [安装与常见问题](docs/one-command-setup.md) · [终端指南](packages/terminal-host/README.md) diff --git a/docs/one-command-setup.md b/docs/one-command-setup.md index 81fffb0..87e241c 100644 --- a/docs/one-command-setup.md +++ b/docs/one-command-setup.md @@ -87,11 +87,11 @@ aow update aow service restart ``` -`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. +`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; `aow update --force` also reinstalls and restarts when the installed CLI is already the current stable version, without downgrading. 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). +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; automatic checks retain cached results and retry with increasing delays. Explicit checks never install from stale metadata after a failed query: they allow 15 seconds per request and retry transient failures once. 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`. +The installed CLI and running host can have different versions. If a newer CLI finds a running host from 0.4.0 or earlier without a maintenance interface, finish terminal work, then use `aow service restart --force` to load installed code or `aow update --force` to upgrade. This recovery requires an updated CLI and verifies that the old host PID belongs to the macOS service; it ends all service terminal tasks because the old host cannot pause or count them. A foreground `aow terminal` must be stopped manually. If the installed CLI lacks recovery support, 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 d669b51..f3dff53 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,16 +1,16 @@ # Release AgentOnWeb -## Terminal copy patch release +## Terminal host update patch release -Terminal host **0.4.2** enables direct mouse/trackpad text selection while TUIs capture the mouse, preserves app clicks and scrolling, and includes the checkbox setup and CLI output improvements merged since 0.4.1. Extension **0.4.1** includes acknowledged mode-click updates and the matching Safari package. Safari uses marketing version **0.4.1**, build **10**. DSH remains **0.4.0** and connector protocol remains **1**. +Terminal host **0.4.3** repairs forced updates when an older host is still running, reinstalls the current stable version on explicit force, and retries transient manual npm checks with longer deadlines. It retains the direct terminal selection improvements from 0.4.2. Extension **0.4.1**, Safari marketing version **0.4.1** / build **10**, DSH **0.4.0** and connector protocol **1** remain unchanged; this release publishes only the terminal host. -The copy fix is bundled in the terminal host. Updating the browser extension alone does not install it. After finishing live shells, update the host with `aow update` from a separate macOS Terminal. The DSH compatibility baseline remains **0.1.7-rc.2**; `dsh-latest` checks the currently published upstream version. +After finishing live shells, update the host with `aow update` from a separate macOS Terminal. If CLI 0.4.1/0.4.2 cannot prepare an update against an older running host, run `aow service uninstall`, then rerun the installer to install and start 0.4.3. The recovery path requires CLI 0.4.3; it cannot change code already running in an older updater. The DSH compatibility baseline remains **0.1.7-rc.2**; `dsh-latest` checks the currently published upstream version. | Artifact | Version field | Delivery | | --- | --- | --- | -| Chrome/Firefox extension ZIPs and source | `extensionVersion` | `ext-v0.4.1`, then store submission | +| Chrome/Firefox extension ZIPs and source | `extensionVersion` | Remains 0.4.1; no publication | | DSH plugin TGZ | `pluginVersion` | Remains 0.4.0; no publication | -| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.2`, npm + GitHub release | +| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.3`, 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,25 +52,25 @@ 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.2 +npm install -g @agentonweb/terminal-host@0.4.3 # 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.2/install.sh` pins the package to 0.4.2 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.3/install.sh` pins the package to 0.4.3 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.2.tgz +AOW_SKIP_SETUP=1 npm install -g ./release/agentonweb-terminal-host-0.4.3.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. 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. +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. An updated CLI can also recover a host without the maintenance interface using `aow update --force` or `aow service restart --force`, after verifying the host PID belongs to the macOS service; this ends all its terminal tasks. Older CLIs 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. diff --git a/packages/terminal-host/README.md b/packages/terminal-host/README.md index 61caa65..8debad4 100644 --- a/packages/terminal-host/README.md +++ b/packages/terminal-host/README.md @@ -66,9 +66,11 @@ 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. +The host checks npm asynchronously after startup, caches successful checks for 24 hours, and retries failed checks with increasing delays. Automatic requests time out after three seconds. Explicit `check-update` and `update` requests allow 15 seconds each and retry a transient network failure, HTTP 429 or server error once. 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. +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`; hosts with update support pause new terminal creation 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. + +Installing a newer CLI does not replace an older running host. A host from 0.4.0 or earlier has no maintenance interface and can return HTTP 401 to a new CLI. After finishing terminal work, an updated CLI can use `aow service restart --force` to reload installed code, or `aow update --force` to upgrade and restart. Explicit `aow update --force` also reinstalls and restarts when the CLI already matches npm's stable version; it never downgrades. The CLI verifies that the older host PID belongs to the macOS service before proceeding and rechecks it before stopping anything. Older hosts cannot pause or count terminals; explicit force ends all their terminal tasks. Foreground hosts must be stopped manually. Authorization errors from hosts with update support are not bypassed. If the installed CLI lacks this recovery support, run `aow service uninstall`, then rerun the installer. 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. diff --git a/packages/terminal-host/package.json b/packages/terminal-host/package.json index 0abd2e5..48e339a 100644 --- a/packages/terminal-host/package.json +++ b/packages/terminal-host/package.json @@ -1,6 +1,6 @@ { "name": "@agentonweb/terminal-host", - "version": "0.4.2", + "version": "0.4.3", "type": "module", "license": "MIT", "description": "Your local interactive shell in AgentOnWeb", diff --git a/packages/terminal-host/src/cli-help.ts b/packages/terminal-host/src/cli-help.ts index 0fbca1d..5c0f3b7 100644 --- a/packages/terminal-host/src/cli-help.ts +++ b/packages/terminal-host/src/cli-help.ts @@ -82,7 +82,7 @@ const topics: Record = { update: { usage: 'Usage: aow update [--force]', summary: 'Install the newer stable npm version into the existing prefix and restart the macOS service. Close terminals first.', - sections: [{ title: 'OPTIONS', rows: [['--force', 'End active terminal tasks before restarting.']] }], + sections: [{ title: 'OPTIONS', rows: [['--force', 'Reinstall even at the current stable version; end active terminal tasks when restarting.']] }], }, terminal: { usage: 'Usage: aow terminal', summary: 'Run the terminal host in the foreground. Keep this process running while using your browser terminal.', diff --git a/packages/terminal-host/src/management.test.ts b/packages/terminal-host/src/management.test.ts index 1a81820..c89a635 100644 --- a/packages/terminal-host/src/management.test.ts +++ b/packages/terminal-host/src/management.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { Maintenance, outsideTerminal } from './management.js'; -afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); }); +import { Maintenance, outsideTerminal, hostRequest } from './management.js'; +afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); 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'); @@ -20,3 +20,18 @@ 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'); }); +it('identifies an older host whose unknown control route returns the browser login response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('Connect through AgentOnWeb, or open the one-time link printed in your host terminal.', { status: 401 }))); + await expect(hostRequest({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch?token=private' })) + .rejects.toMatchObject({ status: 401, legacyPid: 777, message: expect.stringContaining('aow service restart --force') }); +}); +it.each([401, 403])('does not treat authorization failure on an update-capable host as a legacy host: %s', async status => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status }))); + await expect(hostRequest({ pid: 777, version: '0.4.1', managedService: true, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })) + .rejects.toMatchObject({ status, legacyPid: undefined, message: expect.stringContaining(`HTTP ${status}`) }); +}); +it('preserves the specific busy-terminal error from the host', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: '1 active terminal(s). Exit or close them first.' }, { status: 409 }))); + await expect(hostRequest({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' }, { action: 'prepare' })) + .rejects.toThrow('1 active terminal(s)'); +}); diff --git a/packages/terminal-host/src/management.ts b/packages/terminal-host/src/management.ts index 0945a04..9c3a569 100644 --- a/packages/terminal-host/src/management.ts +++ b/packages/terminal-host/src/management.ts @@ -3,7 +3,10 @@ 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 }; +type Endpoint = { pid: number; nativeToken: string; launchUrl: string; version?: string; managedService?: boolean }; +export class HostControlError extends Error { + constructor(message: string, readonly status: number, readonly legacyPid?: number) { super(message); } +} export class Maintenance { private lease: { token: string; expires: number } | undefined; get active() { return !!this.lease && this.lease.expires > Date.now(); } @@ -35,7 +38,11 @@ export async function hostRequest(endpoint: Endpoint, action?: object) { }); 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.'); + const legacy = !action && endpoint.version === undefined && endpoint.managedService === undefined && [401, 404, 405].includes(response.status); + const message = legacy + ? `The running host does not support safe updates (HTTP ${response.status}); its active terminals cannot be checked. Finish terminal work, then run aow service restart --force or aow update --force. This ends all service terminal tasks. For a foreground host, stop aow terminal first.` + : `The running host rejected the control request (HTTP ${response.status}). Run aow service status. If this persists, finish terminal work, then run aow service uninstall followed by aow service install.`; + throw new HostControlError(typeof body?.error === 'string' ? body.error : message, response.status, legacy ? endpoint.pid : undefined); } return response.json(); } @@ -48,7 +55,7 @@ export async function prepareHost(directory: string, force: boolean) { 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') + if (!status || typeof status.version !== 'string' || !Number.isInteger(status.activeTerminals) || status.activeTerminals < 0 || 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 }; diff --git a/packages/terminal-host/src/service.test.ts b/packages/terminal-host/src/service.test.ts index a900ac1..baf0521 100644 --- a/packages/terminal-host/src/service.test.ts +++ b/packages/terminal-host/src/service.test.ts @@ -1,13 +1,15 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -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() })); +const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), rmdir: vi.fn(), writeFile: vi.fn(async (..._args: unknown[]) => {}), readFile: vi.fn(), execFileSync: vi.fn<(...args: any[]) => string>(() => '') })); +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir, rmdir: mocks.rmdir, writeFile: mocks.writeFile, readFile: mocks.readFile, unlink: vi.fn() })); vi.mock('node:child_process', () => ({ execFileSync: mocks.execFileSync })); -import { serviceCommand } from './service.js'; +import { prepareService, serviceCommand } from './service.js'; import { VERSION } from './version.js'; beforeEach(() => { vi.clearAllMocks(); mocks.readFile.mockReset(); mocks.execFileSync.mockReset().mockReturnValue(''); + mocks.mkdir.mockReset().mockResolvedValue(undefined); + mocks.rmdir.mockReset().mockResolvedValue(undefined); vi.stubGlobal('process', { ...process, platform: 'darwin', getuid: () => 501, kill: vi.fn() }); }); afterEach(() => { @@ -94,3 +96,86 @@ it('reports readiness failure instead of claiming a successful restart', async ( const result = expect(serviceCommand(['restart'])).rejects.toThrow('did not confirm version'); await vi.runAllTimersAsync(); await result; }); + +it('refuses an older host without explicit force and explains how to recover', async () => { + mocks.readFile.mockResolvedValue(JSON.stringify({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })); + vi.stubGlobal('fetch', vi.fn(async () => new Response('Connect through AgentOnWeb', { status: 401 }))); + await expect(serviceCommand(['restart'])).rejects.toThrow('aow service restart --force'); + expect(mocks.execFileSync).not.toHaveBeenCalled(); + expect(mocks.writeFile).not.toHaveBeenCalled(); +}); + +it('force-restarts an older host only when launchctl confirms it is the managed service', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + let bootedOut = false, bootstrapped = false; + mocks.readFile.mockImplementation(async () => JSON.stringify({ pid: bootstrapped ? 888 : 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })); + 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 '\tpid = ' + (bootstrapped ? '888' : '777') + '\n'; + }); + 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 () => bootstrapped + ? Response.json({ version: VERSION, activeTerminals: 0, managedService: true }) + : new Response('Connect through AgentOnWeb', { status: 401 }))); + await serviceCommand(['restart', '--force']); + expect(bootedOut).toBe(true); + expect(bootstrapped).toBe(true); +}); + +it.each([['401', '999'], ['403', '777']])('does not force-stop a mismatched service or an authorization failure: %s / %s', async (status, pid) => { + mocks.readFile.mockResolvedValue(JSON.stringify({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })); + mocks.execFileSync.mockReturnValue('\tpid = ' + pid + '\n'); + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: Number(status) }))); + await expect(serviceCommand(['restart', '--force'])).rejects.toThrow(); + expect(mocks.execFileSync.mock.calls.some(([, args]) => args[0] === 'bootout')).toBe(false); + expect(mocks.writeFile).not.toHaveBeenCalled(); +}); + +it('refuses to stop a replacement host that appears before the legacy restart', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const endpoint = (pid: number) => JSON.stringify({ pid, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' }); + mocks.readFile.mockResolvedValueOnce(endpoint(777)).mockResolvedValueOnce(endpoint(777)).mockResolvedValue(endpoint(888)); + mocks.execFileSync.mockReturnValue('\tpid = 777\n'); + vi.stubGlobal('fetch', vi.fn(async () => new Response('Connect through AgentOnWeb', { status: 401 }))); + await expect(serviceCommand(['restart', '--force'])).rejects.toThrow('changed before restart'); + expect(mocks.execFileSync.mock.calls.some(([, args]) => args[0] === 'bootout')).toBe(false); + expect(mocks.rmdir).toHaveBeenCalledOnce(); +}); + +it('serializes legacy updates and restarts until the guard is released', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + mocks.readFile.mockResolvedValue(JSON.stringify({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })); + mocks.execFileSync.mockReturnValue('\tpid = 777\n'); + vi.stubGlobal('fetch', vi.fn(async () => new Response('Connect through AgentOnWeb', { status: 401 }))); + let busy = false; + mocks.mkdir.mockImplementation(async () => { + if (busy) throw Object.assign(new Error('exists'), { code: 'EEXIST' }); + busy = true; + }); + mocks.rmdir.mockImplementation(async () => { busy = false; }); + const prepared = await prepareService(true); + try { await expect(prepareService(true)).rejects.toThrow('in progress'); } + finally { await prepared.release(); } + const retry = await prepareService(true); + await retry.release(); + expect(mocks.execFileSync.mock.calls.some(([, args]) => args[0] === 'bootout')).toBe(false); +}); + +it('does not start a replacement if the old process has not fully exited', async () => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + mocks.readFile.mockResolvedValue(JSON.stringify({ pid: 777, nativeToken: 'test', launchUrl: 'http://localhost:1234/launch' })); + mocks.execFileSync.mockReturnValue('\tpid = 777\n'); + vi.stubGlobal('fetch', vi.fn(async () => new Response('Connect through AgentOnWeb', { status: 401 }))); + const result = expect(serviceCommand(['restart', '--force'])).rejects.toThrow('previous host did not stop'); + await vi.runAllTimersAsync(); + await result; + expect(mocks.execFileSync.mock.calls.some(([, args]) => args[0] === 'bootout')).toBe(true); + expect(mocks.execFileSync.mock.calls.some(([, args]) => args[0] === 'bootstrap')).toBe(false); + expect(mocks.writeFile).not.toHaveBeenCalled(); + expect(mocks.rmdir).toHaveBeenCalledOnce(); +}); diff --git a/packages/terminal-host/src/service.ts b/packages/terminal-host/src/service.ts index 052070a..0bfb9b4 100644 --- a/packages/terminal-host/src/service.ts +++ b/packages/terminal-host/src/service.ts @@ -1,9 +1,9 @@ -import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, unlink, rmdir } from 'node:fs/promises'; 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 { HostControlError, hostRequest, liveEndpoint, prepareHost } from './management.js'; import { VERSION } from './version.js'; import { formatHelp } from './cli-help.js'; import { printStatus } from './cli-ui.js'; @@ -59,11 +59,11 @@ export async function serviceCommand(args: string[]): Promise { const status = await hostRequest(endpoint); printStatus('info', `Running host: ${status.version} · ${status.activeTerminals} active terminal(s).`); if (status.version !== VERSION) printStatus('warn', 'Installed version differs from the running host. Run aow service restart after closing terminals.'); - } catch { printStatus('warn', 'Running host version unavailable. Finish terminal work and restart the older host manually.'); } + } catch (error) { printStatus('warn', `Running host version unavailable. ${(error as Error).message}`); } } } else if (action === 'restart') { - const prepared = await prepareHost(serviceDirectory, args.includes('--force')); - try { await reloadService(VERSION); } + const prepared = await prepareService(args.includes('--force')); + try { await reloadService(VERSION, prepared.legacyPid); } finally { await prepared.release(); } } else if (action === 'uninstall') { try { execFileSync('/bin/launchctl', ['bootout', domain + '/' + label], { stdio: 'pipe' }); } catch {} @@ -75,9 +75,39 @@ 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) { +function managedServicePid(): number | undefined { + try { + const output = execFileSync('/bin/launchctl', ['print', `gui/${process.getuid!()}/${label}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const pid = Number(/^\s*pid = ([1-9]\d*)\s*$/mu.exec(output)?.[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; + } catch { return undefined; } +} +export async function prepareService(force: boolean): Promise> & { legacyPid?: number }> { + try { return await prepareHost(serviceDirectory, force); } + catch (error) { + if (!force || !(error instanceof HostControlError) || !error.legacyPid) throw error; + const pid = error.legacyPid; + if (managedServicePid() !== pid) throw new Error('The older host is not the managed service. Stop aow terminal before updating or restarting; no service was stopped.'); + const guard = join(serviceDirectory, 'legacy-maintenance.guard'); + try { await mkdir(guard, { mode: 0o700 }); } + catch (failure) { + if ((failure as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Another update or restart is in progress. If an earlier legacy update was interrupted, remove the legacy-maintenance.guard directory only after confirming it has stopped.'); + throw failure; + } + try { + const endpoint = await liveEndpoint(serviceDirectory); + if (endpoint?.pid !== pid || endpoint.version !== undefined || endpoint.managedService !== undefined || managedServicePid() !== pid) + throw new Error('The running host changed during preparation. Retry after checking aow service status; no service was stopped.'); + printStatus('warn', 'The older service cannot pause or count terminals. --force will end all its terminal tasks when it restarts.'); + return { status: undefined, legacyPid: pid, release: async () => { await rmdir(guard); } }; + } catch (failure) { await rmdir(guard); throw failure; } + } +} +/** Caller holds the host's maintenance lease or the verified legacy-service guard. */ +export async function reloadService(expectedVersion: string, legacyPid?: number) { const old = await liveEndpoint(serviceDirectory); + if (legacyPid !== undefined && (old?.pid !== legacyPid || managedServicePid() !== legacyPid)) + throw new Error('The older service changed before restart. No service was stopped; run aow service status before retrying.'); 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 }); diff --git a/packages/terminal-host/src/updates.test.ts b/packages/terminal-host/src/updates.test.ts index 4b0a634..70c4dd1 100644 --- a/packages/terminal-host/src/updates.test.ts +++ b/packages/terminal-host/src/updates.test.ts @@ -9,7 +9,7 @@ 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 }); }); +afterEach(async () => { vi.unstubAllEnvs(); vi.restoreAllMocks(); 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; @@ -38,7 +38,7 @@ it('keeps the last successful result on network failure and backs off across res 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); + await f.updates.check(); expect(f.request).toHaveBeenCalledTimes(3); f.advance(15 * 60000); expect(await f.updates.check()).toMatchObject({ failures: 2, nextCheck: 100000 + 45 * 60000 }); }); @@ -70,3 +70,46 @@ it('coalesces checks and stops background timers', async () => { const stop = f.updates.start(); expect(vi.getTimerCount()).toBe(2); stop(); expect(vi.getTimerCount()).toBe(0); }); +it('retries a transient timeout for a manual check with a longer request deadline', async () => { + const request = vi.fn() + .mockRejectedValueOnce(new DOMException('timed out', 'TimeoutError')) + .mockResolvedValueOnce(Response.json({ name: '@agentonweb/terminal-host', version: '0.5.0' })); + const deadline = vi.spyOn(AbortSignal, 'timeout'); + const updates = new Updates(directory, { fetch: request, version: '0.4.1' }); + expect(await updates.check(true)).toMatchObject({ latest: '0.5.0', updateAvailable: true, failures: 0 }); + expect(request).toHaveBeenCalledTimes(2); + expect(deadline).toHaveBeenCalledWith(15000); + deadline.mockRestore(); +}); +it('does not retry automatic checks or invalid stable metadata', async () => { + const request = vi.fn().mockRejectedValue(new Error('offline')); + const updates = new Updates(directory, { fetch: request }); + expect(await updates.check()).toHaveProperty('error'); + expect(request).toHaveBeenCalledOnce(); + request.mockClear().mockResolvedValue(Response.json({ name: '@agentonweb/terminal-host', version: '0.5.0-beta.1' })); + expect(await updates.check(true)).toHaveProperty('error'); + expect(request).toHaveBeenCalledOnce(); +}); +it.each([429, 503])('retries a temporary registry HTTP failure: %s', async status => { + const request = vi.fn().mockResolvedValueOnce(new Response('', { status })) + .mockResolvedValueOnce(Response.json({ name: '@agentonweb/terminal-host', version: '0.5.0' })); + expect(await new Updates(directory, { fetch: request, version: '0.4.1' }).check(true)).toMatchObject({ latest: '0.5.0', failures: 0 }); + expect(request).toHaveBeenCalledTimes(2); +}); +it('bounds manual retries and keeps a safe failure reason across processes', async () => { + const request = vi.fn().mockRejectedValue(new DOMException('private request details', 'TimeoutError')); + const updates = new Updates(directory, { fetch: request }); + const status = await updates.check(true); + expect(status.error).toContain('request timed out'); + expect(request).toHaveBeenCalledTimes(2); + expect((await new Updates(directory).status()).error).toBe(status.error); + expect(await readFile(join(directory, 'update-cache.json'), 'utf8')).not.toContain('private request details'); +}); +it('retains cached metadata alongside a permanent HTTP failure without retrying', async () => { + const request = vi.fn().mockResolvedValueOnce(Response.json({ name: '@agentonweb/terminal-host', version: '0.5.0' })); + const updates = new Updates(directory, { fetch: request, version: '0.4.1' }); + await updates.check(true); + request.mockClear().mockResolvedValue(new Response('', { status: 404 })); + expect(await updates.check(true)).toMatchObject({ latest: '0.5.0', error: expect.stringContaining('HTTP 404'), failures: 1 }); + expect(request).toHaveBeenCalledOnce(); +}); diff --git a/packages/terminal-host/src/updates.ts b/packages/terminal-host/src/updates.ts index 0ba0b76..8d6552a 100644 --- a/packages/terminal-host/src/updates.ts +++ b/packages/terminal-host/src/updates.ts @@ -8,6 +8,9 @@ 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 }; +class RegistryError extends Error { + constructor(message: string, readonly retryable = false) { super(message); } +} async function readJson(path: string): Promise { try { return JSON.parse(await readFile(path, 'utf8')); } @@ -29,7 +32,8 @@ function cacheValue(value: unknown): Cache { 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.'; + if (typeof v.error === 'string') result.error = /^Could not check npm for updates \((?:HTTP \d{3}|invalid npm metadata|request timed out|network request failed)\)\. Try aow check-update later\.$/u.test(v.error) + ? v.error : 'Could not check npm for updates. Try aow check-update later.'; return result; } @@ -55,28 +59,39 @@ export class Updates { this.pending = this.query(manual).finally(() => { this.pending = undefined; }); return this.pending; } + private async metadata(manual: boolean): Promise<{ latest: string; node?: string }> { + for (let attempt = 0; ; attempt++) { + try { + const response = await (this.options.fetch ?? fetch)(registry, { + signal: AbortSignal.timeout(manual ? 15000 : 3000), redirect: 'error', headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new RegistryError(`HTTP ${response.status}`, response.status === 429 || response.status >= 500); + let metadata: { name?: string; version?: string; engines?: { node?: string } } | null; + try { metadata = await response.json(); } + catch (error) { if (error instanceof SyntaxError) throw new RegistryError('invalid npm metadata'); throw error; } + if (metadata?.name !== '@agentonweb/terminal-host' || typeof metadata.version !== 'string' + || !semver.valid(metadata.version) || semver.prerelease(metadata.version) + || metadata.engines?.node !== undefined && !semver.validRange(metadata.engines.node)) throw new RegistryError('invalid npm metadata'); + return { latest: metadata.version, ...(metadata.engines?.node ? { node: metadata.engines.node } : {}) }; + } catch (error) { + if (!manual || attempt >= 1 || error instanceof RegistryError && !error.retryable) throw error; + } + } + } 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 { + cache = { ...await this.metadata(manual), checkedAt: now, nextCheck: now + DAY, failures: 0 }; + } catch (error) { const failures = Math.min((status.failures ?? 0) + 1, 8); + const reason = error instanceof RegistryError ? error.message : (error as Error)?.name === 'TimeoutError' ? 'request timed out' : 'network request failed'; 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.' }; + error: `Could not check npm for updates (${reason}). Try aow check-update later.` }; } await mkdir(this.directory, { recursive: true, mode: 0o700 }); await saveJson(join(this.directory, 'update-cache.json'), cache); diff --git a/packages/terminal-host/src/upgrade.test.ts b/packages/terminal-host/src/upgrade.test.ts index 6383676..21a0bb4 100644 --- a/packages/terminal-host/src/upgrade.test.ts +++ b/packages/terminal-host/src/upgrade.test.ts @@ -7,8 +7,8 @@ const f = vi.hoisted(() => ({ directory: '', root: '/tmp/aow prefix/lib/node_mod 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('./management.js', () => ({ outsideTerminal: vi.fn(), liveEndpoint: vi.fn(async () => undefined) })); +vi.mock('./service.js', () => ({ get serviceDirectory() { return f.directory; }, prepareService: f.prepare, 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 () => {}) })); @@ -31,7 +31,7 @@ it('installs the exact checked version in the original prefix, skips setup and v 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(); + expect(f.reload).toHaveBeenCalledWith('0.5.0', undefined); 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')); @@ -56,8 +56,31 @@ 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('reinstalls the current stable version and restarts when force is explicit, without downgrading', async () => { + f.check.mockResolvedValue({ currentVersion: '0.4.0', latest: '0.4.0', updateAvailable: false }); + f.spawn.mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit('exit', 0)); return child; }); + await updateCommand(['--force']); + expect(f.spawn.mock.calls[0]?.[1]).toContain('@agentonweb/terminal-host@0.4.0'); + expect(f.reload).toHaveBeenCalledWith('0.4.0', undefined); + f.spawn.mockClear(); f.reload.mockClear(); + f.check.mockResolvedValue({ currentVersion: '0.4.0', latest: '0.3.0', updateAvailable: false }); + await updateCommand(['--force']); + expect(f.spawn).not.toHaveBeenCalled(); + expect(f.reload).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(); + expect(f.prepare).toHaveBeenCalledWith(true); expect(f.reload).not.toHaveBeenCalled(); +}); +it('installs before restarting a verified legacy service and preserves it on npm failure', async () => { + f.prepare.mockResolvedValue({ status: undefined, legacyPid: 777, release: f.release }); + await updateCommand(['--force']); + expect(f.reload).toHaveBeenCalledWith('0.5.0', 777); + expect(f.release).toHaveBeenCalledOnce(); + f.reload.mockClear(); f.release.mockClear(); + f.spawn.mockImplementation(() => { const child = new EventEmitter(); queueMicrotask(() => child.emit('exit', 1)); return child; }); + await expect(updateCommand(['--force'])).rejects.toThrow('not restarted'); + expect(f.reload).not.toHaveBeenCalled(); + expect(f.release).toHaveBeenCalledOnce(); }); diff --git a/packages/terminal-host/src/upgrade.ts b/packages/terminal-host/src/upgrade.ts index ef7f8ca..fd9157a 100644 --- a/packages/terminal-host/src/upgrade.ts +++ b/packages/terminal-host/src/upgrade.ts @@ -4,8 +4,8 @@ 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 { outsideTerminal, liveEndpoint } from './management.js'; +import { serviceDirectory, serviceLoaded, prepareService, reloadService } from './service.js'; import { Updates } from './updates.js'; import { formatHelp } from './cli-help.js'; import { printStatus } from './cli-ui.js'; @@ -43,7 +43,9 @@ export async function updateCommand(args: string[]) { const updates = new Updates(serviceDirectory); const status = await updates.check(true); if (status.error) throw new Error(status.error); - if (!status.updateAvailable) { printStatus('ok', `AgentOnWeb ${status.currentVersion} is up to date; no package was changed.`); return; } + const force = args.includes('--force'); + const reinstall = force && status.latest === status.currentVersion; + if (!status.updateAvailable && !reinstall) { printStatus('ok', `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'); @@ -53,15 +55,15 @@ export async function updateCommand(args: string[]) { throw error; } try { - const prepared = await prepareHost(serviceDirectory, args.includes('--force')); + const prepared = await prepareService(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.'); - printStatus('info', `Updating AgentOnWeb ${status.currentVersion} → ${status.latest} in ${prefix}.`); + if (!prepared.status && !prepared.legacyPid && serviceLoaded()) throw new Error('The service is loaded but cannot be checked. No package was changed; repair the service first.'); + printStatus('info', reinstall ? `Reinstalling AgentOnWeb ${status.latest} in ${prefix}.` : `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!); + if (prepared.status || prepared.legacyPid) await reloadService(status.latest!, prepared.legacyPid); 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.'); printStatus('ok', `Installed ${status.latest}. Run aow setup if you want to configure the service.`); diff --git a/release-contract.json b/release-contract.json index 9ae778e..6841745 100644 --- a/release-contract.json +++ b/release-contract.json @@ -1,7 +1,7 @@ { "extensionVersion": "0.4.1", "pluginVersion": "0.4.0", - "terminalHostVersion": "0.4.2", + "terminalHostVersion": "0.4.3", "dsh": "0.1.7-rc.2", "connectorProtocol": 1, "artifacts": { @@ -9,6 +9,6 @@ "firefoxExtension": "agentonweb-extension-firefox-0.4.1.zip", "extensionSource": "agentonweb-extension-source-0.4.1.zip", "dshPlugin": "agentonweb-dsh-surface-0.4.0.tgz", - "terminalHost": "agentonweb-terminal-host-0.4.2.tgz" + "terminalHost": "agentonweb-terminal-host-0.4.3.tgz" } } diff --git a/scripts/install.sh b/scripts/install.sh index 2f9a8ba..595fa0a 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.2'