-
Notifications
You must be signed in to change notification settings - Fork 108
feat: add bare-metal runner — run straight from GitHub #320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+10
to
+17
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not require Node to install Bun. On a fresh machine with neither Bun nor Node, this exits instead of running the Bun installer, even though the installer does not require Node. Gate this branch on Proposed fix-elif command -v node &>/dev/null; then
+elif command -v curl &>/dev/null; then📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Remove the added implementation comments. The TypeScript rules require self-documenting code without comments.
As per coding guidelines: “Do not add comments; code should be self-documenting.” 📍 Affects 1 file
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||
| 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') { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Compare OpenCode versions numerically. Line 85 accepts 🤖 Prompt for AI Agents |
||||||
| 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 } { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Remove the unused
As per coding guidelines: “Do not leave dead code, commented-out blocks, unused variables, or unused imports.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||
| 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) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Restrict permissions on the generated secret file.
Proposed fix- writeFileSync(envPath, content)
+ writeFileSync(envPath, content, { mode: 0o600 })📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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<void> { | ||||||
| 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)) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Do not mask signal termination as success. A backend killed by a signal produces Proposed fix- child.on('close', (code) => process.exit(code ?? 0))
+ child.on('close', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| child.on('error', (err) => die(`Failed to start server: ${err.message}`)) | ||||||
| } | ||||||
|
|
||||||
| async function main(): Promise<void> { | ||||||
| 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))) | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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.') | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+19
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not use directory existence as the freshness check.
Proposed fix-import { existsSync } from 'fs'
import { resolve } from 'path'
const root = resolve(import.meta.dir, '..')
@@
-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('Building frontend for npm publish...')
+run('bun', ['run', 'build:frontend'])📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.44.1)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| console.log('npm package ready for publish.') | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the Git-installation claim.
The launcher does not install Git;
ensureGit()exits when it is unavailable.README.md#L41-L41: state that Git is required rather than installed.README.md#L51-L51: update the repeated bootstrap description accordingly.📍 Affects 1 file
README.md#L41-L41(this comment)README.md#L51-L51🤖 Prompt for AI Agents