Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/build-extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Build Chrome Extension
on:
push:
branches: [ "main" ]
tags: [ "v*.*.*" ]
tags: [ "ext-v*" ]
paths:
- 'extension/**'
- '.github/workflows/build-extension.yml'
Expand Down Expand Up @@ -44,15 +44,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: Upload Artifacts (Action Run)
uses: actions/upload-artifact@v7
with:
name: opencli-extension-build
path: |
opencli-extension.zip
opencli-extension-v*.zip
retention-days: 7

- name: Attach to GitHub Release
Expand All @@ -61,6 +64,7 @@ jobs:
with:
files: |
opencli-extension.zip
opencli-extension-v*.zip
draft: false
prerelease: false
env:
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,32 @@ 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: |
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
opencli-extension-v*.zip

- name: Publish to npm
run: npm publish --provenance --access public
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 4 additions & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 8 additions & 2 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,8 +68,12 @@ async function connect(): Promise<void> {
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) => {
Expand Down
7 changes: 7 additions & 0 deletions extension/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface DaemonStatus {
uptime: number;
extensionConnected: boolean;
extensionVersion?: string;
extensionCompatRange?: string;
pending: number;
memoryMB: number;
port: number;
Expand Down
5 changes: 5 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
resolve: (data: unknown) => void;
reject: (error: Error) => void;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
57 changes: 55 additions & 2 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
Expand Down Expand Up @@ -113,7 +143,17 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
}
const extensionVersion = health.status?.extensionVersion;
if (extensionVersion && opts.cliVersion) {
const extensionCompatRange = health.status?.extensionCompatRange;
if (extensionVersion && opts.cliVersion && extensionCompatRange) {
if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
issues.push(
`CLI version incompatible with extension: extension v${extensionVersion} requires CLI ${extensionCompatRange}, but CLI is v${opts.cliVersion}\n` +
' Update the CLI: npm install -g @jackwener/opencli\n' +
' Or download a compatible extension from: https://github.com/jackwener/opencli/releases',
);
}
} else if (extensionVersion && opts.cliVersion) {
// Fallback for older extensions that don't send compatRange
const extMajor = extensionVersion.split('.')[0];
const cliMajor = opts.cliVersion.split('.')[0];
if (extMajor !== cliMajor) {
Expand All @@ -124,13 +164,23 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
}
}

// Extension update check (from cached background fetch)
const latestExtensionVersion = getCachedLatestExtensionVersion();
if (extensionVersion && latestExtensionVersion && isNewerVersion(latestExtensionVersion, extensionVersion)) {
issues.push(
`Extension update available: v${extensionVersion} → v${latestExtensionVersion}\n` +
' Download from: https://github.com/jackwener/opencli/releases',
);
}

return {
cliVersion: opts.cliVersion,
daemonRunning,
daemonFlaky,
extensionConnected,
extensionFlaky,
extensionVersion,
latestExtensionVersion,
connectivity,
sessions,
issues,
Expand All @@ -153,7 +203,10 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
const extIcon = report.extensionFlaky
? styleText('yellow', '[WARN]')
: report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) : '';
const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
: '';
const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint : '';
const extLabel = report.extensionFlaky
? 'unstable (connected during live check, then disconnected)'
: report.extensionConnected ? 'connected' : 'not connected';
Expand Down
40 changes: 40 additions & 0 deletions src/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading