Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@

## 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`.

Copy link
Copy Markdown
Contributor

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 41, Update the bootstrap descriptions at README.md lines
41-41 and 51-51 to state that Git is required, not installed by the launcher;
keep the existing claims about installing Bun and OpenCode and starting the
server.


### 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
git clone https://github.com/chriswritescode-dev/opencode-manager.git
cd opencode-manager
Expand Down
18 changes: 18 additions & 0 deletions bin/opencode-manager
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 curl (or attempt installation directly) instead.

Proposed fix
-elif command -v node &>/dev/null; then
+elif command -v curl &>/dev/null; then
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
elif command -v curl &>/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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/opencode-manager` around lines 10 - 17, Update the command-selection
logic in the opencode-manager launcher so Bun installation is not gated on Node
being available. Use curl availability, or attempt the Bun installer directly,
allowing fresh machines without Bun or Node to install Bun; retain the existing
error path only when the installer cannot be run.

fi
268 changes: 268 additions & 0 deletions bin/opencode-manager.ts
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

Copy link
Copy Markdown
Contributor

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

Remove the added implementation comments.

The TypeScript rules require self-documenting code without comments.

  • bin/opencode-manager.ts#L69-L69: remove the installation re-check comment.
  • bin/opencode-manager.ts#L80-L80: remove the version-check comment.
  • bin/opencode-manager.ts#L129-L129: remove the package-directory context comment.
  • bin/opencode-manager.ts#L177-L177: remove the fallback-install comment.
  • bin/opencode-manager.ts#L204-L204: remove the env-loading comment.
  • bin/opencode-manager.ts#L219-L219: remove the production-mode comment.

As per coding guidelines: “Do not add comments; code should be self-documenting.”

📍 Affects 1 file
  • bin/opencode-manager.ts#L69-L69 (this comment)
  • bin/opencode-manager.ts#L80-L80
  • bin/opencode-manager.ts#L129-L129
  • bin/opencode-manager.ts#L177-L177
  • bin/opencode-manager.ts#L204-L204
  • bin/opencode-manager.ts#L219-L219
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/opencode-manager.ts` at line 69, Remove the added implementation comments
in bin/opencode-manager.ts at lines 69, 80, 129, 177, 204, and 219, including
the installation re-check, version-check, package-directory, fallback-install,
env-loading, and production-mode comments. Leave the surrounding
self-documenting code and behavior unchanged.

Source: 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') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare OpenCode versions numerically.

Line 85 accepts 1.0.9 as newer than 1.0.137 via lexicographic comparison, so an unsupported OpenCode version is not upgraded. Use a numeric semver comparison.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/opencode-manager.ts` at line 85, Update the version check around the
`ver` comparison to use numeric semantic-version ordering rather than string
lexicographic comparison, ensuring versions such as `1.0.9` are correctly
recognized as older than `1.0.137` and upgraded when required.

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 } {

Copy link
Copy Markdown
Contributor

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

Remove the unused pkgDir parameter.

ensureDataDir never uses pkgDir; remove it from the function and its call site.

As per coding guidelines: “Do not leave dead code, commented-out blocks, unused variables, or unused imports.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/opencode-manager.ts` at line 133, Remove the unused pkgDir parameter from
the ensureDataDir function signature and update its call site to invoke it
without that argument. Preserve the function’s existing dataDir and workspaceDir
behavior.

Source: 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict permissions on the generated secret file.

writeFileSync creates .env using the process umask, commonly making AUTH_SECRET readable by other local users. Write it with mode 0o600.

Proposed fix
-  writeFileSync(envPath, content)
+  writeFileSync(envPath, content, { mode: 0o600 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
writeFileSync(envPath, content)
writeFileSync(envPath, content, { mode: 0o600 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/opencode-manager.ts` at line 162, Update the writeFileSync call that
creates the generated secret file at envPath to explicitly set file mode 0o600,
ensuring only the owner can read or write the .env contents.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 code === null, and Line 236 exits the CLI with status 0. Preserve a non-zero failure status when signal is set.

Proposed fix
-  child.on('close', (code) => process.exit(code ?? 0))
+  child.on('close', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
child.on('close', (code) => process.exit(code ?? 0))
child.on('close', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/opencode-manager.ts` at line 236, Update the child process close handler
in bin/opencode-manager.ts so signal termination does not map to exit status 0:
use the close callback’s signal information to return a non-zero failure status
when signal is set, while preserving the child exit code for normal exits.

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)))
18 changes: 17 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions scripts/build-npm.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

prepack can run after frontend source changes while frontend/dist still exists, causing npm to publish stale frontend assets. Always rebuild during packaging, or validate freshness with a source hash/manifest.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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'])
🧰 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.
Context: import { spawnSync } from 'child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build-npm.ts` around lines 19 - 25, The frontend packaging flow
currently skips rebuilding based only on frontendDist existence, which can
publish stale assets. Update the prepack/build logic around frontendDist and the
frontend build command to always rebuild during packaging, or replace the
existence check with reliable source freshness validation such as a hash or
manifest.


console.log('npm package ready for publish.')