Add auto-update check and CLI update command#29
Conversation
Introduces an update-checker utility that checks for new versions of agent-relay via the npm registry, with caching to limit API calls. The CLI now checks for updates in the background for interactive commands and adds a new 'update' command to check for and install updates. This improves user awareness of new releases and streamlines the update process.
There was a problem hiding this comment.
Pull request overview
This PR introduces an auto-update notification system for agent-relay that checks the npm registry for new versions. It adds a new update-checker utility with caching to limit API calls to once per hour, integrates background update checks into the CLI for interactive commands, and provides a new update command for users to check for and install updates.
Key Changes:
- New update-checker utility that fetches version info from npm registry with 1-hour caching
- Background update notifications for interactive CLI commands (up, down, status, etc.)
- New
agent-relay updatecommand with--checkflag for checking/installing updates
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/utils/update-checker.ts | New utility implementing version checking, npm registry fetching, caching, and notification formatting |
| src/cli/index.ts | Integrates background update checks for interactive commands and adds new 'update' command |
| package-lock.json | Version bump from 1.0.8 to 1.0.10 |
| .beads/issues.jsonl | Marks the auto-update notification feature issue as closed |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| const { stdout, stderr } = await execAsync('npm install -g agent-relay@latest'); | ||
| if (stdout) console.log(stdout); | ||
| if (stderr) console.error(stderr); |
There was a problem hiding this comment.
The npm stderr output is printed using console.error even when the installation might succeed. npm often writes progress information and warnings to stderr that aren't actual errors. This could confuse users who see error output despite a successful update. Consider only displaying stderr if the command fails, or differentiate between stderr that indicates an error versus informational output.
| if (stderr) console.error(stderr); | |
| if (stderr) console.log(stderr); |
| console.error(''); | ||
| console.error(`╭─────────────────────────────────────────────────────╮`); | ||
| console.error(`│ Update available: ${info.currentVersion} → ${info.latestVersion.padEnd(10)} │`); | ||
| console.error(`│ Run: npm install -g agent-relay │`); |
There was a problem hiding this comment.
The notification formatting uses hardcoded padding that will cause misalignment when version numbers vary in length. The padEnd(10) assumes a fixed width, but version strings like "1.0.10" vs "10.20.301" have different lengths, causing the trailing whitespace to not align properly with the box border. Consider calculating the required padding dynamically based on both version strings or using a fixed-width format.
| console.error(''); | |
| console.error(`╭─────────────────────────────────────────────────────╮`); | |
| console.error(`│ Update available: ${info.currentVersion} → ${info.latestVersion.padEnd(10)} │`); | |
| console.error(`│ Run: npm install -g agent-relay │`); | |
| const border = `╭─────────────────────────────────────────────────────╮`; | |
| const contentWidth = border.length - 2; | |
| const updateLine = ` Update available: ${info.currentVersion} → ${info.latestVersion}`; | |
| const runLine = ` Run: npm install -g agent-relay`; | |
| console.error(''); | |
| console.error(border); | |
| console.error(`│${updateLine.padEnd(contentWidth, ' ')}│`); | |
| console.error(`│${runLine.padEnd(contentWidth, ' ')}│`); |
| // Follow redirect | ||
| const location = res.headers.location; | ||
| if (location) { | ||
| https.get(location, { timeout: 5000 }, (redirectRes) => { |
There was a problem hiding this comment.
The redirect handling doesn't handle http redirects properly. If the npm registry redirects from https to http (or vice versa), the code always uses https.get for the redirect regardless of the redirect URL scheme. This could cause the redirect to fail. Consider checking the protocol of the location URL and using the appropriate module (http or https).
| https.get(location, { timeout: 5000 }, (redirectRes) => { | |
| const locationUrl = new URL(location, NPM_REGISTRY_URL); | |
| const client = locationUrl.protocol === 'http:' ? http : https; | |
| client.get(locationUrl, { timeout: 5000 }, (redirectRes) => { |
| https.get(location, { timeout: 5000 }, (redirectRes) => { | ||
| handleResponse(redirectRes, resolve, reject); | ||
| }).on('error', reject); |
There was a problem hiding this comment.
The redirect handling doesn't set up timeout handling for the redirected request. While the initial request has timeout: 5000 and a timeout handler, the redirect request at line 60 only has the timeout option but the redirect request doesn't inherit the timeout event handler from the parent, which means the redirected request could hang indefinitely if the server doesn't respond. Add a timeout event handler to the redirected request as well.
| https.get(location, { timeout: 5000 }, (redirectRes) => { | |
| handleResponse(redirectRes, resolve, reject); | |
| }).on('error', reject); | |
| const redirectReq = https.get(location, { timeout: 5000 }, (redirectRes) => { | |
| handleResponse(redirectRes, resolve, reject); | |
| }); | |
| redirectReq.on('error', reject); | |
| redirectReq.on('timeout', () => { | |
| redirectReq.destroy(); | |
| reject(new Error('Request timed out')); | |
| }); |
| /** | ||
| * Auto-update checker for agent-relay | ||
| * | ||
| * Checks npm registry for newer versions and notifies users. | ||
| * Caches results to avoid excessive API calls (checks at most once per hour). | ||
| */ | ||
|
|
||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import https from 'node:https'; | ||
| import http from 'node:http'; | ||
| import os from 'node:os'; | ||
|
|
||
| const PACKAGE_NAME = 'agent-relay'; | ||
| const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour | ||
| const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`; | ||
|
|
||
| interface UpdateCache { | ||
| lastCheck: number; | ||
| latestVersion: string | null; | ||
| error?: string; | ||
| } | ||
|
|
||
| function getCachePath(): string { | ||
| const cacheDir = path.join(os.homedir(), '.agent-relay'); | ||
| return path.join(cacheDir, 'update-cache.json'); | ||
| } | ||
|
|
||
| function readCache(): UpdateCache | null { | ||
| try { | ||
| const cachePath = getCachePath(); | ||
| if (!fs.existsSync(cachePath)) return null; | ||
| const data = fs.readFileSync(cachePath, 'utf-8'); | ||
| return JSON.parse(data); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function writeCache(cache: UpdateCache): void { | ||
| try { | ||
| const cachePath = getCachePath(); | ||
| const cacheDir = path.dirname(cachePath); | ||
| if (!fs.existsSync(cacheDir)) { | ||
| fs.mkdirSync(cacheDir, { recursive: true }); | ||
| } | ||
| fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2)); | ||
| } catch { | ||
| // Silently ignore cache write errors | ||
| } | ||
| } | ||
|
|
||
| function fetchLatestVersion(): Promise<string> { | ||
| return new Promise((resolve, reject) => { | ||
| const req = https.get(NPM_REGISTRY_URL, { timeout: 5000 }, (res) => { | ||
| if (res.statusCode === 301 || res.statusCode === 302) { | ||
| // Follow redirect | ||
| const location = res.headers.location; | ||
| if (location) { | ||
| https.get(location, { timeout: 5000 }, (redirectRes) => { | ||
| handleResponse(redirectRes, resolve, reject); | ||
| }).on('error', reject); | ||
| return; | ||
| } | ||
| } | ||
| handleResponse(res, resolve, reject); | ||
| }); | ||
|
|
||
| req.on('error', reject); | ||
| req.on('timeout', () => { | ||
| req.destroy(); | ||
| reject(new Error('Request timed out')); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function handleResponse( | ||
| res: http.IncomingMessage, | ||
| resolve: (version: string) => void, | ||
| reject: (err: Error) => void | ||
| ): void { | ||
| if (res.statusCode !== 200) { | ||
| reject(new Error(`HTTP ${res.statusCode}`)); | ||
| return; | ||
| } | ||
|
|
||
| let data = ''; | ||
| res.on('data', (chunk: Buffer) => { data += chunk.toString(); }); | ||
| res.on('end', () => { | ||
| try { | ||
| const json = JSON.parse(data); | ||
| if (json.version) { | ||
| resolve(json.version); | ||
| } else { | ||
| reject(new Error('No version in response')); | ||
| } | ||
| } catch (err) { | ||
| reject(err as Error); | ||
| } | ||
| }); | ||
| res.on('error', reject); | ||
| } | ||
|
|
||
| /** | ||
| * Compare semver versions | ||
| * Returns: 1 if a > b, -1 if a < b, 0 if equal | ||
| */ | ||
| function compareVersions(a: string, b: string): number { | ||
| const partsA = a.replace(/^v/, '').split('.').map(Number); | ||
| const partsB = b.replace(/^v/, '').split('.').map(Number); | ||
|
|
||
| for (let i = 0; i < 3; i++) { | ||
| const numA = partsA[i] || 0; | ||
| const numB = partsB[i] || 0; | ||
| if (numA > numB) return 1; | ||
| if (numA < numB) return -1; | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| export interface UpdateInfo { | ||
| updateAvailable: boolean; | ||
| currentVersion: string; | ||
| latestVersion: string | null; | ||
| error?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Check for updates (uses cache to avoid excessive API calls) | ||
| */ | ||
| export async function checkForUpdates(currentVersion: string): Promise<UpdateInfo> { | ||
| const cache = readCache(); | ||
| const now = Date.now(); | ||
|
|
||
| // Return cached result if still valid | ||
| if (cache && (now - cache.lastCheck) < CHECK_INTERVAL_MS) { | ||
| const updateAvailable = cache.latestVersion | ||
| ? compareVersions(cache.latestVersion, currentVersion) > 0 | ||
| : false; | ||
| return { | ||
| updateAvailable, | ||
| currentVersion, | ||
| latestVersion: cache.latestVersion, | ||
| error: cache.error, | ||
| }; | ||
| } | ||
|
|
||
| // Fetch latest version from npm | ||
| try { | ||
| const latestVersion = await fetchLatestVersion(); | ||
| const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; | ||
|
|
||
| writeCache({ | ||
| lastCheck: now, | ||
| latestVersion, | ||
| }); | ||
|
|
||
| return { | ||
| updateAvailable, | ||
| currentVersion, | ||
| latestVersion, | ||
| }; | ||
| } catch (err) { | ||
| const error = (err as Error).message; | ||
|
|
||
| // Cache the error to avoid repeated failed requests | ||
| writeCache({ | ||
| lastCheck: now, | ||
| latestVersion: cache?.latestVersion || null, | ||
| error, | ||
| }); | ||
|
|
||
| return { | ||
| updateAvailable: false, | ||
| currentVersion, | ||
| latestVersion: cache?.latestVersion || null, | ||
| error, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Print update notification to stderr (non-blocking) | ||
| */ | ||
| export function printUpdateNotification(info: UpdateInfo): void { | ||
| if (!info.updateAvailable || !info.latestVersion) return; | ||
|
|
||
| console.error(''); | ||
| console.error(`╭─────────────────────────────────────────────────────╮`); | ||
| console.error(`│ Update available: ${info.currentVersion} → ${info.latestVersion.padEnd(10)} │`); | ||
| console.error(`│ Run: npm install -g agent-relay │`); | ||
| console.error(`╰─────────────────────────────────────────────────────╯`); | ||
| console.error(''); | ||
| } | ||
|
|
||
| /** | ||
| * Check for updates in the background and print notification if available. | ||
| * This is non-blocking and errors are silently ignored. | ||
| */ | ||
| export function checkForUpdatesInBackground(currentVersion: string): void { | ||
| // Run async check without awaiting | ||
| checkForUpdates(currentVersion) | ||
| .then(info => { | ||
| if (info.updateAvailable) { | ||
| printUpdateNotification(info); | ||
| } | ||
| }) | ||
| .catch(() => { | ||
| // Silently ignore errors | ||
| }); | ||
| } |
There was a problem hiding this comment.
The update-checker module lacks test coverage. Given that other utility files in the same directory (name-generator.test.ts, agent-config.test.ts, project-namespace.test.ts) have comprehensive test coverage, this new module should also include tests for version comparison, caching logic, error handling, and the update check flow.
Refactored the update notification to calculate box width based on content length, ensuring proper alignment regardless of version string length.
Replaces the custom semver comparison logic in update-checker.ts with the compare-versions library for improved reliability. Adds compare-versions as a dependency and updates related tests to verify version comparison and notification logic.
template-resolver.ts: shell-escape interpolated variables (CRITICAL #1) broker_tests.rs: uncomment and wire up 5 real tests (CRITICAL #2) worker_tests.rs: uncomment and wire up 5 real tests (CRITICAL #3) worker.rs: log bypass-flag injection, add .. path traversal rejection (CRITICAL #4, #7) verification.ts: export stripInjectedTaskEcho, add path traversal guard (CRITICAL #5) runner.ts: remove duplicate stripInjectedTaskEcho, add ENV_ALLOWLIST filtering (HIGH #17) channel-messenger.ts: add secret scrubbing, hoist regex constants (MEDIUM #27, #28) process-spawner.ts: add settled guard for race condition (MEDIUM #23) step-executor.ts: add sideEffects to callback type, deprecate alias (HIGH #15, #16) index.ts: export StepExecutor directly (MEDIUM #29) workflows/refactor/*.ts: replace hardcoded paths, remove --no-verify (HIGH #8-11) broker.rs: move is_pid_alive to canonical location (HIGH #14) cost/tracker.ts: add restrictive file permissions (MEDIUM #30) cost/pricing.ts: add last-verified date (MEDIUM #31) verification.test.ts: 9 new tests for exported helpers (MEDIUM #32) Co-Authored-By: My Senior Dev <dev@myseniordev.com>
…#675) * refactor: TDD decomposition of runner.ts + main.rs with extracted modules Extracted 5 modules from runner.ts (6,878 lines): - verification.ts (143 lines) - template-resolver.ts (87 lines) - channel-messenger.ts (151 lines) - step-executor.ts (571 lines) - process-spawner.ts (96 lines) Added characterization tests for all extracted modules. Extracted broker.rs and worker.rs from main.rs. Bug fixes: - Restore stripInjectedTaskEcho in verification.ts - Guard agent.release() against broker 400 race condition - Fix run-summary-table test for new table format - Export normalizeModel for correct pricing resolution - Fix --wave argument parsing in run-refactor.ts - ESM imports in all workflow files * fix: address 10 review finding(s) tracker.ts: resolveModel now uses normalizeModel for alias resolution (pre-existing fix verified) run-refactor.ts: --wave parsing with proper validation (pre-existing fix verified) step-executor.ts: signal-killed processes now correctly treated as failures channel-messenger.ts: replaced ReDoS-vulnerable regex with iterative indexOf stripping runner.ts: eliminated shell injection by using direct git spawn with argument arrays process-spawner.ts: fixed SIGKILL fallback timer leak by storing and clearing reference Co-Authored-By: My Senior Dev <dev@myseniordev.com> * Revert "chore: gitignore .trajectories/ (automated run artifacts) (#676)" (#677) This reverts commit 07a8dc0. * refactor: TDD decomposition of runner.ts + main.rs with extracted modules Extracted 5 modules from runner.ts (6,878 lines): - verification.ts (143 lines) - template-resolver.ts (87 lines) - channel-messenger.ts (151 lines) - step-executor.ts (571 lines) - process-spawner.ts (96 lines) Added characterization tests for all extracted modules. Extracted broker.rs and worker.rs from main.rs. Bug fixes: - Restore stripInjectedTaskEcho in verification.ts - Guard agent.release() against broker 400 race condition - Fix run-summary-table test for new table format - Export normalizeModel for correct pricing resolution - Fix --wave argument parsing in run-refactor.ts - ESM imports in all workflow files * trajectories correction again * pre commit is executable * remove tracked workflows * fix: address 36 review findings across Rust and TypeScript modules template-resolver.ts: shell-escape interpolated variables (CRITICAL #1) broker_tests.rs: uncomment and wire up 5 real tests (CRITICAL #2) worker_tests.rs: uncomment and wire up 5 real tests (CRITICAL #3) worker.rs: log bypass-flag injection, add .. path traversal rejection (CRITICAL #4, #7) verification.ts: export stripInjectedTaskEcho, add path traversal guard (CRITICAL #5) runner.ts: remove duplicate stripInjectedTaskEcho, add ENV_ALLOWLIST filtering (HIGH #17) channel-messenger.ts: add secret scrubbing, hoist regex constants (MEDIUM #27, #28) process-spawner.ts: add settled guard for race condition (MEDIUM #23) step-executor.ts: add sideEffects to callback type, deprecate alias (HIGH #15, #16) index.ts: export StepExecutor directly (MEDIUM #29) workflows/refactor/*.ts: replace hardcoded paths, remove --no-verify (HIGH #8-11) broker.rs: move is_pid_alive to canonical location (HIGH #14) cost/tracker.ts: add restrictive file permissions (MEDIUM #30) cost/pricing.ts: add last-verified date (MEDIUM #31) verification.test.ts: 9 new tests for exported helpers (MEDIUM #32) Co-Authored-By: My Senior Dev <dev@myseniordev.com> * style: auto-format Rust code with cargo fmt * minor clean * fix: reinstate deleted workflow files into workflows/ci/ Moved fix-mcp-spawn.yaml, add-swift-sdk.ts, and cli-observability.ts into workflows/ci/ to clearly distinguish them as CI test suite workflows. Updated .gitignore to allow workflows/ci/ and workflows/refactor/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining Devin review findings and fix failing test - Fix tracker test: expect mode: 0o700 in mkdirSync assertion - Use Object.hasOwn() instead of `in` operator to avoid prototype chain false positives - Use Promise.allSettled to preserve partial output on process timeout - Apply path containment check for absolute paths in checkFileExists Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address new Devin review findings — StepExecutor name collision and cwd trailing slash - Rename StepExecutor interface in runner.ts to RunnerStepExecutor to avoid shadowing the StepExecutor class export in the barrel index - Normalize cwd with path.resolve() in checkFileExists to handle trailing slashes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces an update-checker utility that checks for new versions of agent-relay via the npm registry, with caching to limit API calls. The CLI now checks for updates in the background for interactive commands and adds a new 'update' command to check for and install updates. This improves user awareness of new releases and streamlines the update process.