Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ LOG_LEVEL=info
OPENCODE_SERVER_PORT=5551
OPENCODE_HOST=127.0.0.1

# Optional - import an existing standalone OpenCode install on first startup
# Useful for Docker when your host OpenCode data is bind-mounted into the container
# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json
# OPENCODE_IMPORT_STATE_PATH=/import/opencode-state

# ============================================
# Database
# ============================================
Expand All @@ -27,6 +32,11 @@ DATABASE_PATH=./data/opencode.db
# ============================================
WORKSPACE_PATH=./workspace

# Optional - convenience vars for Docker bind mounts documented in docs/configuration/docker.md
# OCM_REPOS_HOST_PATH=/Users/you/Development
# OCM_OPENCODE_CONFIG_HOST_PATH=/Users/you/.config/opencode
# OCM_OPENCODE_STATE_HOST_PATH=/Users/you/.local/share/opencode

# ============================================
# Timeouts (milliseconds)
# ============================================
Expand Down
27 changes: 27 additions & 0 deletions backend/src/db/migrations/009-repo-source-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Migration } from '../migration-runner'

interface ColumnInfo {
name: string
}

const migration: Migration = {
version: 9,
name: 'repo-source-path',

up(db) {
const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[]
const existing = new Set(tableInfo.map((column) => column.name))

if (!existing.has('source_path')) {
db.run('ALTER TABLE repos ADD COLUMN source_path TEXT')
}

db.run('CREATE INDEX IF NOT EXISTS idx_repo_source_path ON repos(source_path)')
},

down(db) {
db.run('DROP INDEX IF EXISTS idx_repo_source_path')
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import migration003 from './003-repos-add-columns'
import migration004 from './004-repos-indexes'
import migration005 from './005-repos-local-path-prefix'
import migration006 from './006-git-token-to-credentials'
import migration009 from './009-repo-source-path'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -13,4 +14,5 @@ export const allMigrations: Migration[] = [
migration004,
migration005,
migration006,
migration009,
]
28 changes: 22 additions & 6 deletions backend/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface RepoRow {
id: number
repo_url?: string
local_path: string
source_path?: string
branch?: string
default_branch: string
clone_status: string
Expand All @@ -19,11 +20,14 @@ export interface RepoRow {
}

function rowToRepo(row: RepoRow): Repo {
const fullPath = row.source_path || path.join(getReposPath(), row.local_path)

return {
id: row.id,
repoUrl: row.repo_url,
localPath: row.local_path,
fullPath: path.join(getReposPath(), row.local_path),
fullPath,
sourcePath: row.source_path,
branch: row.branch,
defaultBranch: row.default_branch,
cloneStatus: row.clone_status as Repo['cloneStatus'],
Expand All @@ -39,22 +43,25 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
const normalizedPath = repo.localPath.trim().replace(/\/+$/, '')

const existing = repo.isLocal
? getRepoByLocalPath(db, normalizedPath)
? repo.sourcePath
? getRepoBySourcePath(db, repo.sourcePath) ?? getRepoByLocalPath(db, normalizedPath)
: getRepoByLocalPath(db, normalizedPath)
: getRepoByUrlAndBranch(db, repo.repoUrl, repo.branch)

if (existing) {
return existing
}

const stmt = db.prepare(`
INSERT INTO repos (repo_url, local_path, branch, default_branch, clone_status, cloned_at, is_worktree, is_local)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO repos (repo_url, local_path, source_path, branch, default_branch, clone_status, cloned_at, is_worktree, is_local)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)

try {
const result = stmt.run(
repo.repoUrl || null,
normalizedPath,
repo.sourcePath || null,
repo.branch || null,
repo.defaultBranch,
repo.cloneStatus,
Expand All @@ -72,7 +79,9 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo {
const errorMessage = getErrorMessage(error)
if (errorMessage.includes('UNIQUE constraint failed') || (error && typeof error === 'object' && 'code' in error && error.code === 'SQLITE_CONSTRAINT_UNIQUE')) {
const conflictRepo = repo.isLocal
? getRepoByLocalPath(db, normalizedPath)
? repo.sourcePath
? getRepoBySourcePath(db, repo.sourcePath) ?? getRepoByLocalPath(db, normalizedPath)
: getRepoByLocalPath(db, normalizedPath)
: getRepoByUrlAndBranch(db, repo.repoUrl, repo.branch)

if (conflictRepo) {
Expand Down Expand Up @@ -114,6 +123,13 @@ export function getRepoByLocalPath(db: Database, localPath: string): Repo | null
return row ? rowToRepo(row) : null
}

export function getRepoBySourcePath(db: Database, sourcePath: string): Repo | null {
const stmt = db.prepare('SELECT * FROM repos WHERE source_path = ?')
const row = stmt.get(sourcePath) as RepoRow | undefined

return row ? rowToRepo(row) : null
}

export function listRepos(db: Database, repoOrder?: number[]): Repo[] {
const stmt = db.prepare('SELECT * FROM repos ORDER BY cloned_at DESC')
const rows = stmt.all() as RepoRow[]
Expand Down Expand Up @@ -146,7 +162,7 @@ export function listRepos(db: Database, repoOrder?: number[]): Repo[] {
function getRepoName(repo: Repo): string {
return repo.repoUrl
? repo.repoUrl.split('/').slice(-1)[0]?.replace('.git', '') || repo.localPath
: repo.localPath
: repo.sourcePath ? path.basename(repo.sourcePath) : repo.localPath
}

export function updateRepoStatus(db: Database, id: number, cloneStatus: Repo['cloneStatus']): void {
Expand Down
116 changes: 109 additions & 7 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { cors } from 'hono/cors'
import { serveStatic } from '@hono/node-server/serve-static'
import os from 'os'
import path from 'path'
import { readFile } from 'fs/promises'
import { cp, readdir, readFile, rm } from 'fs/promises'
import { Database as SQLiteDatabase } from 'bun:sqlite'
import { initializeDatabase } from './db/schema'
import { createRepoRoutes } from './routes/repos'
import { createIPCServer, type IPCServer } from './ipc/ipcServer'
Expand Down Expand Up @@ -81,6 +82,71 @@ import { DEFAULT_AGENTS_MD } from './constants'

let ipcServer: IPCServer | undefined
const gitAuthService = new GitAuthService()
const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal'])

function getImportPathCandidates(envKey: string, fallbackPath: string): string[] {
const candidates = [process.env[envKey], fallbackPath]
.filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value))

return Array.from(new Set(candidates))
}

async function getFirstExistingPath(paths: string[]): Promise<string | undefined> {
for (const candidate of paths) {
if (await fileExists(candidate)) {
return candidate
}
}

return undefined
}

function escapeSqliteValue(value: string): string {
return value.replace(/'/g, "''")
}

async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise<void> {
const entries = await readdir(sourcePath, { withFileTypes: true })

for (const entry of entries) {
if (OPENCODE_STATE_DB_FILENAMES.has(entry.name)) {
continue
}

await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), {
recursive: true,
force: false,
errorOnExist: false,
})
}
}

async function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): Promise<void> {
await rm(targetPath, { force: true })

const database = new SQLiteDatabase(sourcePath)

try {
database.exec(`VACUUM INTO '${escapeSqliteValue(targetPath)}'`)
} finally {
database.close()
}
}

async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise<void> {
await ensureDirectoryExists(targetPath)
await copyOpenCodeStateFiles(sourcePath, targetPath)

const sourceDbPath = path.join(sourcePath, 'opencode.db')
if (!await fileExists(sourceDbPath)) {
return
}

await rm(path.join(targetPath, 'opencode.db-shm'), { force: true })
await rm(path.join(targetPath, 'opencode.db-wal'), { force: true })
await snapshotOpenCodeDatabase(sourceDbPath, path.join(targetPath, 'opencode.db'))
}

async function ensureDefaultConfigExists(): Promise<void> {
const settingsService = new SettingsService(db)
Expand Down Expand Up @@ -118,11 +184,17 @@ async function ensureDefaultConfigExists(): Promise<void> {
}
}

const homeConfigPath = path.join(os.homedir(), '.config/opencode/opencode.json')
if (await fileExists(homeConfigPath)) {
logger.info(`Found home config at ${homeConfigPath}, importing...`)
const importConfigPath = await getFirstExistingPath(
getImportPathCandidates(
'OPENCODE_IMPORT_CONFIG_PATH',
path.join(os.homedir(), '.config/opencode/opencode.json')
)
)

if (importConfigPath) {
logger.info(`Found importable OpenCode config at ${importConfigPath}, importing...`)
try {
const rawContent = await readFileContent(homeConfigPath)
const rawContent = await readFileContent(importConfigPath)
const parsed = parseJsonc(rawContent)
const validation = OpenCodeConfigSchema.safeParse(parsed)

Expand All @@ -142,11 +214,11 @@ async function ensureDefaultConfigExists(): Promise<void> {
}

await writeFileContent(workspaceConfigPath, rawContent)
logger.info('Imported home config to workspace')
logger.info(`Imported OpenCode config from ${importConfigPath} to workspace`)
return
}
} catch (error) {
logger.warn('Failed to import home config', error)
logger.warn(`Failed to import OpenCode config from ${importConfigPath}`, error)
}
}

Expand All @@ -171,6 +243,35 @@ async function ensureDefaultConfigExists(): Promise<void> {
logger.info('Created minimal seed config')
}

async function ensureHomeStateImported(): Promise<void> {
try {
const workspaceStateRoot = path.join(getWorkspacePath(), '.opencode', 'state')
const workspaceStatePath = path.join(workspaceStateRoot, 'opencode')
const workspaceStateDbPath = path.join(workspaceStatePath, 'opencode.db')

if (await fileExists(workspaceStateDbPath)) {
return
}

const importStatePath = await getFirstExistingPath(
getImportPathCandidates(
'OPENCODE_IMPORT_STATE_PATH',
path.join(os.homedir(), '.local', 'share', 'opencode')
)
)

if (!importStatePath) {
return
}

await ensureDirectoryExists(workspaceStateRoot)
await importOpenCodeStateDirectory(importStatePath, workspaceStatePath)
logger.info(`Imported OpenCode state from ${importStatePath}`)
} catch (error) {
logger.warn('Failed to import OpenCode state, continuing without imported state', error)
}
}

async function ensureDefaultAgentsMdExists(): Promise<void> {
const agentsMdPath = getAgentsMdPath()
const exists = await fileExists(agentsMdPath)
Expand All @@ -197,6 +298,7 @@ try {
await cleanupExpiredCache()

await ensureDefaultConfigExists()
await ensureHomeStateImported()
await ensureDefaultAgentsMdExists()

const settingsService = new SettingsService(db)
Expand Down
32 changes: 28 additions & 4 deletions backend/src/routes/repos.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { Database } from 'bun:sqlite'
import { DiscoverReposRequestSchema } from '@opencode-manager/shared/schemas'
import * as db from '../db/queries'
import * as repoService from '../services/repo'
import * as archiveService from '../services/archive'
Expand All @@ -10,7 +11,7 @@ import { opencodeServerManager } from '../services/opencode-single-server'
import { proxyToOpenCodeWithDirectory } from '../services/proxy'
import { logger } from '../utils/logger'
import { getErrorMessage, getStatusCode } from '../utils/error-utils'
import { getOpenCodeConfigFilePath, getReposPath } from '@opencode-manager/shared/config/env'
import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env'
import { createRepoGitRoutes } from './repo-git'
import type { GitAuthService } from '../services/git-auth'
import path from 'path'
Expand Down Expand Up @@ -68,7 +69,30 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ
return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode)
}
})


app.post('/discover', async (c) => {
try {
const body = await c.req.json()
const result = DiscoverReposRequestSchema.safeParse(body)

if (!result.success) {
return c.json({ error: result.error.issues[0]?.message || 'Invalid request' }, 400)
}

const discovery = await repoService.discoverLocalRepos(
database,
gitAuthService,
result.data.rootPath,
result.data.maxDepth
)

return c.json(discovery)
} catch (error: unknown) {
logger.error('Failed to discover repos:', error)
return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode)
}
})

app.get('/', async (c) => {
try {
const settingsService = new SettingsService(database)
Expand Down Expand Up @@ -267,8 +291,8 @@ app.get('/', async (c) => {
return c.json({ error: 'Repo not found' }, 404)
}

const repoPath = path.resolve(getReposPath(), repo.localPath)
const repoName = path.basename(repo.localPath)
const repoPath = repo.fullPath
const repoName = path.basename(repo.fullPath)

const includeGit = c.req.query('includeGit') === 'true'
const includePathsParam = c.req.query('includePaths')
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "@opencode-manager/shared/schemas";
import { SettingsService } from "./settings";
import { sseAggregator, type SSEEvent } from "./sse-aggregator";
import { getRepoByLocalPath } from "../db/queries";
import { getRepoByLocalPath, getRepoBySourcePath } from "../db/queries";
import { getReposPath } from "@opencode-manager/shared/config/env";
import path from "path";

Expand Down Expand Up @@ -221,7 +221,7 @@ export class NotificationService {
if (_directory) {
const reposBasePath = getReposPath();
const localPath = path.relative(reposBasePath, _directory);
const repo = getRepoByLocalPath(this.db, localPath);
const repo = getRepoBySourcePath(this.db, path.resolve(_directory)) ?? getRepoByLocalPath(this.db, localPath);

if (repo) {
repoId = repo.id;
Expand Down
Loading