From 2c2bcf0e8c843ce58a9971ea834eaa202d6aa306 Mon Sep 17 00:00:00 2001 From: Mateusz Kupczyk Date: Wed, 30 Sep 2026 15:21:47 +0200 Subject: [PATCH] feat(launcher): add the launcher and tombstone packages The launcher declares the old package name and bin so a same-name upgrade can hand users the merged implementation; the tombstone retires it in phase 2. --- README.md | 31 +++++++- launcher/README.md | 37 ++++++++++ launcher/bin/cli.js | 157 +++++++++++++++++++++++++++++++++++++++++ launcher/package.json | 18 +++++ tombstone/README.md | 29 ++++++++ tombstone/package.json | 12 ++++ 6 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 launcher/README.md create mode 100755 launcher/bin/cli.js create mode 100644 launcher/package.json create mode 100644 tombstone/README.md create mode 100644 tombstone/package.json diff --git a/README.md b/README.md index 6212ea9..6aa900f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,31 @@

The most agent-ergonomic browser automation

+> [!IMPORTANT] +> **This package has moved to [`opera-devtools-mcp`](https://github.com/operasoftware/opera-devtools-mcp).** +> `opera-browser-cli` is no longer developed here; the CLI now ships inside `opera-devtools-mcp`, +> still as the `opera-browser-cli` command. +> +> ```sh +> npm install -g opera-browser-cli # or: npm update -g opera-browser-cli +> ``` +> +> That installs the **compatibility launcher** (`0.1.55`): the same package name and the same +> command, delegating to the new implementation inside `opera-devtools-mcp`. No uninstall, no +> `--force`, no changes to your scripts, and your `~/.opera-browser-cli/config` is reused. +> Add the MCP server as its own global install if you want it too: +> `npm install -g opera-devtools-mcp`. +> +> When you want to drop the launcher and keep a single global package: +> +> ```sh +> npm install -g opera-devtools-mcp@0.9.0 opera-browser-cli@0.2.0 +> npm rm -g opera-browser-cli # optional: drops the inert tombstone +> ``` +> +> Existing `0.1.x` installs keep working; they are simply no longer maintained. +> Run `opera-browser-cli doctor` for the same instructions on the installed CLI. + `opera-browser-cli` is a fork of [chrome-devtools-axi](https://github.com/kunchenguid/chrome-devtools-axi). It wraps [opera-devtools-mcp](https://github.com/operasoftware/opera-devtools-mcp) with an [AXI](https://axi.md)-compliant CLI. @@ -12,7 +37,7 @@ It wraps [opera-devtools-mcp](https://github.com/operasoftware/opera-devtools-mc ## Quick Start ```sh -npm install -g opera-browser-cli +npm install -g opera-browser-cli # installs the launcher that delivers the new implementation opera-browser-cli open https://example.com ``` @@ -69,6 +94,10 @@ Prerequisites: **Node.js >= 20**, **Opera** browser ([Opera Neon](https://www.op npm install -g opera-browser-cli ``` +Development has moved to `opera-devtools-mcp`, which ships this CLI as the +`opera-browser-cli` command; this package name installs the compatibility +launcher that delivers it. See the notice at the top of this file. + No setup step is required. The first command you run detects your Opera installation, writes `~/.opera-browser-cli/config`, and continues: diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..5bcf9ee --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,37 @@ +# opera-browser-cli (compatibility launcher) + +`opera-browser-cli` has moved into +[`opera-devtools-mcp`](https://github.com/operasoftware/opera-devtools-mcp), +which now ships both the MCP server and the `opera-browser-cli` command. + +This version is a **launcher**: it keeps the package name and the bin name so +that the command you already run installs the new implementation — + +```sh +npm i -g opera-browser-cli # or: npm update -g opera-browser-cli +opera-browser-cli --version # the new implementation's version +``` + +— and delegates every invocation to `opera-devtools-mcp`, installed as its +dependency. Nothing about the command line changes. + +When you are ready to drop the launcher and keep only one global package: + +```sh +npm i -g opera-devtools-mcp@0.9.0 opera-browser-cli@0.2.0 +npm rm -g opera-browser-cli # optional: drops the inert tombstone +``` + +or, in two steps: + +```sh +npm rm -g opera-browser-cli +npm i -g opera-devtools-mcp@0.9.0 +``` + +`opera-browser-cli --version` and `opera-browser-cli doctor` print the same +recipes whenever the CLI notices it was launched from here. + +See +[`docs/npm-package-transition.md`](https://github.com/operasoftware/opera-devtools-mcp/blob/main/docs/npm-package-transition.md) +for the full transition, including what each release train publishes. \ No newline at end of file diff --git a/launcher/bin/cli.js b/launcher/bin/cli.js new file mode 100755 index 0000000..fb8a8de --- /dev/null +++ b/launcher/bin/cli.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +/** + * `opera-browser-cli` compatibility launcher. + * + * The CLI moved into `opera-devtools-mcp`, but npm has no way to remove another + * package's global binstub except a **same-name upgrade**: it retires the + * previous version of the incoming package's name — unlinking its binstubs — + * before the new package's bin-conflict check runs. So this package keeps the + * old name and the old bin name and depends on the real implementation, which + * makes `npm i -g opera-browser-cli` / `npm update -g opera-browser-cli` — the + * command users already run — install the new CLI without `--force` and + * without a manual uninstall. + * + * The launcher only resolves and spawns; it never reimplements the CLI. The + * implementation entry is ESM (so it is spawned, not `require`d), and stdio is + * inherited for a zero-copy pass-through of TTYs and pipes. + * + * Marker it hands the child (read by `--version` and `doctor` in + * `opera-devtools-mcp`, `src/opera/launcherNotice.ts`): + * OPERA_CLI_LAUNCHER=1 — this CLI came from the launcher + * OPERA_CLI_LAUNCHER_PREFIX= — the global prefix it resolved + * + * Retire the launcher when `opera-browser-cli@0.2.0` (a bin-less tombstone) and + * `opera-devtools-mcp@0.9.0` (dual bin) exist: + * npm i -g opera-devtools-mcp@0.9.0 opera-browser-cli@0.2.0 + * npm rm -g opera-browser-cli # optional: drops the inert tombstone + */ + +'use strict'; + +const {execSync, spawnSync} = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +/** Where the implementation's CLI entry lives inside its package. */ +const CLI_ENTRY = path.join('build', 'src', 'bin', 'opera-browser-cli.js'); + +/** What npm sets while it runs an install script, when it is set at all. */ +function envPrefix() { + const prefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX; + return prefix ? prefix : null; +} + +/** The prefix npm installed this launcher under, asked of npm as a last resort. */ +function npmPrefix() { + try { + return ( + execSync('npm config get prefix', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5_000, + }).trim() || null + ); + } catch { + return null; + } +} + +/** + * The prefix the launcher itself sits under, read from its own path: npm links + * `/bin/opera-browser-cli` to `/lib/node_modules//bin/cli.js` + * on POSIX and `/node_modules//bin/cli.js` on Windows. Deriving it + * costs nothing; asking npm would spawn a process on every single command. + */ +function ownPathPrefix() { + const packageDir = path.dirname(__dirname); + if (path.basename(packageDir) !== 'opera-browser-cli') { + return null; + } + const nodeModules = path.dirname(packageDir); + if (path.basename(nodeModules) !== 'node_modules') { + return null; + } + const parent = path.dirname(nodeModules); + return path.basename(parent) === 'lib' ? path.dirname(parent) : parent; +} + +function globalPrefix() { + return ownPathPrefix() ?? envPrefix() ?? npmPrefix(); +} + +/** + * Where the implementation may be, best first: a sibling root is a package the + * user installed directly and is therefore the one they upgrade, while the + * nested copy is whatever `^0.8.0` resolved when *this* launcher was installed. + */ +function implementationCandidates() { + const candidates = []; + const prefix = globalPrefix(); + if (prefix) { + candidates.push( + path.join(prefix, 'lib', 'node_modules', 'opera-devtools-mcp'), + path.join(prefix, 'node_modules', 'opera-devtools-mcp'), + ); + } + try { + candidates.push(path.dirname(require.resolve('opera-devtools-mcp/package.json'))); + } catch { + // No nested copy either: the candidates tried so far are what the error lists. + } + return candidates; +} + +function resolveImplementation() { + const candidates = implementationCandidates(); + const usable = candidates.find(dir => fs.existsSync(path.join(dir, CLI_ENTRY))); + if (usable) { + return usable; + } + process.stderr.write( + [ + 'opera-browser-cli: the compatibility launcher cannot find the new implementation.', + ...candidates.map(dir => ` tried ${dir}`), + 'Install it directly, then retire this launcher:', + ' npm i -g opera-devtools-mcp@latest', + ' npm rm -g opera-browser-cli', + '', + ].join('\n'), + ); + process.exit(1); +} + +const prefix = globalPrefix(); +const result = spawnSync( + process.execPath, + [path.join(resolveImplementation(), CLI_ENTRY), ...process.argv.slice(2)], + { + stdio: 'inherit', + env: { + ...process.env, + OPERA_CLI_LAUNCHER: '1', + OPERA_CLI_LAUNCHER_PREFIX: prefix ?? '', + }, + }, +); + +if (result.error) { + process.stderr.write( + `opera-browser-cli: could not start the new implementation: ${result.error.message}\n`, + ); + process.exit(1); +} + +if (result.signal) { + // A child killed by a signal (`Ctrl-C` sends SIGINT to the whole foreground + // process group) has no exit status, and `?? 1` would report the user's + // cancellation as a failure — visible to `set -e` and to CI runners. Re-raise + // it instead; the ones Windows cannot raise on itself fall through to 1. + try { + process.kill(process.pid, result.signal); + } catch { + // Unsupported signal: the exit code below is all that is left. + } +} + +process.exit(result.status ?? 1); \ No newline at end of file diff --git a/launcher/package.json b/launcher/package.json new file mode 100644 index 0000000..6d3291a --- /dev/null +++ b/launcher/package.json @@ -0,0 +1,18 @@ +{ + "name": "opera-browser-cli", + "version": "0.1.55", + "description": "Compatibility launcher — delegates to opera-devtools-mcp", + "license": "MIT", + "bin": { + "opera-browser-cli": "./bin/cli.js" + }, + "dependencies": { + "opera-devtools-mcp": "^0.8.0" + }, + "files": [ + "bin/" + ], + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } +} \ No newline at end of file diff --git a/tombstone/README.md b/tombstone/README.md new file mode 100644 index 0000000..b1f091d --- /dev/null +++ b/tombstone/README.md @@ -0,0 +1,29 @@ +# opera-browser-cli is retired + +The CLI lives in +[`opera-devtools-mcp`](https://github.com/operasoftware/opera-devtools-mcp) +now — one package provides both the MCP server and the `opera-browser-cli` +command. + +```sh +npm rm -g opera-browser-cli +npm i -g opera-devtools-mcp@latest +``` + +If you installed `opera-browser-cli@0.1.55`, that version was a **launcher**: it +kept the package name so the command you already run delivered the new +implementation, and it delegated every invocation to `opera-devtools-mcp`. + +This version (`0.2.0`) is the tombstone that retires the launcher. It has no +bin and no dependencies; installing it is what lets npm unlink the launcher's +global binstub on a same-name upgrade, which is the only way npm removes a +foreign binstub without `--force`. Installing it *together with* the new MCP +package does the whole move in one command: + +```sh +npm i -g opera-devtools-mcp@0.9.0 opera-browser-cli@0.2.0 +npm rm -g opera-browser-cli # optional: drops this inert package +``` + +`opera-browser-cli --version` and `opera-browser-cli doctor` print both recipes +whenever the CLI notices it was launched by the launcher. \ No newline at end of file diff --git a/tombstone/package.json b/tombstone/package.json new file mode 100644 index 0000000..8194e24 --- /dev/null +++ b/tombstone/package.json @@ -0,0 +1,12 @@ +{ + "name": "opera-browser-cli", + "version": "0.2.0", + "description": "Retired. Use opera-devtools-mcp.", + "license": "MIT", + "files": [ + "README.md" + ], + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } +} \ No newline at end of file