From 565ce18ebadfa165a2a746362dc11c24445d99f9 Mon Sep 17 00:00:00 2001 From: shivammittal274 <56757235+shivammittal274@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:30:58 +0530 Subject: [PATCH] feat: add npm/npx distribution for BrowserOS CLI (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): skip self-update prompts for package manager installs Checks BROWSEROS_INSTALL_METHOD env var (npm, brew) and skips automatic update checks. Users should use their package manager's update mechanism. FormatNotice now shows the appropriate upgrade command based on install method. * feat(cli): add npm bin wrapper for browseros-cli * feat(cli): add npm postinstall script to download platform binary Downloads the correct platform binary from GitHub releases during npm install, verifies SHA256 checksums, and extracts to .binary directory. * feat(cli): add npm package metadata and README Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move npm package files to correct monorepo path The bin wrapper and postinstall were created at apps/cli/npm/ instead of packages/browseros-agent/apps/cli/npm/. Moves them to the correct location. * style: use node: protocol for builtin module imports * feat(cli): add Makefile npm targets and release workflow npm publish step Adds npm-version and npm-publish Makefile targets for version sync. Adds Node.js setup and npm publish step to the release workflow. Adds npm/npx install instructions to release notes template. * fix(cli): fail on missing checksum entry and limit redirect depth - Abort if checksums.txt downloaded but archive entry is missing - Warn if checksums.txt itself failed to download - Cap redirect depth at 5 to prevent stack overflow on circular redirects * fix(cli): match install.sh checksum behavior — warn instead of abort The existing shell installer (install.sh) warns and continues when the checksum entry is missing from checksums.txt. Match that behavior in the npm postinstall to avoid unnecessary install failures. Both files come from the same GitHub release, so the checksum is a corruption check, not a strong security boundary. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/release-cli.yml | 21 +++ packages/browseros-agent/apps/cli/Makefile | 10 ++ .../browseros-agent/apps/cli/npm/.npmignore | 2 + .../browseros-agent/apps/cli/npm/README.md | 81 ++++++++++ .../apps/cli/npm/bin/browseros-cli.js | 32 ++++ .../browseros-agent/apps/cli/npm/package.json | 45 ++++++ .../apps/cli/npm/scripts/postinstall.js | 142 ++++++++++++++++++ .../apps/cli/update/manager.go | 28 +++- .../apps/cli/update/manager_test.go | 26 ++++ 9 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 packages/browseros-agent/apps/cli/npm/.npmignore create mode 100644 packages/browseros-agent/apps/cli/npm/README.md create mode 100644 packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js create mode 100644 packages/browseros-agent/apps/cli/npm/package.json create mode 100644 packages/browseros-agent/apps/cli/npm/scripts/postinstall.js diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index d77ca6eef..7971087ad 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -103,6 +103,13 @@ jobs: ## Install `browseros-cli` + ### npm / npx + + ```bash + npx browseros-cli --help + npm install -g browseros-cli + ``` + ### macOS / Linux ```bash @@ -138,3 +145,17 @@ jobs: --notes-file /tmp/release-notes.md \ ${CLI_DIST}/* working-directory: ${{ github.workspace }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + make npm-version VERSION=${{ inputs.version }} + cd npm + npm publish --access public diff --git a/packages/browseros-agent/apps/cli/Makefile b/packages/browseros-agent/apps/cli/Makefile index 68214b7e4..16f304163 100644 --- a/packages/browseros-agent/apps/cli/Makefile +++ b/packages/browseros-agent/apps/cli/Makefile @@ -48,3 +48,13 @@ release: @cd $(DIST) && (command -v sha256sum >/dev/null 2>&1 && sha256sum *.tar.gz *.zip || shasum -a 256 *.tar.gz *.zip) > checksums.txt @echo "=== Built artifacts ===" @ls -lh $(DIST) + +.PHONY: npm-version npm-publish + +npm-version: + @if [ "$(VERSION)" = "dev" ]; then echo "Error: VERSION required" >&2; exit 1; fi + @node -e "const p=require('./npm/package.json');p.version='$(VERSION)';require('fs').writeFileSync('./npm/package.json',JSON.stringify(p,null,2)+'\n')" + @echo "npm/package.json version set to $(VERSION)" + +npm-publish: npm-version + cd npm && npm publish diff --git a/packages/browseros-agent/apps/cli/npm/.npmignore b/packages/browseros-agent/apps/cli/npm/.npmignore new file mode 100644 index 000000000..86b53ccb9 --- /dev/null +++ b/packages/browseros-agent/apps/cli/npm/.npmignore @@ -0,0 +1,2 @@ +.binary/ +node_modules/ diff --git a/packages/browseros-agent/apps/cli/npm/README.md b/packages/browseros-agent/apps/cli/npm/README.md new file mode 100644 index 000000000..f9a3d2167 --- /dev/null +++ b/packages/browseros-agent/apps/cli/npm/README.md @@ -0,0 +1,81 @@ +# browseros-cli + +Command-line interface for controlling BrowserOS -- launch and automate the browser from the terminal. + +## Installation + +**Zero install (recommended):** + +```bash +npx browseros-cli --help +``` + +**Global install:** + +```bash +npm install -g browseros-cli +``` + +**Shell script fallback:** + +```bash +curl -fsSL https://cdn.browseros.com/cli/install.sh | bash +``` + +## Quick Start + +```bash +# Download BrowserOS +browseros-cli install + +# Start BrowserOS +browseros-cli launch + +# Auto-configure MCP settings for your AI tools +browseros-cli init --auto + +# Verify everything is working +browseros-cli health +``` + +## Usage + +### Navigation + +```bash +browseros-cli navigate "https://example.com" +``` + +### Observation + +```bash +browseros-cli snapshot # Get the accessibility tree of the current page +browseros-cli console-logs # View browser console output +``` + +### Screenshots + +```bash +browseros-cli screenshot # Capture the current page +``` + +### Input + +```bash +browseros-cli click 42 # Click an element by its node ID +browseros-cli fill 85 "query" # Type text into an input field +``` + +### Agent Mode + +```bash +browseros-cli agent "Search for flights to Tokyo" +``` + +## Documentation + +Full documentation is available at [browseros.com](https://browseros.com). + +## License + +MIT diff --git a/packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js b/packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js new file mode 100644 index 000000000..6eeb158df --- /dev/null +++ b/packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const { execFileSync, spawnSync } = require('node:child_process') +const path = require('node:path') +const fs = require('node:fs') + +const BINARY_DIR = path.join(__dirname, '..', '.binary') +const EXT = process.platform === 'win32' ? '.exe' : '' +const BIN_PATH = path.join(BINARY_DIR, `browseros-cli${EXT}`) + +if (!fs.existsSync(BIN_PATH)) { + console.error('browseros-cli: binary not found, downloading...') + try { + execFileSync( + process.execPath, + [path.join(__dirname, '..', 'scripts', 'postinstall.js')], + { stdio: 'inherit', env: { ...process.env, BROWSEROS_NPM_FORCE: '1' } }, + ) + } catch { + console.error( + 'browseros-cli: failed to download binary. Try reinstalling:\n npm install -g browseros-cli', + ) + process.exit(1) + } +} + +const result = spawnSync(BIN_PATH, process.argv.slice(2), { + stdio: 'inherit', + env: { ...process.env, BROWSEROS_INSTALL_METHOD: 'npm' }, +}) + +process.exit(result.status ?? 1) diff --git a/packages/browseros-agent/apps/cli/npm/package.json b/packages/browseros-agent/apps/cli/npm/package.json new file mode 100644 index 000000000..aeeb8446e --- /dev/null +++ b/packages/browseros-agent/apps/cli/npm/package.json @@ -0,0 +1,45 @@ +{ + "name": "browseros-cli", + "version": "0.2.0", + "description": "Command-line interface for controlling BrowserOS — launch and automate the browser from the terminal", + "bin": { + "browseros-cli": "bin/browseros-cli.js" + }, + "scripts": { + "postinstall": "node scripts/postinstall.js" + }, + "keywords": [ + "browseros", + "cli", + "browser", + "automation", + "mcp", + "ai-agent", + "model-context-protocol" + ], + "repository": { + "type": "git", + "url": "https://github.com/browseros-ai/BrowserOS", + "directory": "packages/browseros-agent/apps/cli/npm" + }, + "homepage": "https://browseros.com", + "bugs": "https://github.com/browseros-ai/BrowserOS/issues", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "engines": { + "node": ">=18" + }, + "files": [ + "bin/", + "scripts/", + "README.md" + ] +} diff --git a/packages/browseros-agent/apps/cli/npm/scripts/postinstall.js b/packages/browseros-agent/apps/cli/npm/scripts/postinstall.js new file mode 100644 index 000000000..8ec1030d0 --- /dev/null +++ b/packages/browseros-agent/apps/cli/npm/scripts/postinstall.js @@ -0,0 +1,142 @@ +const https = require('node:https') +const http = require('node:http') +const fs = require('node:fs') +const path = require('node:path') +const { execSync } = require('node:child_process') +const { createHash } = require('node:crypto') + +const VERSION = require('../package.json').version +const GITHUB_RELEASE_BASE = `https://github.com/browseros-ai/BrowserOS/releases/download/browseros-cli-v${VERSION}` +const BINARY_DIR = path.join(__dirname, '..', '.binary') +const EXT = process.platform === 'win32' ? '.exe' : '' +const BINARY_PATH = path.join(BINARY_DIR, `browseros-cli${EXT}`) + +if (process.env.CI && !process.env.BROWSEROS_NPM_FORCE) { + process.exit(0) +} + +const PLATFORM_MAP = { darwin: 'darwin', linux: 'linux', win32: 'windows' } +const ARCH_MAP = { x64: 'amd64', arm64: 'arm64' } + +const platform = PLATFORM_MAP[process.platform] +const arch = ARCH_MAP[process.arch] + +if (!platform || !arch) { + console.error( + `browseros-cli: unsupported platform ${process.platform}/${process.arch}`, + ) + process.exit(1) +} + +const isWindows = platform === 'windows' +const archiveExt = isWindows ? 'zip' : 'tar.gz' +const archiveName = `browseros-cli_${VERSION}_${platform}_${arch}.${archiveExt}` +const archiveURL = `${GITHUB_RELEASE_BASE}/${archiveName}` +const checksumURL = `${GITHUB_RELEASE_BASE}/checksums.txt` + +const MAX_REDIRECTS = 5 + +function download(url, redirects = 0) { + return new Promise((resolve, reject) => { + if (redirects > MAX_REDIRECTS) { + return reject(new Error(`Too many redirects for ${url}`)) + } + const client = url.startsWith('https') ? https : http + client + .get(url, { headers: { 'User-Agent': 'browseros-cli-npm' } }, (res) => { + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + return download(res.headers.location, redirects + 1).then( + resolve, + reject, + ) + } + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)) + } + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => resolve(Buffer.concat(chunks))) + res.on('error', reject) + }) + .on('error', reject) + }) +} + +async function main() { + console.log( + `browseros-cli: downloading v${VERSION} for ${platform}/${arch}...`, + ) + + const [archiveBuffer, checksumBuffer] = await Promise.all([ + download(archiveURL), + download(checksumURL).catch(() => null), + ]) + + if (checksumBuffer) { + const checksumText = checksumBuffer.toString('utf-8') + const expectedLine = checksumText + .split('\n') + .find((l) => l.includes(archiveName)) + if (expectedLine) { + const expected = expectedLine.split(/\s+/)[0] + const actual = createHash('sha256').update(archiveBuffer).digest('hex') + if (actual !== expected) { + console.error( + `browseros-cli: checksum mismatch!\n expected: ${expected}\n got: ${actual}`, + ) + process.exit(1) + } + console.log('browseros-cli: checksum verified.') + } else { + console.warn( + 'browseros-cli: warning: checksum entry not found in checksums.txt, skipping verification.', + ) + } + } else { + console.warn( + 'browseros-cli: warning: could not fetch checksums.txt, skipping verification.', + ) + } + + fs.mkdirSync(BINARY_DIR, { recursive: true }) + const tmpArchive = path.join(BINARY_DIR, archiveName) + fs.writeFileSync(tmpArchive, archiveBuffer) + + if (isWindows) { + execSync( + `powershell -Command "Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${BINARY_DIR}'"`, + { stdio: 'inherit' }, + ) + } else { + execSync(`tar -xzf "${tmpArchive}" -C "${BINARY_DIR}"`, { + stdio: 'inherit', + }) + } + + fs.unlinkSync(tmpArchive) + + if (!fs.existsSync(BINARY_PATH)) { + console.error( + `browseros-cli: binary not found after extraction at ${BINARY_PATH}`, + ) + process.exit(1) + } + + if (!isWindows) { + fs.chmodSync(BINARY_PATH, 0o755) + } + + console.log(`browseros-cli: installed v${VERSION} successfully.`) +} + +main().catch((err) => { + console.error(`browseros-cli: installation failed: ${err.message}`) + console.error( + 'You can install manually: curl -fsSL https://cdn.browseros.com/cli/install.sh | bash', + ) + process.exit(1) +}) diff --git a/packages/browseros-agent/apps/cli/update/manager.go b/packages/browseros-agent/apps/cli/update/manager.go index 118004bcc..80d7b1960 100644 --- a/packages/browseros-agent/apps/cli/update/manager.go +++ b/packages/browseros-agent/apps/cli/update/manager.go @@ -15,6 +15,7 @@ const ( DefaultHTTPTimeout = 2 * time.Second DefaultDownloadTimeout = 5 * time.Minute SkipCheckEnv = "BROWSEROS_SKIP_UPDATE_CHECK" + InstallMethodEnv = "BROWSEROS_INSTALL_METHOD" ) type Options struct { @@ -95,9 +96,21 @@ func (m *Manager) AutomaticEnabled() bool { if os.Getenv(SkipCheckEnv) != "" { return false } + if installedViaPackageManager() { + return false + } return IsReleaseVersion(m.options.CurrentVersion) } +func installedViaPackageManager() bool { + method := os.Getenv(InstallMethodEnv) + switch method { + case "npm", "brew", "homebrew": + return true + } + return false +} + func (m *Manager) ShouldCheck() bool { if !m.AutomaticEnabled() { return false @@ -210,11 +223,22 @@ func (m *Manager) Apply(ctx context.Context, result *CheckResult) error { } func FormatNotice(currentVersion, latestVersion string) string { - return fmt.Sprintf( - "Update available: browseros-cli v%s (current v%s)\nRun `browseros-cli update` to upgrade.", + notice := fmt.Sprintf( + "Update available: browseros-cli v%s (current v%s)", latestVersion, currentVersion, ) + + switch os.Getenv(InstallMethodEnv) { + case "npm": + notice += "\nRun `npm update -g browseros-cli` to upgrade." + case "brew", "homebrew": + notice += "\nRun `brew upgrade browseros-cli` to upgrade." + default: + notice += "\nRun `browseros-cli update` to upgrade." + } + + return notice } func (m *Manager) recordError(err error) { diff --git a/packages/browseros-agent/apps/cli/update/manager_test.go b/packages/browseros-agent/apps/cli/update/manager_test.go index 230198c71..58c19a537 100644 --- a/packages/browseros-agent/apps/cli/update/manager_test.go +++ b/packages/browseros-agent/apps/cli/update/manager_test.go @@ -144,6 +144,32 @@ func TestManagerSaveAppliedState(t *testing.T) { } } +func TestAutomaticEnabledSkipsForPackageManagerInstall(t *testing.T) { + t.Setenv("BROWSEROS_INSTALL_METHOD", "npm") + + manager := NewManager(Options{ + CurrentVersion: "1.0.0", + Automatic: true, + }) + + if manager.AutomaticEnabled() { + t.Fatal("AutomaticEnabled() = true, want false when BROWSEROS_INSTALL_METHOD=npm") + } +} + +func TestAutomaticEnabledAllowsNormalInstall(t *testing.T) { + t.Setenv("BROWSEROS_INSTALL_METHOD", "") + + manager := NewManager(Options{ + CurrentVersion: "1.0.0", + Automatic: true, + }) + + if !manager.AutomaticEnabled() { + t.Fatal("AutomaticEnabled() = false, want true when BROWSEROS_INSTALL_METHOD is empty") + } +} + func runtimePlatformKey(t *testing.T) string { t.Helper() key, err := PlatformKey(runtimeGOOS(), runtimeGOARCH())