Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ For OAuth, Passkeys, Push Notifications (VAPID), and advanced configuration, see

## `ocm` CLI

OpenCode Manager ships an `ocm` CLI (from `ocm-cli/`) that attaches your local OpenCode TUI to a repo hosted on the Manager. It lists ready repos, attaches via the Manager's `/api/opencode-proxy` (so prompts run on the Manager's filesystem against a single shared OpenCode server), and can tarball-sync the working tree up or down with `ocm push` / `ocm pull`. Running `ocm` inside a local clone auto-detects the matching Manager repo by `origin` URL.
OpenCode Manager ships an `ocm` CLI (from `ocm-cli/`) that attaches your local OpenCode TUI to a repo hosted on the Manager. It lists ready repos, attaches via the Manager's `/api/opencode-proxy` (so prompts run on the Manager's filesystem against a single shared OpenCode server), and can sync the working tree up or down with `ocm push` / `ocm pull` (fast git bundle + working-tree patch by default; pass `--full` for the legacy tarball mirror). Running `ocm` inside a local clone auto-detects the matching Manager repo by `origin` URL.

See the [`ocm` CLI guide](docs/ocm-cli.md) for setup and commands.

Expand Down
14 changes: 8 additions & 6 deletions backend/src/routes/internal/opencode-workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { listRepos } from '../../db/queries'
import { resolveProjectId } from '../../services/project-id-resolver'
import { logger } from '../../utils/logger'
import { getErrorMessage } from '../../utils/error-utils'
import path from 'path'

