mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
feat: add npm/npx distribution for BrowserOS CLI (#618)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com>
This commit is contained in:
21
.github/workflows/release-cli.yml
vendored
21
.github/workflows/release-cli.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
packages/browseros-agent/apps/cli/npm/.npmignore
Normal file
2
packages/browseros-agent/apps/cli/npm/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
.binary/
|
||||
node_modules/
|
||||
81
packages/browseros-agent/apps/cli/npm/README.md
Normal file
81
packages/browseros-agent/apps/cli/npm/README.md
Normal file
@@ -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
|
||||
32
packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js
Normal file
32
packages/browseros-agent/apps/cli/npm/bin/browseros-cli.js
Normal file
@@ -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)
|
||||
45
packages/browseros-agent/apps/cli/npm/package.json
Normal file
45
packages/browseros-agent/apps/cli/npm/package.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
142
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js
Normal file
142
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user