diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index d77ca6ee..7971087a 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 68214b7e..16f30416 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 00000000..86b53ccb --- /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 00000000..f9a3d216 --- /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 00000000..6eeb158d --- /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 00000000..aeeb8446 --- /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 00000000..8ec1030d --- /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 118004bc..80d7b196 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 230198c7..58c19a53 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())