export function createInternalOpenCodeWorkspacesRoutes(db: Database) {
const app = new Hono()

app.get('/', (c) => {
app.get('/', async (c) => {
try {
const repos = listRepos(db)
const workspaces = repos
.filter((repo) => repo.cloneStatus === 'ready')
.map((repo) => ({
const repos = listRepos(db).filter((repo) => repo.cloneStatus === 'ready')
const workspaces = await Promise.all(
repos.map(async (repo) => ({
repoId: repo.id,
name: repo.repoUrl
? repo.repoUrl.split('/').slice(-1)[0]?.replace('.git', '') || repo.localPath
Expand All @@ -24,12 +24,14 @@ export function createInternalOpenCodeWorkspacesRoutes(db: Database) {
cloneStatus: repo.cloneStatus,
directory: repo.fullPath,
originUrl: repo.repoUrl ?? null,
projectId: await resolveProjectId(repo.fullPath).catch(() => null),
extra: {
repoId: repo.id,
localPath: repo.localPath,
fullPath: repo.fullPath,
},
}))
})),
)
return c.json({ workspaces })
} catch (error) {
logger.error('Failed to list opencode workspaces:', error)
Expand Down
265 changes: 263 additions & 2 deletions backend/src/routes/internal/repo-mirror.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { spawn } from 'child_process'
import { createWriteStream } from 'fs'
import { copyFileSync, createReadStream, createWriteStream, existsSync } from 'fs'
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
Expand All @@ -19,6 +19,7 @@ import {
readUploadMeta,
deleteUploadSession,
getPartPath,
getStagingRoot,
extractPartsToStaging,
atomicSwapIntoPlace,
carryOverIgnoredFiles,
Expand All @@ -42,8 +43,97 @@ interface CommitBody {
gzip?: boolean
}

interface PatchBody {
baseHead?: string | null
patch?: string
force?: boolean
}

const LEGACY_UPGRADE_MESSAGE = 'this ocm CLI is too old for this server; upgrade to ocm-cli >= 0.1.2 (the mirror upload protocol changed to chunked uploads)'

function gitRaw(repoPath: string, args: string[], env: NodeJS.ProcessEnv = process.env, input?: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd: repoPath, env })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', reject)
child.on('close', (code) => {
if (code === 0) resolve(stdout)
else reject(new Error(stderr.trim() || `git exited with code ${code}`))
})
if (input !== undefined) child.stdin.end(input)
})
}

async function createMirrorPatch(fullPath: string): Promise<string> {
const untracked = (await gitRaw(fullPath, ['ls-files', '--others', '--exclude-standard', '-z']).catch(() => ''))
.split('\0')
.filter(Boolean)
if (untracked.length === 0) return gitRaw(fullPath, ['diff', '--binary', 'HEAD', '--'])

const indexPath = (await safeGitOut(fullPath, ['rev-parse', '--git-path', 'index']))?.trim()
const tempIndexDir = mkdtempSync(join(getReposPath(), '.ocm-index-'))
const tempIndex = join(tempIndexDir, 'index')
const env = { ...process.env, GIT_INDEX_FILE: tempIndex }

try {
if (indexPath && existsSync(join(fullPath, indexPath))) {
copyFileSync(join(fullPath, indexPath), tempIndex)
}
await gitRaw(fullPath, ['add', '-N', '--', ...untracked], env)
return gitRaw(fullPath, ['diff', '--binary', 'HEAD', '--'], env)
} finally {
await fsp.rm(tempIndexDir, { recursive: true, force: true }).catch(() => {})
}
}

async function applyMirrorPatch(fullPath: string, patch: string): Promise<void> {
if (!patch) return
await gitRaw(fullPath, ['apply', '--binary', '--whitespace=nowarn', '-'], process.env, patch)
}

async function importBundle(fullPath: string, bundlePath: string, branch: string | null): Promise<void> {
await gitRaw(fullPath, ['fetch', bundlePath, '+refs/heads/*:refs/remotes/ocm-sync/*', '+refs/tags/*:refs/tags/*'])
const refs = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/ocm-sync'])
const updates: string[] = []
for (const line of refs.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const firstSpace = trimmed.indexOf(' ')
if (firstSpace === -1) continue
const name = trimmed.slice(0, firstSpace)
if (name === 'HEAD') continue
const sha = trimmed.slice(firstSpace + 1)
updates.push(`update refs/heads/${name} ${sha}\n`)
}
if (updates.length > 0) {
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, updates.join(''))
}

if (branch) {
await gitRaw(fullPath, ['checkout', branch])
const head = (await gitRaw(fullPath, ['rev-parse', `refs/remotes/ocm-sync/${branch}`])).trim()
if (head) await gitRaw(fullPath, ['reset', '--hard', head])
}

const syncRefsOut = await gitRaw(fullPath, ['for-each-ref', '--format=%(refname)', 'refs/remotes/ocm-sync']).catch(() => '')
const deletes = syncRefsOut.split('\n').map((l) => l.trim()).filter(Boolean).map((ref) => `delete ${ref}\n`)
if (deletes.length > 0) {
await gitRaw(fullPath, ['update-ref', '--stdin'], process.env, deletes.join('')).catch(() => {})
}
}

async function createBundle(fullPath: string): Promise<string> {
const stagingRoot = getStagingRoot()
mkdirSync(stagingRoot, { recursive: true })
const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-'))
const bundlePath = join(bundleDir, 'repo.bundle')
await gitRaw(fullPath, ['bundle', 'create', bundlePath, '--all'])
return bundlePath
}

export function createInternalRepoMirrorRoutes(db: Database) {
const app = new Hono()

Expand Down Expand Up @@ -214,6 +304,177 @@ export function createInternalRepoMirrorRoutes(db: Database) {
return c.json({ ok: true })
})

app.get('/:repoId/mirror/bundle', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)

let bundlePath: string | undefined
try {
bundlePath = await createBundle(repo.fullPath)
const stream = createReadStream(bundlePath)
stream.on('close', () => {
if (bundlePath) fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {})
})
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: { 'Content-Type': 'application/octet-stream' },
})
} catch (error) {
logger.error('mirror bundle download failed:', error)
if (bundlePath) await fsp.rm(join(bundlePath, '..'), { recursive: true, force: true }).catch(() => {})
return c.json({ error: getErrorMessage(error) }, 500)
}
})

app.post('/:repoId/mirror/bundle', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)
if (isRepoInUse(db, repoId) && c.req.query('force') !== '1') {
return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409)
}

const rawBody = c.req.raw.body
if (!rawBody) return c.json({ error: 'no body provided' }, 400)

const stagingRoot = getStagingRoot()
mkdirSync(stagingRoot, { recursive: true })
const bundleDir = mkdtempSync(join(stagingRoot, 'bundle-upload-'))
const bundlePath = join(bundleDir, 'repo.bundle')
const branch = c.req.header('x-ocm-branch')?.trim() || null

