From 3534a2f86e3f01a53628444888e5bfa9c89d7c78 Mon Sep 17 00:00:00 2001 From: Jelloeater Agent Date: Fri, 24 Jul 2026 01:36:40 -0400 Subject: [PATCH 1/2] feat: add bare-metal runner via bunx/npx Adds a one-liner entry point so OpenCode Manager can be started without Docker or cloning the repo: bunx opencode-manager The CLI bootstraps the full service: - Checks/install prerequisites (Bun, Git, OpenCode) - Creates data directory at ~/.opencode-manager/ - Auto-generates AUTH_SECRET on first run - Installs dependencies and builds frontend - Starts the backend server Also adds: - bin/ entry point (shell + Bun TypeScript) - scripts/build-npm.ts prepublish hook - package.json bin/files/prepack fields - README one-liner quick-start section --- README.md | 10 ++ bin/opencode-manager | 18 +++ bin/opencode-manager.ts | 268 ++++++++++++++++++++++++++++++++++++++++ package.json | 18 ++- scripts/build-npm.ts | 27 ++++ 5 files changed, 340 insertions(+), 1 deletion(-) create mode 100755 bin/opencode-manager create mode 100644 bin/opencode-manager.ts create mode 100644 scripts/build-npm.ts diff --git a/README.md b/README.md index 8dbabe34..5c640930 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,16 @@ ## Quick Start +### One-liner (bunx) + +```bash +bunx opencode-manager +``` + +This installs prerequisites (Bun, OpenCode, Git), sets up `~/.opencode-manager/`, and starts the server. Open `http://localhost:5003`. + +### Docker + ```bash git clone https://github.com/chriswritescode-dev/opencode-manager.git cd opencode-manager diff --git a/bin/opencode-manager b/bin/opencode-manager new file mode 100755 index 00000000..0985910c --- /dev/null +++ b/bin/opencode-manager @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resolve the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find bun +if command -v bun &>/dev/null; then + exec bun run "$SCRIPT_DIR/opencode-manager.ts" "$@" +elif command -v node &>/dev/null; then + echo "opencode-manager: Bun is required but not found. Installing..." + curl -fsSL https://bun.sh/install | bash + export PATH="$HOME/.bun/bin:$PATH" + exec bun run "$SCRIPT_DIR/opencode-manager.ts" "$@" +else + echo "opencode-manager: Neither bun nor node found. Please install Bun: https://bun.sh" >&2 + exit 1 +fi diff --git a/bin/opencode-manager.ts b/bin/opencode-manager.ts new file mode 100644 index 00000000..85fb5fc5 --- /dev/null +++ b/bin/opencode-manager.ts @@ -0,0 +1,268 @@ +#!/usr/bin/env bun + +import { spawnSync, spawn } from 'child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { join, resolve } from 'path' +import { homedir } from 'os' +import { randomBytes } from 'crypto' + +const VERSION = '0.15.0' + +const USAGE = ` +opencode-manager v${VERSION} + +Usage: + opencode-manager Start the server + opencode-manager --version Show version + opencode-manager --help Show this help + +Environment: + PORT Backend port (default: 5003) + HOST Bind address (default: 0.0.0.0) + AUTH_SECRET Required in production. Generate: openssl rand -base64 32 + DATABASE_PATH SQLite database path (default: ./data/opencode.db) + WORKSPACE_PATH Workspace root (default: ./workspace) +` + +function die(msg: string, code = 1): never { + process.stderr.write(`opencode-manager: ${msg}\n`) + process.exit(code) +} + +function info(msg: string): void { + process.stdout.write(`${msg}\n`) +} + +function warn(msg: string): void { + process.stderr.write(`opencode-manager: warning: ${msg}\n`) +} + +function hasCommand(cmd: string): boolean { + try { + const result = spawnSync('which', [cmd], { stdio: 'pipe' }) + return result.status === 0 + } catch { + return false + } +} + +function run(cmd: string, args: string[], opts: { stdio?: 'pipe' | 'inherit'; cwd?: string } = {}): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync(cmd, args, { + stdio: opts.stdio ?? 'pipe', + cwd: opts.cwd, + encoding: 'utf-8', + }) + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +function ensureBun(): void { + if (hasCommand('bun')) return + + info('Bun not found. Installing...') + const install = spawnSync('curl', ['-fsSL', 'https://bun.sh/install'], { + stdio: 'pipe', + shell: true, + }) + if (install.status !== 0) { + die('Failed to install Bun. Install manually: https://bun.sh') + } + // Re-check after install + const homeBin = join(homedir(), '.bun', 'bin') + process.env.PATH = `${homeBin}:${process.env.PATH}` + if (!hasCommand('bun')) { + die('Bun installed but not found in PATH. Restart your shell or add ~/.bun/bin to PATH.') + } + info('Bun installed successfully.') +} + +function ensureOpencode(): void { + if (hasCommand('opencode')) { + // Check version + const v = run('opencode', ['--version']) + const match = v.stdout.match(/(\d+\.\d+\.\d+)/) + if (match) { + const ver = match[1] + if (ver >= '1.0.137') { + info(`OpenCode ${ver} found.`) + return + } + warn(`OpenCode ${ver} is below recommended >=1.0.137. Upgrading...`) + } + } else { + info('OpenCode not found. Installing...') + } + + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + const platform = process.platform === 'darwin' ? 'darwin' : 'linux' + const url = `https://github.com/anomalyco/opencode/releases/latest/download/opencode-${platform}-${arch}.tar.gz` + + const curl = spawnSync('curl', ['-fsSL', url, '-o', '/tmp/opencode.tar.gz'], { stdio: 'pipe' }) + if (curl.status !== 0) { + die('Failed to download OpenCode.') + } + + const tar = spawnSync('tar', ['-xzf', '/tmp/opencode.tar.gz', '-C', '/tmp'], { stdio: 'pipe' }) + if (tar.status !== 0) { + die('Failed to extract OpenCode.') + } + + const binDir = join(homedir(), '.local', 'bin') + mkdirSync(binDir, { recursive: true }) + + const mv = spawnSync('mv', ['/tmp/opencode', join(binDir, 'opencode')], { stdio: 'pipe' }) + if (mv.status !== 0) { + die('Failed to install OpenCode binary.') + } + + spawnSync('chmod', ['755', join(binDir, 'opencode')], { stdio: 'pipe' }) + + process.env.PATH = `${binDir}:${process.env.PATH}` + info('OpenCode installed successfully.') +} + +function ensureGit(): void { + if (hasCommand('git')) return + die('Git is not installed. Please install Git and try again.') +} + +function getPackageDir(): string { + // When run via bunx, import.meta.dir is the bin/ directory inside the cached package + return resolve(import.meta.dir, '..') +} + +function ensureDataDir(pkgDir: string): { dataDir: string; workspaceDir: string } { + const dataDir = process.env.DATA_DIR ?? join(homedir(), '.opencode-manager') + const workspaceDir = process.env.WORKSPACE_PATH ?? join(dataDir, 'workspace') + + mkdirSync(dataDir, { recursive: true }) + mkdirSync(join(dataDir, 'repos'), { recursive: true }) + mkdirSync(join(dataDir, 'data'), { recursive: true }) + mkdirSync(workspaceDir, { recursive: true }) + mkdirSync(join(workspaceDir, '.config', 'opencode'), { recursive: true }) + + return { dataDir, workspaceDir } +} + +function ensureEnvFile(dataDir: string): void { + const envPath = join(dataDir, '.env') + if (existsSync(envPath)) return + + const secret = randomBytes(32).toString('base64').slice(0, 32) + const content = [ + `NODE_ENV=production`, + `PORT=5003`, + `HOST=0.0.0.0`, + `DATABASE_PATH=${join(dataDir, 'data', 'opencode.db')}`, + `WORKSPACE_PATH=${join(dataDir, 'workspace')}`, + `AUTH_SECRET=${secret}`, + `OPENCODE_SERVER_PORT=5551`, + `OPENCODE_HOST=127.0.0.1`, + ].join('\n') + '\n' + + writeFileSync(envPath, content) + info(`Created ${envPath}`) + info(`AUTH_SECRET generated. Edit ${envPath} to customize.`) +} + +function installDeps(pkgDir: string): void { + const nodeModules = join(pkgDir, 'node_modules') + if (existsSync(nodeModules)) { + info('Dependencies already installed.') + return + } + + info('Installing dependencies...') + const result = run('bun', ['install', '--frozen-lockfile'], { cwd: pkgDir, stdio: 'inherit' }) + if (result.status !== 0) { + // Fallback without frozen lockfile + const fallback = run('bun', ['install'], { cwd: pkgDir, stdio: 'inherit' }) + if (fallback.status !== 0) { + die('Failed to install dependencies.') + } + } + info('Dependencies installed.') +} + +function buildFrontend(pkgDir: string): void { + const distDir = join(pkgDir, 'frontend', 'dist') + if (existsSync(distDir)) { + info('Frontend already built.') + return + } + + info('Building frontend...') + const result = run('bun', ['run', 'build:frontend'], { cwd: pkgDir, stdio: 'inherit' }) + if (result.status !== 0) { + die('Failed to build frontend.') + } + info('Frontend built.') +} + +async function startServer(pkgDir: string, dataDir: string): Promise { + const envFile = join(dataDir, '.env') + if (existsSync(envFile)) { + // Load env file + const content = readFileSync(envFile, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eqIdx = trimmed.indexOf('=') + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const val = trimmed.slice(eqIdx + 1).trim() + if (!process.env[key]) { + process.env[key] = val + } + } + } + + // Ensure production mode + if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production' + + const port = process.env.PORT ?? '5003' + const host = process.env.HOST ?? '0.0.0.0' + + info(`Starting OpenCode Manager on http://${host}:${port}`) + info(`Data directory: ${dataDir}`) + info('Press Ctrl+C to stop.') + + const backendEntry = join(pkgDir, 'backend', 'src', 'index.ts') + const child = spawn('bun', ['run', backendEntry], { + cwd: pkgDir, + stdio: 'inherit', + env: { ...process.env }, + }) + + child.on('close', (code) => process.exit(code ?? 0)) + child.on('error', (err) => die(`Failed to start server: ${err.message}`)) +} + +async function main(): Promise { + const args = process.argv.slice(2) + + if (args.includes('--help') || args.includes('-h')) { + info(USAGE) + return + } + + if (args.includes('--version') || args.includes('-v')) { + info(VERSION) + return + } + + const pkgDir = getPackageDir() + + info('Checking prerequisites...') + ensureGit() + ensureBun() + ensureOpencode() + + const { dataDir } = ensureDataDir(pkgDir) + ensureEnvFile(dataDir) + + installDeps(pkgDir) + buildFrontend(pkgDir) + await startServer(pkgDir, dataDir) +} + +main().catch((err) => die(err instanceof Error ? err.message : String(err))) diff --git a/package.json b/package.json index 458dd3b0..ac3bb86e 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,21 @@ "private": false, "type": "module", "packageManager": "pnpm@10.28.1", + "bin": { + "opencode-manager": "./bin/opencode-manager" + }, + "files": [ + "bin", + "backend/src", + "shared/src", + "frontend/dist", + "frontend/package.json", + "shared/package.json", + "backend/package.json", + "package.json", + "pnpm-workspace.yaml", + "pnpm-lock.yaml" + ], "scripts": { "predev": "bash scripts/setup-dev.sh", "dev": "concurrently \"pnpm:dev:backend\" \"pnpm:dev:frontend\"", @@ -34,7 +49,8 @@ "docker:up": "docker-compose up -d", "docker:down": "docker-compose down -v", "docker:logs": "docker-compose logs -f", - "docker:restart": "docker-compose restart" + "docker:restart": "docker-compose restart", + "prepack": "bun run scripts/build-npm.ts" }, "devDependencies": { "concurrently": "^9.1.0" diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts new file mode 100644 index 00000000..0aba043b --- /dev/null +++ b/scripts/build-npm.ts @@ -0,0 +1,27 @@ +import { spawnSync } from 'child_process' +import { existsSync } from 'fs' +import { resolve } from 'path' + +const root = resolve(import.meta.dir, '..') + +function run(cmd: string, args: string[]): void { + const result = spawnSync(cmd, args, { + cwd: root, + stdio: 'inherit', + encoding: 'utf-8', + }) + if (result.status !== 0) { + console.error(`Failed: ${cmd} ${args.join(' ')}`) + process.exit(1) + } +} + +const frontendDist = resolve(root, 'frontend', 'dist') +if (!existsSync(frontendDist)) { + console.log('Building frontend for npm publish...') + run('bun', ['run', 'build:frontend']) +} else { + console.log('Frontend already built, skipping.') +} + +console.log('npm package ready for publish.') From 3457628ef2622cdd032304ca7012935147e26793 Mon Sep 17 00:00:00 2001 From: Jelloeater Agent Date: Fri, 24 Jul 2026 02:47:14 -0400 Subject: [PATCH 2/2] docs: add bare-metal from-git quick-start section --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 5c640930..1fccd342 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ bunx opencode-manager This installs prerequisites (Bun, OpenCode, Git), sets up `~/.opencode-manager/`, and starts the server. Open `http://localhost:5003`. +### Bare metal (from git) + +```bash +git clone https://github.com/chriswritescode-dev/opencode-manager.git +cd opencode-manager +./bin/opencode-manager +``` + +Same bootstrap as `bunx` — installs prereqs, creates `~/.opencode-manager/`, builds frontend, and starts the server. Edit `~/.opencode-manager/.env` to customize ports, auth, etc. + ### Docker ```bash