From 371be18e96eb89838744eadbcc2447b144c841aa Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 13 Apr 2026 12:07:51 +0800 Subject: [PATCH 1/2] feat: decouple extension version from CLI version Extension and CLI had tightly coupled version numbers (both 1.7.2), requiring manual sync across 3 files on every release. This decouples them so each can release independently. Changes: - Extension version reset to 1.0.0 with independent versioning - Extension sends compatRange (e.g. ">=1.7.0") in hello message so doctor can check CLI/extension compatibility - Daemon stores and exposes extensionCompatRange via /status - Doctor uses compatRange for compatibility checks (falls back to major-version check for older extensions without compatRange) - Doctor shows extension update availability from cached GitHub Releases data - release.yml always builds and attaches extension zip to every CLI release, so users always find both in the same release page - build-extension.yml triggers on ext-v* tags (not v*) to avoid duplicate builds --- .github/workflows/build-extension.yml | 2 +- .github/workflows/release.yml | 18 +++++++++ extension/manifest.json | 2 +- extension/package.json | 5 ++- extension/src/background.ts | 10 ++++- extension/vite.config.ts | 7 ++++ src/browser/daemon-client.ts | 1 + src/daemon.ts | 5 +++ src/doctor.ts | 57 ++++++++++++++++++++++++++- src/update-check.ts | 49 +++++++++++++++++++++-- 10 files changed, 146 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index 2051c75ad..c637f41bd 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -3,7 +3,7 @@ name: Build Chrome Extension on: push: branches: [ "main" ] - tags: [ "v*.*.*" ] + tags: [ "ext-v*" ] paths: - 'extension/**' - '.github/workflows/build-extension.yml' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f65bf0263..e7eaef956 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,10 +26,28 @@ jobs: - name: Type check run: npx tsc --noEmit + - name: Install extension dependencies + run: npm ci + working-directory: extension + + - name: Build extension + run: npm run build + working-directory: extension + + - name: Package extension + run: npm run package:release -- --out ../extension-package + working-directory: extension + + - name: Create extension ZIP + run: | + cd extension-package + zip -r ../opencli-extension.zip . + - name: Create GitHub Release uses: softprops/action-gh-release@v2.6.1 with: generate_release_notes: true + files: opencli-extension.zip - name: Publish to npm run: npm publish --provenance --access public diff --git a/extension/manifest.json b/extension/manifest.json index 2ab610c94..0e95e5f6a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "OpenCLI", - "version": "1.7.2", + "version": "1.0.0", "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.", "permissions": [ "debugger", diff --git a/extension/package.json b/extension/package.json index 1d341cb2d..d76026ebe 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,7 +1,10 @@ { "name": "opencli-extension", - "version": "1.7.2", + "version": "1.0.0", "private": true, + "opencli": { + "compatRange": ">=1.7.0" + }, "type": "module", "scripts": { "dev": "vite build --watch", diff --git a/extension/src/background.ts b/extension/src/background.ts index 7e1852af5..20b83a329 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -5,6 +5,8 @@ * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results. */ +declare const __OPENCLI_COMPAT_RANGE__: string; + import type { Command, Result } from './protocol'; import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; @@ -66,8 +68,12 @@ async function connect(): Promise { clearTimeout(reconnectTimer); reconnectTimer = null; } - // Send version so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + // Send version + compatibility range so the daemon can report mismatches to the CLI + ws?.send(JSON.stringify({ + type: 'hello', + version: chrome.runtime.getManifest().version, + compatRange: __OPENCLI_COMPAT_RANGE__, + })); }; ws.onmessage = async (event) => { diff --git a/extension/vite.config.ts b/extension/vite.config.ts index f7cd0ecc1..a538ef51c 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -1,7 +1,14 @@ import { defineConfig } from 'vite'; import { resolve } from 'path'; +import { readFileSync } from 'fs'; + +const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); +const compatRange: string = pkg.opencli?.compatRange ?? '>=0.0.0'; export default defineConfig({ + define: { + __OPENCLI_COMPAT_RANGE__: JSON.stringify(compatRange), + }, build: { outDir: 'dist', emptyOutDir: true, diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index cce48aa99..cc7cd3b05 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -67,6 +67,7 @@ export interface DaemonStatus { uptime: number; extensionConnected: boolean; extensionVersion?: string; + extensionCompatRange?: string; pending: number; memoryMB: number; port: number; diff --git a/src/daemon.ts b/src/daemon.ts index d9ebcea80..f4de2ea1b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -31,6 +31,7 @@ const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_P let extensionWs: WebSocket | null = null; let extensionVersion: string | null = null; +let extensionCompatRange: string | null = null; const pending = new Map void; reject: (error: Error) => void; @@ -124,6 +125,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise uptime, extensionConnected: extensionWs?.readyState === WebSocket.OPEN, extensionVersion, + extensionCompatRange, pending: pending.size, memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, port: PORT, @@ -211,6 +213,7 @@ wss.on('connection', (ws: WebSocket) => { log.info('[daemon] Extension connected'); extensionWs = ws; extensionVersion = null; // cleared until hello message arrives + extensionCompatRange = null; // ── Heartbeat: ping every 15s, close if 2 pongs missed ── let missedPongs = 0; @@ -240,6 +243,7 @@ wss.on('connection', (ws: WebSocket) => { // Handle hello message from extension (version handshake) if (msg.type === 'hello') { extensionVersion = typeof msg.version === 'string' ? msg.version : null; + extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null; return; } @@ -270,6 +274,7 @@ wss.on('connection', (ws: WebSocket) => { if (extensionWs === ws) { extensionWs = null; extensionVersion = null; + extensionCompatRange = null; // Reject all pending requests since the extension is gone for (const [id, p] of pending) { clearTimeout(p.timer); diff --git a/src/doctor.ts b/src/doctor.ts index ddcdd6ec9..f0eb07335 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -10,9 +10,38 @@ import { BrowserBridge } from './browser/index.js'; import { getDaemonHealth, listSessions } from './browser/daemon-client.js'; import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; +import { getCachedLatestExtensionVersion } from './update-check.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; +/** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */ +function parseSemver(v: string): [number, number, number] | null { + const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number); + if (parts.length < 3 || parts.some(isNaN)) return null; + return [parts[0], parts[1], parts[2]]; +} + +/** Returns true if `a` is strictly newer than `b`. */ +function isNewerVersion(a: string, b: string): boolean { + const va = parseSemver(a); + const vb = parseSemver(b); + if (!va || !vb) return false; + const cmp = va[0] - vb[0] || va[1] - vb[1] || va[2] - vb[2]; + return cmp > 0; +} + +/** Check if version satisfies a simple range like ">=1.7.0". */ +function satisfiesRange(version: string, range: string): boolean { + const match = range.match(/^(>=?)\s*(\S+)$/); + if (!match) return true; // Unknown range format — don't block + const [, op, rangeVer] = match; + const v = parseSemver(version); + const r = parseSemver(rangeVer); + if (!v || !r) return true; + const cmp = v[0] - r[0] || v[1] - r[1] || v[2] - r[2]; + return op === '>=' ? cmp >= 0 : cmp > 0; +} + export type DoctorOptions = { yes?: boolean; live?: boolean; @@ -34,6 +63,7 @@ export type DoctorReport = { extensionConnected: boolean; extensionFlaky?: boolean; extensionVersion?: string; + latestExtensionVersion?: string; connectivity?: ConnectivityResult; sessions?: Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>; issues: string[]; @@ -113,7 +143,17 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { } })(); -function writeCache(latestVersion: string): void { +function writeCache(latestVersion: string, latestExtensionVersion?: string): void { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); - fs.writeFileSync(CACHE_FILE, JSON.stringify({ lastCheck: Date.now(), latestVersion }), 'utf-8'); + const data: UpdateCache = { lastCheck: Date.now(), latestVersion }; + if (latestExtensionVersion) data.latestExtensionVersion = latestExtensionVersion; + fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8'); } catch { // Best-effort; never fail } @@ -85,6 +89,36 @@ export function registerUpdateNoticeOnExit(): void { }); } +/** Fetch the latest extension version from GitHub Releases (looks for ext-v* tags or extension zip assets). */ +async function fetchLatestExtensionVersion(): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + const res = await fetch(GITHUB_RELEASES_URL, { + signal: controller.signal, + headers: { 'User-Agent': `opencli/${PKG_VERSION}`, Accept: 'application/vnd.github+json' }, + }); + clearTimeout(timer); + if (!res.ok) return undefined; + const releases = await res.json() as Array<{ tag_name: string; assets?: Array<{ name: string }> }>; + // Look for releases that have the extension zip attached + for (const release of releases) { + const hasExtZip = release.assets?.some(a => a.name === 'opencli-extension.zip'); + if (!hasExtZip) continue; + // Extract extension version from release body or tag + // For now, use the tag to derive CLI version — extension version is embedded in the zip + // The best approach: look for ext-v* tags first + const extMatch = release.tag_name.match(/^ext-v(.+)$/); + if (extMatch) return extMatch[1]; + } + // Fallback: find the latest release that has the extension zip + // and read the extension version from a release body pattern like "Extension: v1.0.0" + return undefined; + } catch { + return undefined; + } +} + /** * Kick off a background fetch to npm registry. Writes to cache for next run. * Fully non-blocking — never awaited. @@ -105,10 +139,19 @@ export function checkForUpdateBackground(): void { if (!res.ok) return; const data = await res.json() as { version?: string }; if (typeof data.version === 'string') { - writeCache(data.version); + const extVersion = await fetchLatestExtensionVersion(); + writeCache(data.version, extVersion); } } catch { // Network error: silently skip, try again next run } })(); } + +/** + * Get the cached latest extension version (if available). + * Used by `opencli doctor` to report extension updates. + */ +export function getCachedLatestExtensionVersion(): string | undefined { + return _cache?.latestExtensionVersion; +} From 1d3ca39656f1357681e0582040ad99d92a2c83f2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 13 Apr 2026 12:14:27 +0800 Subject: [PATCH 2/2] fix: version extension release assets --- .github/workflows/build-extension.yml | 4 +++ .github/workflows/release.yml | 6 +++- src/update-check.test.ts | 40 ++++++++++++++++++++++++ src/update-check.ts | 44 ++++++++++++++++++--------- 4 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 src/update-check.test.ts diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index c637f41bd..7847ecc67 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -44,8 +44,10 @@ jobs: - name: Create Extension ZIP run: | + EXT_VERSION=$(node -p "require('./extension/package.json').version") cd extension-package zip -r ../opencli-extension.zip . + cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip - name: Upload Artifacts (Action Run) uses: actions/upload-artifact@v7 @@ -53,6 +55,7 @@ jobs: name: opencli-extension-build path: | opencli-extension.zip + opencli-extension-v*.zip retention-days: 7 - name: Attach to GitHub Release @@ -61,6 +64,7 @@ jobs: with: files: | opencli-extension.zip + opencli-extension-v*.zip draft: false prerelease: false env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7eaef956..1fcc8c466 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,14 +40,18 @@ jobs: - name: Create extension ZIP run: | + EXT_VERSION=$(node -p "require('./extension/package.json').version") cd extension-package zip -r ../opencli-extension.zip . + cp ../opencli-extension.zip ../opencli-extension-v${EXT_VERSION}.zip - name: Create GitHub Release uses: softprops/action-gh-release@v2.6.1 with: generate_release_notes: true - files: opencli-extension.zip + files: | + opencli-extension.zip + opencli-extension-v*.zip - name: Publish to npm run: npm publish --provenance --access public diff --git a/src/update-check.test.ts b/src/update-check.test.ts new file mode 100644 index 000000000..5dc3d38c7 --- /dev/null +++ b/src/update-check.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { _extractLatestExtensionVersionFromReleases as extractLatestExtensionVersionFromReleases } from './update-check.js'; + +describe('extractLatestExtensionVersionFromReleases', () => { + it('reads the extension version from a versioned asset on a normal CLI release', () => { + expect( + extractLatestExtensionVersionFromReleases([ + { + tag_name: 'v1.7.3', + assets: [ + { name: 'opencli-extension.zip' }, + { name: 'opencli-extension-v1.0.2.zip' }, + ], + }, + ]), + ).toBe('1.0.2'); + }); + + it('falls back to ext-v tags for extension-only releases', () => { + expect( + extractLatestExtensionVersionFromReleases([ + { + tag_name: 'ext-v1.1.0', + assets: [{ name: 'opencli-extension.zip' }], + }, + ]), + ).toBe('1.1.0'); + }); + + it('returns undefined when no extension version source exists', () => { + expect( + extractLatestExtensionVersionFromReleases([ + { + tag_name: 'v1.7.3', + assets: [{ name: 'opencli-extension.zip' }], + }, + ]), + ).toBeUndefined(); + }); +}); diff --git a/src/update-check.ts b/src/update-check.ts index 505fdf358..52695000a 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -27,6 +27,15 @@ interface UpdateCache { latestExtensionVersion?: string; } +interface GitHubReleaseAsset { + name: string; +} + +interface GitHubRelease { + tag_name: string; + assets?: GitHubReleaseAsset[]; +} + // Read cache once at module load — shared by both exported functions const _cache: UpdateCache | null = (() => { try { @@ -89,7 +98,20 @@ export function registerUpdateNoticeOnExit(): void { }); } -/** Fetch the latest extension version from GitHub Releases (looks for ext-v* tags or extension zip assets). */ +function extractLatestExtensionVersionFromReleases(releases: GitHubRelease[]): string | undefined { + for (const release of releases) { + for (const asset of release.assets ?? []) { + const assetMatch = asset.name.match(/^opencli-extension-v(.+)\.zip$/); + if (assetMatch) return assetMatch[1]; + } + + const tagMatch = release.tag_name.match(/^ext-v(.+)$/); + if (tagMatch) return tagMatch[1]; + } + return undefined; +} + +/** Fetch the latest extension version from GitHub Releases. */ async function fetchLatestExtensionVersion(): Promise { try { const controller = new AbortController(); @@ -100,20 +122,8 @@ async function fetchLatestExtensionVersion(): Promise { }); clearTimeout(timer); if (!res.ok) return undefined; - const releases = await res.json() as Array<{ tag_name: string; assets?: Array<{ name: string }> }>; - // Look for releases that have the extension zip attached - for (const release of releases) { - const hasExtZip = release.assets?.some(a => a.name === 'opencli-extension.zip'); - if (!hasExtZip) continue; - // Extract extension version from release body or tag - // For now, use the tag to derive CLI version — extension version is embedded in the zip - // The best approach: look for ext-v* tags first - const extMatch = release.tag_name.match(/^ext-v(.+)$/); - if (extMatch) return extMatch[1]; - } - // Fallback: find the latest release that has the extension zip - // and read the extension version from a release body pattern like "Extension: v1.0.0" - return undefined; + const releases = await res.json() as GitHubRelease[]; + return extractLatestExtensionVersionFromReleases(releases); } catch { return undefined; } @@ -155,3 +165,7 @@ export function checkForUpdateBackground(): void { export function getCachedLatestExtensionVersion(): string | undefined { return _cache?.latestExtensionVersion; } + +export { + extractLatestExtensionVersionFromReleases as _extractLatestExtensionVersionFromReleases, +};