try {
const body = Readable.fromWeb(rawBody as unknown as Parameters<typeof Readable.fromWeb>[0])
await pipeline(body, createWriteStream(bundlePath))
await importBundle(repo.fullPath, bundlePath, branch)

const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
if (branchName) updateRepoBranch(db, repoId, branchName.trim())
updateLastPulled(db, repoId)

return c.json({
repoId,
fullPath: repo.fullPath,
branch: branchName?.trim() || null,
head: head?.trim() || null,
created: false,
})
} catch (error) {
logger.error('mirror bundle upload failed:', error)
return c.json({ error: getErrorMessage(error) }, 409)
} finally {
await fsp.rm(bundleDir, { recursive: true, force: true }).catch(() => {})
}
})

app.get('/:repoId/mirror/head', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)

const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
const status = await safeGitOut(repo.fullPath, ['status', '--porcelain', '--untracked-files=all'])
return c.json({
repoId: repo.id,
branch: branchName?.trim() || null,
head: head?.trim() || null,
dirty: (status?.trim().length ?? 0) > 0,
})
})

app.get('/:repoId/mirror/contains/:sha', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const sha = c.req.param('sha')
if (!/^[0-9a-f]{7,64}$/i.test(sha)) return c.json({ error: 'invalid sha' }, 400)
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)

const ancestry = await safeGitOut(repo.fullPath, ['merge-base', '--is-ancestor', sha, 'HEAD'])
return c.json({ repoId: repo.id, contained: ancestry !== null })
})

app.get('/:repoId/mirror/patch', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)
const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)

try {
const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
const patch = await createMirrorPatch(repo.fullPath)
return c.json({
repoId: repo.id,
branch: branchName?.trim() || null,
head: head?.trim() || null,
patch,
})
} catch (error) {
logger.error('mirror patch snapshot failed:', error)
return c.json({ error: getErrorMessage(error) }, 500)
}
})

app.post('/:repoId/mirror/patch', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
if (!Number.isFinite(repoId)) return c.json({ error: 'invalid repoId' }, 400)

let body: PatchBody
try {
body = (await c.req.json()) as PatchBody
} catch {
return c.json({ error: 'invalid json body' }, 400)
}

const repo = getRepoById(db, repoId)
if (!repo) return c.json({ error: 'repo not found' }, 404)
if (!body.patch && body.patch !== '') return c.json({ error: 'patch required' }, 400)
if (body.force !== true && isRepoInUse(db, repoId)) {
return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409)
}

try {
const currentHead = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])
const currentHeadTrimmed = currentHead?.trim() || null
const baseHead = body.baseHead?.trim() || null
if (baseHead && currentHeadTrimmed && baseHead !== currentHeadTrimmed) {
return c.json({ error: 'head_mismatch', message: 'Manager repo HEAD differs from patch base' }, 409)
}

await applyMirrorPatch(repo.fullPath, body.patch)

const branchName = await safeGitOut(repo.fullPath, ['rev-parse', '--abbrev-ref', 'HEAD'])
const head = await safeGitOut(repo.fullPath, ['rev-parse', 'HEAD'])

if (branchName) updateRepoBranch(db, repoId, branchName.trim())
updateLastPulled(db, repoId)

return c.json({
repoId,
fullPath: repo.fullPath,
branch: branchName?.trim() || null,
head: head?.trim() || null,
created: false,
applied: true,
})
} catch (error) {
logger.error('mirror patch failed:', error)
return c.json({ error: getErrorMessage(error) }, 409)
}
})

app.get('/:repoId/mirror', async (c) => {
const repoIdRaw = c.req.param('repoId')
const repoId = Number(repoIdRaw)
Expand All @@ -233,7 +494,7 @@ export function createInternalRepoMirrorRoutes(db: Database) {
try {
const ignored = await gitOut(fullPath, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'])
if (ignored.trim()) {
const excludeParent = join(getReposPath(), '.ocm-staging')
const excludeParent = getStagingRoot()
mkdirSync(excludeParent, { recursive: true })
ignoreFile = mkdtempSync(join(excludeParent, 'exclude-'))
writeFileSync(join(ignoreFile, '.gitignore'), ignored)
Expand Down
Loading