diff --git a/.gitignore b/.gitignore index 4f097953..f3d7c92f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ data/ workspace/ /config/ temp/ +.forge/ diff --git a/README.md b/README.md index 7c7846fc..f34c67e3 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,12 @@ AUTH_SECURE_COOKIES=false # Set to true when using HTTPS For OAuth, Passkeys, Push Notifications (VAPID), and advanced configuration, see the [Configuration Guide](https://chriswritescode-dev.github.io/opencode-manager/configuration/environment/). +## `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. + +See the [`ocm` CLI guide](docs/opencode-manager-workspaces.md) for setup and commands. + ## Documentation - [Getting Started](https://chriswritescode-dev.github.io/opencode-manager/getting-started/installation/) — Installation and first-run setup @@ -109,6 +115,7 @@ For OAuth, Passkeys, Push Notifications (VAPID), and advanced configuration, see - [Configuration](https://chriswritescode-dev.github.io/opencode-manager/configuration/environment/) — Environment variables and advanced setup - [Troubleshooting](https://chriswritescode-dev.github.io/opencode-manager/troubleshooting/) — Common issues and solutions - [Development](https://chriswritescode-dev.github.io/opencode-manager/development/setup/) — Contributing and local development +- [`ocm` CLI](docs/opencode-manager-workspaces.md) — Attach local OpenCode TUI to Manager repos ## License diff --git a/backend/package.json b/backend/package.json index a7cb5f36..17d0f0f9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "build": "bun build src/index.ts --outdir=dist --target=bun", "typecheck": "tsc --noEmit", "test": "pnpm run test:bun && pnpm run test:vitest", - "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts src/db/model-state.test.ts src/routes/providers.test.ts", + "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal/repo-mirror.test.ts src/db/model-state.test.ts src/routes/providers.test.ts", "test:vitest": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest --watch", diff --git a/backend/src/auth/internal-token-middleware.ts b/backend/src/auth/internal-token-middleware.ts index 591f0ce2..5398018e 100644 --- a/backend/src/auth/internal-token-middleware.ts +++ b/backend/src/auth/internal-token-middleware.ts @@ -3,17 +3,42 @@ import { timingSafeEqual } from 'node:crypto' import type { Database } from 'bun:sqlite' import { getOrCreateInternalToken } from '../services/internal-token' +function extractTokenFromBasic(header: string): string | null { + if (!header.startsWith('Basic ')) return null + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8') + const colonIndex = decoded.indexOf(':') + if (colonIndex === -1) return null + return decoded.slice(colonIndex + 1) +} + +function tokenMatch(provided: string, expected: string): boolean { + const a = Buffer.from(provided) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + export function createInternalTokenMiddleware(db: Database) { return createMiddleware(async (c, next) => { const header = c.req.header('authorization') ?? c.req.header('Authorization') - if (!header || !header.startsWith('Bearer ')) { + if (!header) { return c.json({ error: 'Unauthorized' }, 401) } - const provided = Buffer.from(header.slice(7)) - const expected = Buffer.from(getOrCreateInternalToken(db)) - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + + const expected = getOrCreateInternalToken(db) + + if (header.startsWith('Bearer ')) { + if (!tokenMatch(header.slice(7), expected)) { + return c.json({ error: 'Unauthorized' }, 401) + } + } else if (header.startsWith('Basic ')) { + const password = extractTokenFromBasic(header) + if (!password || !tokenMatch(password, expected)) { + return c.json({ error: 'Unauthorized' }, 401) + } + } else { return c.json({ error: 'Unauthorized' }, 401) } + await next() }) } diff --git a/backend/src/index.ts b/backend/src/index.ts index fc8911cf..1b87e4d5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import { createAuth } from './auth' import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createInternalRoutes } from './routes/internal' +import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' import { sseAggregator } from './services/sse-aggregator' import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations' import { SettingsService } from './services/settings' @@ -315,6 +316,7 @@ app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) +app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 7663b3e2..51440d6c 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -8,6 +8,9 @@ import { createInternalTokenMiddleware } from '../../auth/internal-token-middlew import { createInternalNotificationRoutes } from './notifications' import { createInternalSettingsRoutes } from './settings' import { createInternalRepoRoutes } from './repos' +import { createInternalRepoSyncRoutes } from './repo-sync' +import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' +import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces' export function createInternalRoutes( db: Database, @@ -23,6 +26,9 @@ export function createInternalRoutes( const repos = new Hono() repos.route('/', createInternalRepoRoutes(db, settingsService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) + repos.route('/', createInternalRepoSyncRoutes(db)) + repos.route('/', mirrorRoutes(db)) app.route('/repos', repos) + app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) return app } diff --git a/backend/src/routes/internal/opencode-workspaces.ts b/backend/src/routes/internal/opencode-workspaces.ts new file mode 100644 index 00000000..ae1f8334 --- /dev/null +++ b/backend/src/routes/internal/opencode-workspaces.ts @@ -0,0 +1,41 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { listRepos } from '../../db/queries' +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) => { + try { + const repos = listRepos(db) + const workspaces = repos + .filter((repo) => repo.cloneStatus === 'ready') + .map((repo) => ({ + repoId: repo.id, + name: repo.repoUrl + ? repo.repoUrl.split('/').slice(-1)[0]?.replace('.git', '') || repo.localPath + : repo.sourcePath + ? path.basename(repo.sourcePath) + : repo.localPath, + branch: repo.branch ?? null, + cloneStatus: repo.cloneStatus, + directory: repo.fullPath, + originUrl: repo.repoUrl ?? 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) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + return app +} diff --git a/backend/src/routes/internal/repo-mirror.ts b/backend/src/routes/internal/repo-mirror.ts new file mode 100644 index 00000000..68c0a2bc --- /dev/null +++ b/backend/src/routes/internal/repo-mirror.ts @@ -0,0 +1,224 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { spawn } from 'child_process' +import { pipeline } from 'stream/promises' +import { Readable } from 'stream' +import { mkdtempSync, mkdirSync, readdirSync, statSync, existsSync, writeFileSync } from 'fs' +import { join } from 'path' +import * as fsp from 'fs/promises' +import { getReposPath } from '@opencode-manager/shared/config/env' +import { getRepoById, updateLastPulled, updateRepoBranch, deleteRepo } from '../../db/queries' +import { ensureMirrorTargetPath, createRepoRow, isRepoInUse } from '../../services/repo' +import { logger } from '../../utils/logger' +import { getErrorMessage } from '../../utils/error-utils' +import { safeGitOut, gitOut } from './repo-sync-helpers' + +const HARDCODED_EXCLUDES = ['node_modules', 'dist', '.next', '.venv', '__pycache__', '.turbo'] + +export function createInternalRepoMirrorRoutes(db: Database) { + const app = new Hono() + + app.post('/:repoId/mirror', async (c) => { + const repoIdRaw = c.req.param('repoId') + const force = c.req.query('force') === '1' + const create = c.req.query('create') === '1' + const name = c.req.query('name') + const originUrl = c.req.query('originUrl') + const branch = c.req.query('branch') + + const rawBody = c.req.raw.body + if (!rawBody) return c.json({ error: 'no body provided' }, 400) + + let repoId: number + let fullPath: string + let created = false + let createdRepoId: number | undefined + + if (repoIdRaw === '0' && create) { + if (!name) return c.json({ error: 'name required', message: 'provide a name query param' }, 400) + const target = ensureMirrorTargetPath(name) + const { repo: newRepo, created: wasCreated } = createRepoRow(db, { name, originUrl, localPath: target.localPath, fullPath: target.fullPath, branch }) + repoId = newRepo.id + fullPath = newRepo.fullPath + created = wasCreated + createdRepoId = wasCreated ? newRepo.id : undefined + + if (!wasCreated && !force && isRepoInUse(db, repoId)) { + return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) + } + } else { + const repoIdNum = Number(repoIdRaw) + if (!Number.isFinite(repoIdNum)) return c.json({ error: 'invalid repoId' }, 400) + const repo = getRepoById(db, repoIdNum) + if (!repo) return c.json({ error: 'repo not found' }, 404) + repoId = repo.id + fullPath = repo.fullPath + + if (!force && isRepoInUse(db, repoId)) { + return c.json({ error: 'repo_in_use', message: 'open OpenCode sessions are using this repo; rerun with force=1' }, 409) + } + } + + let staging: string | undefined + let oldDirMoved = false + try { + const stagingParent = join(getReposPath(), '.ocm-staging') + mkdirSync(stagingParent, { recursive: true }) + staging = mkdtempSync(join(stagingParent, 'recv-')) + + const isGzip = c.req.header('Content-Encoding') === 'gzip' + const tarArgs = ['-x', '-f', '-', '-C', staging] + if (isGzip) tarArgs.unshift('-z') + const child = spawn('tar', tarArgs, { stdio: ['pipe', 'pipe', 'pipe'] }) + + const stderrChunks: Buffer[] = [] + child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)) + + const tarDone = new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve() + else { + const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim() + reject(new Error(`tar exited with code ${code}${stderr ? `: ${stderr}` : ''}`)) + } + }) + child.on('error', reject) + }) + + const body = Readable.fromWeb(rawBody as unknown as Parameters[0]) + await pipeline(body, child.stdin) + await tarDone + + const entries = readdirSync(staging) + let extractedRoot = staging + if (entries.length === 1) { + const candidate = join(staging, entries[0]!) + try { + if (statSync(candidate).isDirectory()) { + extractedRoot = candidate + } + } catch { /* ignore stat errors */ } + } + + if (existsSync(fullPath)) { + try { + await fsp.rename(fullPath, fullPath + '.ocm-old') + oldDirMoved = true + } catch { /* ignore rename errors */ } + } + + await fsp.rename(extractedRoot, fullPath) + + const oldDir = fullPath + '.ocm-old' + await fsp.rm(oldDir, { recursive: true, force: true }).catch(() => {}) + + const branchName = await safeGitOut(fullPath, ['rev-parse', '--abbrev-ref', 'HEAD']) + const head = await safeGitOut(fullPath, ['rev-parse', 'HEAD']) + + if (branchName) { + updateRepoBranch(db, repoId, branchName.trim()) + } + updateLastPulled(db, repoId) + + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) + + return c.json({ + repoId, + fullPath, + branch: branchName?.trim() || null, + head: head?.trim() || null, + created, + }) + } catch (error) { + logger.error('mirror POST failed:', error) + + if (oldDirMoved) { + try { + await fsp.rename(fullPath + '.ocm-old', fullPath) + } catch { + logger.error('failed to restore old repo from .ocm-old') + } + } + + if (createdRepoId !== undefined) { + try { + deleteRepo(db, createdRepoId) + } catch { + logger.error('failed to delete created repo row on failure') + } + } + + if (staging) { + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) + } + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.get('/:repoId/mirror', 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 compress = c.req.query('compress') === 'gzip' + const fullPath = repo.fullPath + + const excludeArgs: string[] = [] + for (const dir of HARDCODED_EXCLUDES) { + excludeArgs.push('--exclude', dir) + } + + let ignoreFile: string | undefined + try { + const ignored = await gitOut(fullPath, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory']) + if (ignored.trim()) { + const excludeParent = join(getReposPath(), '.ocm-staging') + mkdirSync(excludeParent, { recursive: true }) + ignoreFile = mkdtempSync(join(excludeParent, 'exclude-')) + writeFileSync(join(ignoreFile, '.gitignore'), ignored) + excludeArgs.push('--exclude-from', join(ignoreFile, '.gitignore')) + } + } catch { /* ignore git ls-files errors */ } + + const tarArgs = ['-c', '-C', fullPath, ...excludeArgs, '.'] + if (compress) { + tarArgs.unshift('-z') + } + + const child = spawn('tar', tarArgs, { stdio: ['pipe', 'pipe', 'pipe'] }) + + const stream = new ReadableStream({ + start(controller) { + child.stdout.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + child.stdout.on('end', () => { + controller.close() + if (ignoreFile) { + fsp.rm(ignoreFile, { recursive: true, force: true }).catch(() => {}) + } + }) + child.stdout.on('error', (err: Error) => { + controller.error(err) + if (ignoreFile) { + fsp.rm(ignoreFile, { recursive: true, force: true }).catch(() => {}) + } + }) + }, + }) + + child.on('close', () => {}) + child.on('error', () => {}) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-tar', + ...(compress ? { 'Content-Encoding': 'gzip' } : {}), + }, + }) + }) + + return app +} diff --git a/backend/src/routes/internal/repo-sync-helpers.ts b/backend/src/routes/internal/repo-sync-helpers.ts new file mode 100644 index 00000000..d393b71a --- /dev/null +++ b/backend/src/routes/internal/repo-sync-helpers.ts @@ -0,0 +1,25 @@ +import { resolve, normalize, isAbsolute, sep } from 'path' +import { executeCommand } from '../../utils/process' + +export async function gitOut(repoPath: string, args: string[]): Promise { + return executeCommand(['git', '-C', repoPath, ...args], { silent: true }) +} + +export async function safeGitOut(repoPath: string, args: string[]): Promise { + try { + return await gitOut(repoPath, args) + } catch { + return null + } +} + +export function isSafeRelativePath(repoPath: string, relPath: string): string | null { + if (!relPath || relPath.startsWith('/') || relPath.includes('\0')) return null + const normalized = normalize(relPath) + if (normalized.startsWith('..') || normalized.includes(`${sep}..${sep}`) || normalized === '..') return null + if (isAbsolute(normalized)) return null + const full = resolve(repoPath, normalized) + const root = resolve(repoPath) + sep + if (full !== resolve(repoPath) && !full.startsWith(root)) return null + return full +} diff --git a/backend/src/routes/internal/repo-sync.ts b/backend/src/routes/internal/repo-sync.ts new file mode 100644 index 00000000..04a72557 --- /dev/null +++ b/backend/src/routes/internal/repo-sync.ts @@ -0,0 +1,42 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { getRepoById } from '../../db/queries' +import { logger } from '../../utils/logger' +import { getErrorMessage } from '../../utils/error-utils' +import { safeGitOut } from './repo-sync-helpers' + +export function createInternalRepoSyncRoutes(db: Database) { + const app = new Hono() + + app.get('/:repoId/git-info', 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 (repo.cloneStatus !== 'ready') return c.json({ error: 'repo not ready' }, 409) + const repoPath = repo.fullPath + try { + const [originUrl, head, branch, status] = await Promise.all([ + safeGitOut(repoPath, ['remote', 'get-url', 'origin']), + safeGitOut(repoPath, ['rev-parse', 'HEAD']), + safeGitOut(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']), + safeGitOut(repoPath, ['status', '--porcelain']), + ]) + return c.json({ + repoId, + repoName: repo.repoUrl?.split('/').slice(-1)[0]?.replace('.git', '') ?? null, + directory: repoPath, + originUrl: originUrl?.trim() || null, + head: head?.trim() || null, + branch: branch?.trim() || null, + dirty: Boolean(status && status.trim().length > 0), + }) + } catch (error) { + logger.error('git-info failed:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + return app +} diff --git a/backend/src/routes/opencode-proxy.ts b/backend/src/routes/opencode-proxy.ts new file mode 100644 index 00000000..c05de1f0 --- /dev/null +++ b/backend/src/routes/opencode-proxy.ts @@ -0,0 +1,83 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { ENV } from '@opencode-manager/shared/config/env' +import { createInternalTokenMiddleware } from '../auth/internal-token-middleware' +import type { SettingsService } from '../services/settings' + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'upgrade', + 'transfer-encoding', + 'content-length', + 'content-encoding', + 'host', + 'authorization', +]) + +export function createOpenCodeProxyRoutes(db: Database, settingsService: SettingsService) { + const app = new Hono() + + app.use('/*', createInternalTokenMiddleware(db)) + + app.all('/*', async (c) => { + const connectionHeader = c.req.header('connection')?.toLowerCase() ?? '' + const upgradeHeader = c.req.header('upgrade')?.toLowerCase() ?? '' + if (connectionHeader.includes('upgrade') && upgradeHeader === 'websocket') { + return c.json({ error: 'WebSocket proxying is not supported' }, 501) + } + + const url = new URL(c.req.url) + const pathSuffix = url.pathname.replace(/^\/api\/opencode-proxy/, '') || '/' + const upstreamUrl = `http://127.0.0.1:${ENV.OPENCODE.PORT}${pathSuffix}${url.search}` + + const headers: Record = {} + c.req.raw.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(lowerKey)) { + headers[key] = value + } + }) + + const password = settingsService.getOpenCodeServerPassword() + const username = ENV.OPENCODE.SERVER_USERNAME + headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + + let requestBody: RequestInit['body'] = undefined + if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { + requestBody = c.req.raw.body + } + + try { + const upstreamResponse = await fetch(upstreamUrl, { + method: c.req.method, + headers, + body: requestBody, + redirect: 'manual', + duplex: 'half', + }) + + const responseHeaders: Record = {} + upstreamResponse.headers.forEach((value, key) => { + const lowerKey = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(lowerKey)) { + responseHeaders[key] = value + } + }) + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: responseHeaders, + }) + } catch { + return c.json({ error: 'Proxy request failed' }, 502) + } + }) + + return app +} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 57eb94fc..2a8439cc 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -21,6 +21,7 @@ import { } from '@opencode-manager/shared' import { logger } from '../utils/logger' import { opencodeServerManager, ConfigReloadError } from '../services/opencode-single-server' +import { getOrCreateInternalToken, rotateInternalToken } from '../services/internal-token' import { sseAggregator } from '../services/sse-aggregator' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import type { GitAuthService } from '../services/git-auth' @@ -1555,5 +1556,25 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) + app.get('/manager-token', async (c) => { + try { + const token = getOrCreateInternalToken(db) + return c.json({ token }) + } catch (error) { + logger.error('Failed to get manager token:', error) + return c.json({ error: 'Failed to get manager token' }, 500) + } + }) + + app.post('/manager-token/rotate', async (c) => { + try { + const token = rotateInternalToken(db) + return c.json({ token }) + } catch (error) { + logger.error('Failed to rotate manager token:', error) + return c.json({ error: 'Failed to rotate manager token' }, 500) + } + }) + return app } diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 776e5e3d..cdf1221c 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -12,6 +12,7 @@ import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth' import path from 'path' import { parseSSHHost } from '../utils/ssh-key-manager' import { getErrorMessage } from '../utils/error-utils' +import { sseAggregator } from './sse-aggregator' const GIT_CLONE_TIMEOUT = 300000 const DEFAULT_DISCOVERY_MAX_DEPTH = 4 @@ -1028,3 +1029,62 @@ async function createWorktreeSafely(baseRepoPath: string, worktreePath: string, await executeCommand(addArgs, { env }) } } + +export function ensureMirrorTargetPath(name: string): { fullPath: string; localPath: string } { + const slugified = name + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + || 'repo' + + const reposRoot = getReposPath() + + let candidate = slugified + let suffix = 2 + while (existsSync(path.join(reposRoot, candidate))) { + candidate = `${slugified}-${suffix}` + suffix += 1 + } + + return { + fullPath: path.join(reposRoot, candidate), + localPath: candidate, + } +} + +export function createRepoRow( + database: Database, + params: { name: string; originUrl?: string; localPath: string; fullPath: string; branch?: string } +): { repo: Repo; created: boolean } { + const { originUrl, localPath, branch } = params + + const existing = originUrl + ? getRepoByUrlAndBranch(database, originUrl, branch) + : getRepoByLocalPath(database, localPath) + + if (existing) { + return { repo: existing, created: false } + } + + const repo = createRepo(database, { + repoUrl: originUrl, + localPath, + branch, + defaultBranch: branch || 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: !originUrl, + } as CreateRepoInput) + + return { repo, created: true } +} + +export function isRepoInUse(db: Database, repoId: number): boolean { + const repo = getRepoById(db, repoId) + if (!repo) { + return false + } + + return sseAggregator.getActiveDirectories().includes(repo.fullPath) +} diff --git a/backend/test/auth/internal-token-middleware.test.ts b/backend/test/auth/internal-token-middleware.test.ts index 03efd57a..e9454e0a 100644 --- a/backend/test/auth/internal-token-middleware.test.ts +++ b/backend/test/auth/internal-token-middleware.test.ts @@ -28,11 +28,11 @@ describe('internal-token-middleware', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) - it('returns 401 when authorization header is not bearer scheme', async () => { + it('returns 401 when authorization header is not bearer or basic scheme', async () => { const db = createTestDb() const app = createTestApp(db) const res = await app.request('/test', { - headers: { authorization: 'Basic abc123' }, + headers: { authorization: 'Digest abc123' }, }) expect(res.status).toBe(401) }) @@ -56,7 +56,7 @@ describe('internal-token-middleware', () => { expect(res.status).toBe(401) }) - it('returns 200 when token matches', async () => { + it('returns 200 when bearer token matches', async () => { const db = createTestDb() const token = getOrCreateInternalToken(db) const app = createTestApp(db) @@ -67,4 +67,25 @@ describe('internal-token-middleware', () => { const body = await res.json() expect(body).toEqual({ ok: true }) }) + + it('returns 401 when basic auth password is wrong', async () => { + const db = createTestDb() + const app = createTestApp(db) + const res = await app.request('/test', { + headers: { authorization: 'Basic ' + Buffer.from('opencode:wrong-password').toString('base64') }, + }) + expect(res.status).toBe(401) + }) + + it('returns 200 when basic auth password matches internal token', async () => { + const db = createTestDb() + const token = getOrCreateInternalToken(db) + const app = createTestApp(db) + const res = await app.request('/test', { + headers: { authorization: 'Basic ' + Buffer.from(`opencode:${token}`).toString('base64') }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ ok: true }) + }) }) diff --git a/backend/test/bun-mock-test.test.ts b/backend/test/bun-mock-test.test.ts new file mode 100644 index 00000000..4494c031 --- /dev/null +++ b/backend/test/bun-mock-test.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect, vi } from 'vitest' + +const mockFn = vi.hoisted(() => vi.fn().mockReturnValue('mocked')) + +describe('bun test vi.hoisted compatibility', () => { + it('supports vi.hoisted', () => { + expect(mockFn()).toBe('mocked') + }) +}) diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts new file mode 100644 index 00000000..94a1e114 --- /dev/null +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import type { ScheduleService } from '../../src/services/schedules' +import type { NotificationService } from '../../src/services/notification' +import type { SettingsService } from '../../src/services/settings' +import type { Repo } from '../../src/types/repo' + +const mockDb = { + prepare: vi.fn().mockReturnValue({ + run: vi.fn(), + get: vi.fn(), + all: vi.fn(), + }), + exec: vi.fn(), + close: vi.fn(), + transaction: vi.fn((fn: () => void) => fn()), +} as unknown as Database + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(() => mockDb), +})) + +const mockListRepos = vi.fn() +vi.mock('../../src/db/queries', () => ({ + listRepos: (...args: unknown[]) => mockListRepos(...args), +})) + +vi.mock('../../src/db/migration-runner', () => ({ + migrate: vi.fn(), +})) + +vi.mock('../../src/services/internal-token', () => ({ + getOrCreateInternalToken: vi.fn().mockReturnValue('test-internal-token'), +})) + +vi.mock('../../src/services/schedules', () => ({ + ScheduleService: vi.fn(), +})) + +vi.mock('../../src/services/notification', () => ({ + NotificationService: vi.fn(), +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn(), +})) + +vi.mock('../../src/services/opencode/client', () => ({ + createOpenCodeClient: vi.fn(), +})) + +function makeRepo(overrides: Partial): Repo { + return { + id: 1, + localPath: 'test-repo', + fullPath: '/tmp/test-repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + ...overrides, + } +} + +describe('internal-opencode-workspaces routes', () => { + let app: Hono + let token: string + + beforeEach(() => { + vi.clearAllMocks() + mockListRepos.mockReturnValue([]) + const scheduleService = {} as ScheduleService + const notificationService = {} as NotificationService + const settingsService = {} as SettingsService + app = new Hono() + app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService)) + token = 'test-internal-token' + }) + + it('GET /api/internal/opencode-workspaces returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/opencode-workspaces') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/opencode-workspaces returns 200 with bearer token', async () => { + const res = await app.request('/api/internal/opencode-workspaces', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { workspaces: unknown[] } + expect(body).toHaveProperty('workspaces') + expect(Array.isArray(body.workspaces)).toBe(true) + }) + + it('GET /api/internal/opencode-workspaces only returns ready repos', async () => { + mockListRepos.mockReturnValue([ + makeRepo({ id: 1, cloneStatus: 'ready', localPath: 'ready-repo' }), + makeRepo({ id: 2, cloneStatus: 'cloning', localPath: 'cloning-repo' }), + makeRepo({ id: 3, cloneStatus: 'error', localPath: 'error-repo' }), + ]) + + const res = await app.request('/api/internal/opencode-workspaces', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { workspaces: Array<{ repoId: number; cloneStatus: string }> } + expect(body.workspaces.length).toBe(1) + expect(body.workspaces[0]?.cloneStatus).toBe('ready') + expect(body.workspaces[0]?.repoId).toBeDefined() + }) + + it('GET /api/internal/opencode-workspaces returns workspace structure', async () => { + mockListRepos.mockReturnValue([ + makeRepo({ id: 1, localPath: 'test-repo', cloneStatus: 'ready' }), + ]) + + const res = await app.request('/api/internal/opencode-workspaces', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { workspaces: Array<{ repoId: number; name: string; branch: string | null; cloneStatus: string; directory: string; extra: { repoId: number; localPath: string; fullPath: string } }> } + expect(body.workspaces.length).toBe(1) + const workspace = body.workspaces[0]! + expect(workspace).toHaveProperty('repoId') + expect(workspace).toHaveProperty('name') + expect(workspace).toHaveProperty('branch') + expect(workspace).toHaveProperty('cloneStatus') + expect(workspace).toHaveProperty('directory') + expect(workspace).toHaveProperty('extra') + expect(workspace.extra).toHaveProperty('repoId') + expect(workspace.extra).toHaveProperty('localPath') + expect(workspace.extra).toHaveProperty('fullPath') + }) +}) diff --git a/backend/test/routes/internal/repo-mirror.test.ts b/backend/test/routes/internal/repo-mirror.test.ts new file mode 100644 index 00000000..402b9fd4 --- /dev/null +++ b/backend/test/routes/internal/repo-mirror.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { Hono } from 'hono' +import { spawnSync } from 'child_process' +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { gzipSync } from 'zlib' + +const { mockGetActiveDirectories, mockGitOut, mockSafeGitOut, getTmpRoot, setTmpRoot } = vi.hoisted(() => { + const mockGetActiveDirectories = vi.fn().mockReturnValue([]) + const mockGitOut = vi.fn().mockResolvedValue('main') + const mockSafeGitOut = vi.fn().mockResolvedValue('main') + let tmpRoot = '' + return { + mockGetActiveDirectories, + mockGitOut, + mockSafeGitOut, + getTmpRoot: () => tmpRoot, + setTmpRoot: (v: string) => { tmpRoot = v }, + } +}) + +vi.mock('../../../src/services/sse-aggregator', () => ({ + sseAggregator: { + getActiveDirectories: mockGetActiveDirectories, + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getReposPath: () => getTmpRoot(), + getWorkspacePath: () => '/tmp/fake-workspace', +})) + +vi.mock('../../../src/routes/internal/repo-sync-helpers', () => ({ + gitOut: (...args: unknown[]) => mockGitOut(...args), + safeGitOut: (...args: unknown[]) => mockSafeGitOut(...args), + isSafeRelativePath: vi.fn(), +})) + +const mockGetRepoById = vi.fn() +const mockUpdateLastPulled = vi.fn() +const mockUpdateRepoBranch = vi.fn() +const mockDeleteRepo = vi.fn() + +vi.mock('../../../src/db/queries', () => ({ + getRepoById: (...args: unknown[]) => mockGetRepoById(...args), + updateLastPulled: (...args: unknown[]) => mockUpdateLastPulled(...args), + updateRepoBranch: (...args: unknown[]) => mockUpdateRepoBranch(...args), + deleteRepo: (...args: unknown[]) => mockDeleteRepo(...args), + createRepo: vi.fn(), + getRepoByLocalPath: vi.fn(), + getRepoByUrlAndBranch: vi.fn(), + updateRepoStatus: vi.fn(), +})) + +const mockEnsureMirrorTargetPath = vi.fn() +const mockCreateRepoRow = vi.fn() +const mockIsRepoInUse = vi.fn() + +vi.mock('../../../src/services/repo', () => ({ + ensureMirrorTargetPath: (...args: unknown[]) => mockEnsureMirrorTargetPath(...args), + createRepoRow: (...args: unknown[]) => mockCreateRepoRow(...args), + isRepoInUse: (...args: unknown[]) => mockIsRepoInUse(...args), +})) + +import { createInternalRepoMirrorRoutes } from '../../../src/routes/internal/repo-mirror' + +describe('internal-repo-mirror routes', () => { + let app: Hono + + beforeEach(() => { + vi.clearAllMocks() + const tmpRootValue = join(tmpdir(), `mirror-route-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + setTmpRoot(tmpRootValue) + mkdirSync(tmpRootValue, { recursive: true }) + app = new Hono() + app.route('/api/internal/repos', createInternalRepoMirrorRoutes({} as any)) + mockGetActiveDirectories.mockReturnValue([]) + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'test-repo', fullPath: join(getTmpRoot(), 'test-repo') }) + mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true })) + mockIsRepoInUse.mockReturnValue(false) + mockGetRepoById.mockReturnValue(null) + }) + + afterEach(() => { + rmSync(getTmpRoot(), { recursive: true, force: true }) + }) + + describe('GET /:repoId/mirror', () => { + it('returns a streamable tarball containing repo files', async () => { + const repoDir = join(getTmpRoot(), 'test-repo') + mkdirSync(repoDir, { recursive: true }) + writeFileSync(join(repoDir, 'test.txt'), 'hello world') + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + + const res = await app.request('/api/internal/repos/1/mirror') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('application/x-tar') + + const body = Buffer.from(await res.arrayBuffer()) + expect(body.length).toBeGreaterThan(0) + + const extractDir = join(getTmpRoot(), 'extract') + mkdirSync(extractDir, { recursive: true }) + const tarFile = join(getTmpRoot(), 'test.tar') + writeFileSync(tarFile, body) + + spawnSync('tar', ['-x', '-C', extractDir, '-f', tarFile], { stdio: 'inherit' }) + + expect(existsSync(join(extractDir, 'test.txt'))).toBe(true) + expect(readFileSync(join(extractDir, 'test.txt'), 'utf-8')).toBe('hello world') + }) + + it('returns 404 for non-existent repo', async () => { + mockGetRepoById.mockReturnValue(null) + + const res = await app.request('/api/internal/repos/99999/mirror') + expect(res.status).toBe(404) + }) + + it('returns 400 for invalid repoId', async () => { + const res = await app.request('/api/internal/repos/abc/mirror') + expect(res.status).toBe(400) + }) + }) + + describe('POST /:repoId/mirror', () => { + it('creates a repo with create=1 and populates from tarball', async () => { + const targetPath = join(getTmpRoot(), 'test-repo') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'test-repo', fullPath: targetPath }) + mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true })) + + const sourceDir = join(getTmpRoot(), 'source') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'payload.txt'), 'payload data') + + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=test-repo', { + method: 'POST', + body: tarball, + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(200) + const json = (await res.json()) as { created: boolean; repoId: number; fullPath: string } + expect(json.created).toBe(true) + expect(json.repoId).toBe(1) + expect(json.fullPath).toBe(targetPath) + + expect(existsSync(join(json.fullPath, 'payload.txt'))).toBe(true) + expect(readFileSync(join(json.fullPath, 'payload.txt'), 'utf-8')).toBe('payload data') + + expect(mockCreateRepoRow).toHaveBeenCalled() + }) + + it('returns 409 when repo is in use and force not set', async () => { + const repoDir = join(getTmpRoot(), 'test-repo') + mkdirSync(repoDir, { recursive: true }) + writeFileSync(join(repoDir, 'existing.txt'), 'existing') + + mockGetRepoById.mockReturnValue({ id: 1, fullPath: repoDir }) + mockIsRepoInUse.mockReturnValue(true) + + const sourceDir = join(getTmpRoot(), 'source') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'payload.txt'), 'payload data') + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + + const res = await app.request('/api/internal/repos/1/mirror', { + method: 'POST', + body: tarball, + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('repo_in_use') + }) + + it('returns 400 when create=1 but name missing', async () => { + const res = await app.request('/api/internal/repos/0/mirror?create=1', { + method: 'POST', + body: Buffer.alloc(0), + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('name required') + }) + + it('returns 400 with no body and does not create DB row', async () => { + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=foo', { + method: 'POST', + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(400) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('no body provided') + expect(mockCreateRepoRow).not.toHaveBeenCalled() + }) + + it('returns 404 for non-existent repo without create', async () => { + mockGetRepoById.mockReturnValue(null) + + const sourceDir = join(getTmpRoot(), 'source') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'payload.txt'), 'payload data') + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + + const res = await app.request('/api/internal/repos/99999/mirror', { + method: 'POST', + body: tarball, + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(404) + }) + + it('rolls back created DB row when tarball extraction fails', async () => { + const targetPath = join(getTmpRoot(), 'test-repo') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'test-repo', fullPath: targetPath }) + mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true })) + mockDeleteRepo.mockReturnValue(undefined) + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=test-repo', { + method: 'POST', + body: Buffer.from('not a tarball'), + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(500) + expect(mockDeleteRepo).toHaveBeenCalledWith({}, 1) + }) + + it('returns 500 without hanging on very small invalid body (tar exit race)', async () => { + const targetPath = join(getTmpRoot(), 'test-repo-race') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'test-repo-race', fullPath: targetPath }) + mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true })) + mockDeleteRepo.mockReturnValue(undefined) + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=test-repo-race', { + method: 'POST', + body: Buffer.from([0x00]), + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(500) + expect(mockDeleteRepo).toHaveBeenCalledWith({}, 1) + }) + + it('handles gzip-compressed tarball with Content-Encoding: gzip', async () => { + const targetPath = join(getTmpRoot(), 'test-repo-gzip') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'test-repo-gzip', fullPath: targetPath }) + mockCreateRepoRow.mockImplementation((_db: any, input: any) => ({ repo: { id: 1, fullPath: input.fullPath, localPath: input.localPath }, created: true })) + + const sourceDir = join(getTmpRoot(), 'source-gzip') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'gzip.txt'), 'gzip payload') + + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + const gzipped = gzipSync(tarball) + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=test-repo-gzip', { + method: 'POST', + body: gzipped, + headers: { + 'content-type': 'application/x-tar', + 'Content-Encoding': 'gzip', + }, + }) + + expect(res.status).toBe(200) + const json = (await res.json()) as { created: boolean; repoId: number; fullPath: string } + expect(json.created).toBe(true) + expect(json.repoId).toBe(1) + expect(json.fullPath).toBe(targetPath) + + expect(existsSync(join(json.fullPath, 'gzip.txt'))).toBe(true) + expect(readFileSync(join(json.fullPath, 'gzip.txt'), 'utf-8')).toBe('gzip payload') + }) + + it('returns 409 when create-on-push finds existing repo in use and force not set', async () => { + const existingRepoPath = join(getTmpRoot(), 'existing-repo') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'existing-repo', fullPath: existingRepoPath }) + mockCreateRepoRow.mockImplementation(() => ({ + repo: { id: 5, fullPath: existingRepoPath, localPath: 'existing-repo' }, + created: false, + })) + mockIsRepoInUse.mockReturnValue(true) + + const sourceDir = join(getTmpRoot(), 'source-inuse') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'file.txt'), 'should not land') + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=existing-repo', { + method: 'POST', + body: tarball, + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(409) + const json = (await res.json()) as { error: string } + expect(json.error).toBe('repo_in_use') + }) + + it('allows create-on-push of existing repo when force=1 even if in use', async () => { + const existingRepoPath = join(getTmpRoot(), 'existing-repo-force') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'existing-repo-force', fullPath: existingRepoPath }) + mockCreateRepoRow.mockImplementation(() => ({ + repo: { id: 7, fullPath: existingRepoPath, localPath: 'existing-repo-force' }, + created: false, + })) + mockIsRepoInUse.mockReturnValue(true) + + const sourceDir = join(getTmpRoot(), 'source-force') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'forced.txt'), 'forced content') + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=existing-repo-force&force=1', { + method: 'POST', + body: tarball, + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(200) + const json = (await res.json()) as { created: boolean; repoId: number; fullPath: string } + expect(json.created).toBe(false) + expect(json.repoId).toBe(7) + expect(existsSync(join(json.fullPath, 'forced.txt'))).toBe(true) + }) + + it('uses existing repo fullPath when createRepoRow finds matching origin/branch', async () => { + const existingRepoPath = join(getTmpRoot(), 'existing-repo') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'new-name', fullPath: join(getTmpRoot(), 'new-name') }) + mockCreateRepoRow.mockImplementation(() => ({ + repo: { id: 5, fullPath: existingRepoPath, localPath: 'existing-repo' }, + created: false, + })) + + const sourceDir = join(getTmpRoot(), 'source-existing') + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'file.txt'), 'existing content') + + const result = spawnSync('tar', ['-c', '-C', sourceDir, '.'], { encoding: 'buffer' }) + const tarball = result.stdout as Buffer + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=new-name', { + method: 'POST', + body: tarball, + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(200) + const json = (await res.json()) as { created: boolean; repoId: number; fullPath: string } + expect(json.created).toBe(false) + expect(json.repoId).toBe(5) + expect(json.fullPath).toBe(existingRepoPath) + + expect(existsSync(join(json.fullPath, 'file.txt'))).toBe(true) + expect(readFileSync(join(json.fullPath, 'file.txt'), 'utf-8')).toBe('existing content') + }) + + it('does not delete existing repo on failure when createRepoRow returns non-created repo', async () => { + const existingRepoPath = join(getTmpRoot(), 'existing-repo-fail') + mockEnsureMirrorTargetPath.mockReturnValue({ localPath: 'new-name', fullPath: join(getTmpRoot(), 'new-name') }) + mockCreateRepoRow.mockImplementation(() => ({ + repo: { id: 5, fullPath: existingRepoPath, localPath: 'existing-repo' }, + created: false, + })) + mockDeleteRepo.mockReturnValue(undefined) + + const res = await app.request('/api/internal/repos/0/mirror?create=1&name=new-name', { + method: 'POST', + body: Buffer.from('not a tarball'), + headers: { 'content-type': 'application/x-tar' }, + }) + + expect(res.status).toBe(500) + expect(mockDeleteRepo).not.toHaveBeenCalled() + }) + }) +}) diff --git a/backend/test/routes/opencode-proxy.test.ts b/backend/test/routes/opencode-proxy.test.ts new file mode 100644 index 00000000..83fbefe0 --- /dev/null +++ b/backend/test/routes/opencode-proxy.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { createOpenCodeProxyRoutes } from '../../src/routes/opencode-proxy' +import type { SettingsService } from '../../src/services/settings' + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(), +})) + +vi.mock('../../src/services/internal-token', () => ({ + getOrCreateInternalToken: vi.fn().mockReturnValue('test-internal-token'), +})) + +const mockSettingsService = { + getOpenCodeServerPassword: vi.fn().mockReturnValue('test-password'), +} as unknown as SettingsService + +const mockDb = {} as Database + +describe('opencode-proxy routes', () => { + let app: Hono + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + vi.clearAllMocks() + originalFetch = globalThis.fetch + app = new Hono() + app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(mockDb, mockSettingsService)) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('returns 401 without authorization header', async () => { + const res = await app.request('/api/opencode-proxy/doc') + expect(res.status).toBe(401) + const body = await res.json() as { error: string } + expect(body.error).toBe('Unauthorized') + }) + + it('returns 401 with invalid bearer token', async () => { + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: 'Bearer wrong-token' }, + }) + expect(res.status).toBe(401) + const body = await res.json() as { error: string } + expect(body.error).toBe('Unauthorized') + }) + + it('returns 401 with invalid basic auth password', async () => { + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: 'Basic ' + Buffer.from('opencode:wrong-password').toString('base64') }, + }) + expect(res.status).toBe(401) + const body = await res.json() as { error: string } + expect(body.error).toBe('Unauthorized') + }) + + it('returns 200 with valid bearer and injected Basic auth', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalled() + + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + const fetchUrl = fetchCall[0] + expect(fetchUrl).toContain('http://127.0.0.1:') + + const fetchHeaders = fetchCall[1].headers as Record + expect(fetchHeaders['Authorization']).toMatch(/^Basic /) + expect(fetchHeaders['Authorization']).not.toContain('Bearer') + expect(fetchHeaders['Authorization']).toContain( + Buffer.from('opencode:test-password').toString('base64') + ) + }) + + it('returns 200 with valid basic auth (opencode attach) and injected Basic auth', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const basicAuthHeader = 'Basic ' + Buffer.from('opencode:test-internal-token').toString('base64') + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: basicAuthHeader }, + }) + + expect(res.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalled() + + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + const fetchHeaders = fetchCall[1].headers as Record + + expect(fetchHeaders['Authorization']).toMatch(/^Basic /) + expect(fetchHeaders['Authorization']).not.toContain('Bearer') + expect(fetchHeaders['Authorization']).toContain( + Buffer.from('opencode:test-password').toString('base64') + ) + }) + + it('strips caller Bearer and injects Basic auth', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + await app.request('/api/opencode-proxy/doc', { + headers: { + Authorization: 'Bearer test-internal-token', + 'x-opencode-directory': '/some/dir', + }, + }) + + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + const fetchHeaders = fetchCall[1].headers as Record + + expect(fetchHeaders['Authorization']).not.toContain('Bearer') + expect(fetchHeaders['Authorization']).toMatch(/^Basic /) + expect(fetchHeaders['x-opencode-directory']).toBe('/some/dir') + }) + + it('forwards x-opencode-directory header unchanged', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + await app.request('/api/opencode-proxy/doc', { + headers: { + Authorization: 'Bearer test-internal-token', + 'x-opencode-directory': '/home/user/project', + 'x-opencode-workspace': 'my-workspace', + }, + }) + + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + const fetchHeaders = fetchCall[1].headers as Record + + expect(fetchHeaders['x-opencode-directory']).toBe('/home/user/project') + expect(fetchHeaders['x-opencode-workspace']).toBe('my-workspace') + }) + + it('returns 501 for WebSocket upgrade requests', async () => { + const res = await app.request('/api/opencode-proxy/ws', { + headers: { + Authorization: 'Bearer test-internal-token', + Connection: 'Upgrade', + Upgrade: 'websocket', + }, + }) + expect(res.status).toBe(501) + const body = await res.json() as { error: string } + expect(body.error).toContain('WebSocket') + }) + + it('preserves SSE content-type header from upstream', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('event: message\ndata: hello\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const res = await app.request('/api/opencode-proxy/events', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/event-stream') + }) + + it('does not buffer SSE response body', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('data: chunk1\n\ndata: chunk2\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const res = await app.request('/api/opencode-proxy/events', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(200) + expect(res.body).toBeDefined() + }) + + it('returns 502 when upstream fetch fails', async () => { + const upstreamFetch = vi.fn().mockRejectedValue(new Error('Connection refused')) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.status).toBe(502) + const body = await res.json() as { error: string } + expect(body.error).toBe('Proxy request failed') + }) + + it('preserves query string in upstream URL', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + await app.request('/api/opencode-proxy/doc?foo=bar&baz=qux', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + const fetchUrl = fetchCall[0] + expect(fetchUrl).toContain('?foo=bar&baz=qux') + }) + + it('strips hop-by-hop headers from request', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + await app.request('/api/opencode-proxy/doc', { + headers: { + Authorization: 'Bearer test-internal-token', + Host: 'localhost:5003', + Connection: 'keep-alive', + 'Transfer-Encoding': 'chunked', + }, + }) + + const fetchCall = upstreamFetch.mock.calls[0] as [string, RequestInit] + const fetchHeaders = fetchCall[1].headers as Record + + expect(fetchHeaders['Host']).toBeUndefined() + expect(fetchHeaders['host']).toBeUndefined() + expect(fetchHeaders['Connection']).toBeUndefined() + expect(fetchHeaders['connection']).toBeUndefined() + expect(fetchHeaders['Transfer-Encoding']).toBeUndefined() + expect(fetchHeaders['transfer-encoding']).toBeUndefined() + }) + + it('strips hop-by-hop headers from response', async () => { + const upstreamFetch = vi.fn().mockResolvedValue( + new Response('ok', { + status: 200, + headers: { + 'content-type': 'text/plain', + connection: 'keep-alive', + 'transfer-encoding': 'chunked', + }, + }) + ) + globalThis.fetch = upstreamFetch as unknown as typeof fetch + + const res = await app.request('/api/opencode-proxy/doc', { + headers: { Authorization: 'Bearer test-internal-token' }, + }) + + expect(res.headers.get('connection')).toBeNull() + expect(res.headers.get('transfer-encoding')).toBeNull() + expect(res.headers.get('content-type')).toBe('text/plain') + }) +}) diff --git a/backend/test/services/repo-mirror-helpers.test.ts b/backend/test/services/repo-mirror-helpers.test.ts new file mode 100644 index 00000000..a3b47cb4 --- /dev/null +++ b/backend/test/services/repo-mirror-helpers.test.ts @@ -0,0 +1,294 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const getRepoById = vi.fn() +const createRepo = vi.fn() +const mockGetRepoByLocalPath = vi.fn() +const mockGetRepoByUrlAndBranch = vi.fn() + +vi.mock('../../src/db/queries', () => ({ + getRepoById, + createRepo, + getRepoByLocalPath: mockGetRepoByLocalPath, + getRepoBySourcePath: vi.fn(), + updateRepoStatus: vi.fn(), + updateRepoBranch: vi.fn(), + deleteRepo: vi.fn(), + getRepoByUrlAndBranch: mockGetRepoByUrlAndBranch, +})) + +const mockGetActiveDirectories = vi.fn().mockReturnValue([]) +vi.mock('../../src/services/sse-aggregator', () => ({ + sseAggregator: { + getActiveDirectories: mockGetActiveDirectories, + }, +})) + +let tmpRoot: string + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getReposPath: () => tmpRoot, + getWorkspacePath: vi.fn(() => '/tmp/fake-workspace'), +})) + +describe('ensureMirrorTargetPath', () => { + beforeEach(() => { + vi.clearAllMocks() + tmpRoot = path.join(os.tmpdir(), `mirror-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + fs.mkdirSync(tmpRoot, { recursive: true }) + }) + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + it('returns a path under the repos root', async () => { + const { ensureMirrorTargetPath } = await import('../../src/services/repo') + const result = ensureMirrorTargetPath('demo') + + expect(result.localPath).toBe('demo') + expect(result.fullPath).toBe(path.join(tmpRoot, 'demo')) + }) + + it('appends -2 when the target directory already exists on disk', async () => { + const { ensureMirrorTargetPath } = await import('../../src/services/repo') + + const result1 = ensureMirrorTargetPath('demo') + expect(result1.localPath).toBe('demo') + + fs.mkdirSync(result1.fullPath, { recursive: true }) + + const result2 = ensureMirrorTargetPath('demo') + expect(result2.localPath).toBe('demo-2') + expect(result2.fullPath).toBe(path.join(tmpRoot, 'demo-2')) + }) + + it('continues incrementing suffix on successive collisions', async () => { + const { ensureMirrorTargetPath } = await import('../../src/services/repo') + + fs.mkdirSync(path.join(tmpRoot, 'demo'), { recursive: true }) + fs.mkdirSync(path.join(tmpRoot, 'demo-2'), { recursive: true }) + fs.mkdirSync(path.join(tmpRoot, 'demo-3'), { recursive: true }) + + const result = ensureMirrorTargetPath('demo') + expect(result.localPath).toBe('demo-4') + expect(result.fullPath).toBe(path.join(tmpRoot, 'demo-4')) + }) + + it('slugifies names with special characters', async () => { + const { ensureMirrorTargetPath } = await import('../../src/services/repo') + const result = ensureMirrorTargetPath('My Repo Name!') + + expect(result.localPath).toBe('my-repo-name') + expect(result.fullPath).toBe(path.join(tmpRoot, 'my-repo-name')) + }) + + it('falls back to "repo" when slugification produces empty string', async () => { + const { ensureMirrorTargetPath } = await import('../../src/services/repo') + const result = ensureMirrorTargetPath('!!!') + + expect(result.localPath).toBe('repo') + expect(result.fullPath).toBe(path.join(tmpRoot, 'repo')) + }) +}) + +describe('createRepoRow', () => { + beforeEach(() => { + vi.clearAllMocks() + tmpRoot = path.join(os.tmpdir(), `mirror-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + fs.mkdirSync(tmpRoot, { recursive: true }) + }) + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + it('creates a repo row with cloneStatus ready and isLocal true when no originUrl', async () => { + const database = {} as never + const fakeRepo = { + id: 1, + localPath: 'demo', + fullPath: path.join(tmpRoot, 'demo'), + branch: undefined, + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: Date.now(), + isLocal: true, + } + createRepo.mockReturnValue(fakeRepo) + + const { createRepoRow } = await import('../../src/services/repo') + const result = createRepoRow(database, { + name: 'demo', + localPath: 'demo', + fullPath: path.join(tmpRoot, 'demo'), + }) + + expect(createRepo).toHaveBeenCalledWith(database, expect.objectContaining({ + cloneStatus: 'ready', + isLocal: true, + })) + expect(result.repo.cloneStatus).toBe('ready') + expect(result.created).toBe(true) + }) + + it('creates a repo row with isLocal false when originUrl is provided', async () => { + const database = {} as never + const fakeRepo = { + id: 2, + localPath: 'demo', + fullPath: path.join(tmpRoot, 'demo'), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: Date.now(), + isLocal: false, + } + createRepo.mockReturnValue(fakeRepo) + + const { createRepoRow } = await import('../../src/services/repo') + const result = createRepoRow(database, { + name: 'demo', + originUrl: 'https://github.com/example/repo.git', + localPath: 'demo', + fullPath: path.join(tmpRoot, 'demo'), + branch: 'main', + }) + + expect(createRepo).toHaveBeenCalledWith(database, expect.objectContaining({ + repoUrl: 'https://github.com/example/repo.git', + cloneStatus: 'ready', + isLocal: false, + })) + expect(result.repo.cloneStatus).toBe('ready') + expect(result.created).toBe(true) + }) + + it('uses supplied branch as defaultBranch fallback', async () => { + const database = {} as never + createRepo.mockReturnValue({ id: 3 }) + + const { createRepoRow } = await import('../../src/services/repo') + createRepoRow(database, { + name: 'demo', + localPath: 'demo', + fullPath: path.join(tmpRoot, 'demo'), + branch: 'develop', + }) + + expect(createRepo).toHaveBeenCalledWith(database, expect.objectContaining({ + defaultBranch: 'develop', + branch: 'develop', + })) + }) + + it('returns existing repo with created false when originUrl matches existing row', async () => { + const database = {} as never + const existingRepo = { + id: 5, + localPath: 'existing-repo', + fullPath: path.join(tmpRoot, 'existing-repo'), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: Date.now(), + isLocal: false, + } + + mockGetRepoByUrlAndBranch.mockReturnValue(existingRepo) + + const { createRepoRow } = await import('../../src/services/repo') + const result = createRepoRow(database, { + name: 'new-name', + originUrl: 'https://github.com/example/repo.git', + localPath: 'new-name', + fullPath: path.join(tmpRoot, 'new-name'), + branch: 'main', + }) + + expect(mockGetRepoByUrlAndBranch).toHaveBeenCalledWith(database, 'https://github.com/example/repo.git', 'main') + expect(createRepo).not.toHaveBeenCalled() + expect(result.repo).toEqual(existingRepo) + expect(result.created).toBe(false) + }) + + it('returns existing repo with created false when localPath matches existing row', async () => { + const database = {} as never + const existingRepo = { + id: 6, + localPath: 'existing-repo', + fullPath: path.join(tmpRoot, 'existing-repo'), + branch: undefined, + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: Date.now(), + isLocal: true, + } + + mockGetRepoByLocalPath.mockReturnValue(existingRepo) + + const { createRepoRow } = await import('../../src/services/repo') + const result = createRepoRow(database, { + name: 'new-name', + localPath: 'existing-repo', + fullPath: path.join(tmpRoot, 'existing-repo'), + }) + + expect(mockGetRepoByLocalPath).toHaveBeenCalledWith(database, 'existing-repo') + expect(createRepo).not.toHaveBeenCalled() + expect(result.repo).toEqual(existingRepo) + expect(result.created).toBe(false) + }) +}) + +describe('isRepoInUse', () => { + beforeEach(() => { + vi.clearAllMocks() + tmpRoot = path.join(os.tmpdir(), `mirror-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + fs.mkdirSync(tmpRoot, { recursive: true }) + }) + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + it('returns false when repo does not exist in DB', async () => { + getRepoById.mockReturnValue(null) + mockGetActiveDirectories.mockReturnValue([]) + + const { isRepoInUse } = await import('../../src/services/repo') + const result = isRepoInUse({} as never, 999) + + expect(result).toBe(false) + }) + + it('returns false when no active sessions match repo fullPath', async () => { + getRepoById.mockReturnValue({ + id: 1, + fullPath: path.join(tmpRoot, 'demo'), + localPath: 'demo', + }) + mockGetActiveDirectories.mockReturnValue([path.join(tmpRoot, 'other')]) + + const { isRepoInUse } = await import('../../src/services/repo') + const result = isRepoInUse({} as never, 1) + + expect(result).toBe(false) + }) + + it('returns true when active session matches repo fullPath', async () => { + getRepoById.mockReturnValue({ + id: 1, + fullPath: path.join(tmpRoot, 'demo'), + localPath: 'demo', + }) + mockGetActiveDirectories.mockReturnValue([path.join(tmpRoot, 'demo')]) + + const { isRepoInUse } = await import('../../src/services/repo') + const result = isRepoInUse({} as never, 1) + + expect(result).toBe(true) + }) +}) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 9189b0d7..5299f9f6 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config' +import path from 'path' export default defineConfig({ test: { @@ -33,7 +34,7 @@ export default defineConfig({ }, resolve: { alias: { - 'bun:sqlite': './test/mocks/bun-sqlite.ts', + 'bun:sqlite': path.resolve(__dirname, './test/mocks/bun-sqlite.ts'), }, }, }) diff --git a/docs/features/opencode-workspaces.md b/docs/features/opencode-workspaces.md new file mode 100644 index 00000000..61f9017b --- /dev/null +++ b/docs/features/opencode-workspaces.md @@ -0,0 +1,5 @@ +# OpenCode Workspaces (Repo Targets) + +> This page has been relocated to [opencode-manager-workspaces.md](../opencode-manager-workspaces.md) per Phase 6 requirements. + +See the full documentation at [docs/opencode-manager-workspaces.md](../opencode-manager-workspaces.md). diff --git a/docs/opencode-manager-workspaces.md b/docs/opencode-manager-workspaces.md new file mode 100644 index 00000000..4afedec1 --- /dev/null +++ b/docs/opencode-manager-workspaces.md @@ -0,0 +1,145 @@ +# OpenCode Manager `ocm` CLI + +`ocm` is a small CLI that attaches your local OpenCode TUI to a repo hosted on an OpenCode Manager. Prompts execute on the Manager's filesystem against a single shared OpenCode server, while your laptop terminal hosts the TUI. + +## Architecture Overview + +| Component | Where it runs | Role | +|---|---|---| +| **`ocm` CLI** | Laptop / local shell | Lists repos, attaches `opencode` against the Manager proxy, mirrors `$PWD` up/down | +| **Manager backend** | Manager server | Exposes repo metadata + token-protected OpenCode proxy + tarball mirror endpoints | +| **Manager web UI** | Manager server | Reads from the shared OpenCode server; sessions created via `ocm` appear normally | + +There is no per-repo OpenCode process. All sessions share one OpenCode server on the Manager, with file-level isolation via `--dir`. + +--- + +## 1. Install + +The CLI is published as `@opencode-manager/ocm-cli`. There are two install paths. + +### Option A — install via OpenCode's plugin loader (recommended) + +Add the package to your OpenCode config and OpenCode will fetch it on next start. The package's plugin entry self-installs a `~/.local/bin/ocm` symlink, so the `ocm` binary becomes available on your PATH automatically. + +```jsonc +// ~/.config/opencode/opencode.json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@opencode-manager/ocm-cli"] +} +``` + +The next time OpenCode starts it will run `bun install` for the plugin and import it once. You'll see: + +``` +ocm-cli: installed `ocm` at /Users/you/.local/bin/ocm +``` + +If `~/.local/bin` is not on your PATH, the message will tell you. Add this to your shell rc: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +The plugin itself is a no-op — it does not register tools, commands, or hooks. Its only side effect is the bin symlink, so all real work happens through the `ocm` CLI. + +### Option B — global package manager install + +If you don't use the OpenCode plugin loader, install globally: + +```bash +bun add -g @opencode-manager/ocm-cli +# or +npm i -g @opencode-manager/ocm-cli +``` + +This puts `ocm` on your PATH via the package manager's own bin shim. The `~/.local/bin` symlink is skipped for global installs. + +### Option C — from this repository (dev) + +```bash +pnpm install +pnpm --filter @opencode-manager/ocm-cli build +# postinstall creates ~/.local/bin/ocm symlink +``` + +## 2. Log in + +```bash +ocm login https://manager.example.com +# paste your Manager internal token when prompted +``` + +The token is stored in the macOS Keychain under the manager URL. The manager URL is persisted to `~/.config/opencode-manager/state.json`. + +You can generate / rotate the Manager internal token from **Settings → Manager Token** in the Manager web UI. + +--- + +## 3. Commands + +```text +ocm Attach to the Manager repo matching $PWD's git origin, + or fall back to the last selected repo +ocm login [token] Save manager URL + token (token via stdin if omitted) +ocm logout Forget saved token (Keychain) and state +ocm status Show current manager URL, repo, and whether token is set +ocm list List ready repos from the manager +ocm use Attach to a specific repo and remember it as last +ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo +ocm pull [--force] Mirror the matching Manager repo over $PWD +ocm --help Show this help +``` + +### How bare `ocm` resolves the target + +1. If `$PWD` is inside a git repo and its `origin` matches exactly one Manager repo by URL, attach to that repo and remember it as `last`. +2. If multiple Manager repos match `origin`, fail with a hint to use `ocm use `. +3. Otherwise fall back to the previously used repo (`last`). +4. If there is no `last` either, fail with a hint to run `ocm list` then `ocm use `. + +`origin` matching uses the same normalisation as `ocm push` / `ocm pull` (case-insensitive, `.git` stripped, `git@host:path` rewritten to `ssh://git@host/path`). + +### Attach command equivalent + +Under the hood, `ocm` execs: + +```bash +opencode attach https://manager.example.com/api/opencode-proxy \ + --dir /path/to/repo/on/manager \ + --password \ + --username opencode +``` + +The child takes over the terminal (`stdio: inherit`); closing the TUI exits `ocm` but leaves the Manager-side session intact. + +### Mirror commands + +`ocm push` tarballs `$PWD` (skipping `node_modules`, `dist`, `.next`, `.venv`, `__pycache__`, `.turbo`, and anything matched by `.gitignore`) and streams it to the Manager. `ocm pull` does the reverse. + +- `--force` skips the dirty-working-tree check on `pull` and the safety bail on `push`. +- `--create` (on `push`) creates a new Manager repo when no `origin` match is found. +- `--yes` skips the interactive create confirmation. + +--- + +## 4. Environment variables + +Both can be used in place of `ocm login`: + +| Variable | Description | +|---|---| +| `OPENCODE_MANAGER_URL` | Manager base URL (e.g., `https://manager.example.com`). Not currently consumed by the CLI — use `ocm login`. | +| Keychain entry under `https://manager.example.com` | Token used for Bearer auth on Manager API calls and Basic auth on the OpenCode proxy. | + +--- + +## 5. Related endpoints + +| Endpoint | Method | Purpose | +|---|---|---| +| `/api/internal/opencode-workspaces` | GET | List ready repos with directory + originUrl | +| `/api/internal/repo-mirror/:repoId/up` | POST | Receive tarball, write to repo dir | +| `/api/internal/repo-mirror/:repoId/down` | GET | Stream tarball of repo dir | +| `/api/opencode-proxy/*` | ALL | Token-protected proxy from Manager to single OpenCode server | diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 3c48159b..1a6a2f5c 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -294,3 +294,17 @@ export async function updateOpenCodeServerAuth(password: string | null): Promise body: JSON.stringify({ password }), }) } + +export interface ManagerTokenResponse { + token: string +} + +export async function getManagerToken(): Promise { + return fetchWrapper(`${API_BASE_URL}/api/settings/manager-token`) +} + +export async function rotateManagerToken(): Promise { + return fetchWrapper(`${API_BASE_URL}/api/settings/manager-token/rotate`, { + method: 'POST', + }) +} diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index 87177af6..77ae4f76 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -51,31 +51,31 @@ export function ModelQuickSelect({ return provider ? formatProviderName(provider) : providerID }, [providersData]) - const favoriteModelsWithNames = useMemo(() => { - return favoriteModels - .filter(favorite => `${favorite.providerID}/${favorite.modelID}` !== modelString) - .slice(0, 5) - .map(favorite => ({ - ...favorite, - displayName: getDisplayName(favorite.providerID, favorite.modelID), - providerName: getProviderName(favorite.providerID), - key: `${favorite.providerID}/${favorite.modelID}`, - })) + const favoriteModelsWithNames = useMemo(() => { + return favoriteModels + .filter(favorite => `${favorite.providerID}/${favorite.modelID}` !== modelString) + .slice(0, 5) + .map(favorite => ({ + ...favorite, + displayName: getDisplayName(favorite.providerID, favorite.modelID), + providerName: getProviderName(favorite.providerID), + key: `${favorite.providerID}/${favorite.modelID}`, + })) }, [favoriteModels, getDisplayName, getProviderName, modelString]) - const recentModelsWithNames = useMemo(() => { - return recentModels - .filter(recent => { - const key = `${recent.providerID}/${recent.modelID}` - return key !== modelString && !favoriteModels.some(favorite => favorite.providerID === recent.providerID && favorite.modelID === recent.modelID) - }) - .slice(0, 5) - .map(recent => ({ - ...recent, - displayName: getDisplayName(recent.providerID, recent.modelID), - providerName: getProviderName(recent.providerID), - key: `${recent.providerID}/${recent.modelID}`, - })) + const recentModelsWithNames = useMemo(() => { + return recentModels + .filter(recent => { + const key = `${recent.providerID}/${recent.modelID}` + return key !== modelString && !favoriteModels.some(favorite => favorite.providerID === recent.providerID && favorite.modelID === recent.modelID) + }) + .slice(0, 5) + .map(recent => ({ + ...recent, + displayName: getDisplayName(recent.providerID, recent.modelID), + providerName: getProviderName(recent.providerID), + key: `${recent.providerID}/${recent.modelID}`, + })) }, [recentModels, favoriteModels, getDisplayName, getProviderName, modelString]) const duplicateDisplayNames = useMemo(() => { @@ -87,6 +87,15 @@ export function ModelQuickSelect({ return new Set(Object.entries(counts).filter(([, count]) => count > 1).map(([name]) => name)) }, [favoriteModelsWithNames, recentModelsWithNames]) + const duplicateModelIds = useMemo(() => { + const counts = [...favoriteModelsWithNames, ...recentModelsWithNames].reduce>((acc, item) => { + acc[item.modelID] = (acc[item.modelID] || 0) + 1 + return acc + }, {}) + + return new Set(Object.entries(counts).filter(([, count]) => count > 1).map(([id]) => id)) + }, [favoriteModelsWithNames, recentModelsWithNames]) + const handleVariantSelect = (variant: string | undefined) => { if (variant === undefined) { clearVariant() @@ -122,7 +131,7 @@ export function ModelQuickSelect({ <> - {duplicateDisplayNames.has(currentModelDisplayName) + {duplicateModelIds.has(model.modelID) || duplicateDisplayNames.has(currentModelDisplayName) ? `${currentProviderName}/${currentModelDisplayName}` : currentModelDisplayName} @@ -170,7 +179,7 @@ export function ModelQuickSelect({ className="flex items-center justify-between" > - {duplicateDisplayNames.has(favorite.displayName) + {duplicateModelIds.has(favorite.modelID) || duplicateDisplayNames.has(favorite.displayName) ? `${favorite.providerName}/${favorite.displayName}` : favorite.displayName} @@ -189,7 +198,7 @@ export function ModelQuickSelect({ className="flex items-center justify-between" > - {duplicateDisplayNames.has(recent.displayName) + {duplicateModelIds.has(recent.modelID) || duplicateDisplayNames.has(recent.displayName) ? `${recent.providerName}/${recent.displayName}` : recent.displayName} diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 357c4317..2861f3f8 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -58,6 +58,8 @@ function SearchInput({ onSearch, initialValue = "" }: SearchInputProps) { value={value} onChange={(e) => setValue(e.target.value)} className="pl-10 md:text-sm" + autoComplete="off" + name="model-select" /> diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index 23c19796..8d84eb62 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -87,6 +87,8 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr onChange={(e) => setSearchQuery(e.target.value)} autoFocus className="flex-1" + autoComplete="off" + name="repo-quick-switch" /> + + {isOpen && ( +
+
+
+
+ + +
+ + +
+ + {confirmRotate && ( + + + + Rotating will invalidate the existing token. Any plugin or client using it must be updated. Click Rotate again to confirm. + + + )} +
+
+ )} + + ) +} diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index 6d043b7c..0236e641 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -52,7 +52,11 @@ interface Agent { [key: string]: unknown } -export function OpenCodeConfigManager() { +interface OpenCodeConfigManagerProps { + hideHealthStatus?: boolean +} + +export function OpenCodeConfigManager({ hideHealthStatus = false }: OpenCodeConfigManagerProps) { const queryClient = useQueryClient() const { data: health } = useServerHealth() const [configs, setConfigs] = useState([]) @@ -407,7 +411,7 @@ export function OpenCodeConfigManager() { return (
- {health && ( + {!hideHealthStatus && health && (
diff --git a/frontend/src/components/settings/OpenCodeServerAuthSettings.tsx b/frontend/src/components/settings/OpenCodeServerAuthSettings.tsx index 1b834d21..4bd3b301 100644 --- a/frontend/src/components/settings/OpenCodeServerAuthSettings.tsx +++ b/frontend/src/components/settings/OpenCodeServerAuthSettings.tsx @@ -2,14 +2,21 @@ import { useState } from 'react' import { useOpenCodeServerAuth } from '@/hooks/useOpenCodeServerAuth' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Alert, AlertDescription } from '@/components/ui/alert' -import { AlertTriangle, CheckCircle2, Eye, EyeOff, XCircle } from 'lucide-react' +import { AlertTriangle, CheckCircle2, ChevronDown, Eye, EyeOff, XCircle } from 'lucide-react' -export function OpenCodeServerAuthSettings() { +interface OpenCodeServerAuthSettingsProps { + isOpen?: boolean + onToggle?: () => void +} + +export function OpenCodeServerAuthSettings({ isOpen: controlledOpen, onToggle }: OpenCodeServerAuthSettingsProps = {}) { const { status, setPassword, clearPassword } = useOpenCodeServerAuth() const [password, setPasswordValue] = useState('') const [showPassword, setShowPassword] = useState(false) + const [uncontrolledOpen, setUncontrolledOpen] = useState(true) + const isOpen = controlledOpen ?? uncontrolledOpen + const handleToggle = onToggle ?? (() => setUncontrolledOpen((open) => !open)) const handleSave = () => { if (password.length >= 8) { @@ -34,68 +41,76 @@ export function OpenCodeServerAuthSettings() { } return ( -
-
-

OpenCode Server Authentication

- -
-
- Status: - - {getStatusIcon()} - {getStatusText()} - -
+
+ - {status?.source === 'none' && ( - - - - If you set OPENCODE_HOST=0.0.0.0 in Docker without a password, the server will refuse to start. - - - )} + {isOpen && ( +
+
+ {status?.source === 'none' && ( + + + + If you set OPENCODE_HOST=0.0.0.0 in Docker without a password, the server will refuse to start. + + + )} -
- -
- setPasswordValue(e.target.value)} - placeholder="Enter new password" - className="flex-1" - /> - -
-
+
+
+ setPasswordValue(e.target.value)} + placeholder="Enter new password" + className="pr-9" + autoComplete="new-password" + name="opencode-server-password" + /> + +
- {status?.source === 'db' && ( - - )}
+ {password.length > 0 && password.length < 8 && ( +

Password must be at least 8 characters

+ )} + {status?.source === 'db' && ( + + )}
-
+ )}
) } diff --git a/frontend/src/components/settings/ServerHealthStatus.tsx b/frontend/src/components/settings/ServerHealthStatus.tsx new file mode 100644 index 00000000..d0df8e73 --- /dev/null +++ b/frontend/src/components/settings/ServerHealthStatus.tsx @@ -0,0 +1,178 @@ +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Loader2, ArrowUpCircle, RotateCcw, History, Plus } from 'lucide-react' +import { useServerHealth } from '@/hooks/useServerHealth' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { settingsApi } from '@/api/settings' +import { showToast } from '@/lib/toast' +import { invalidateConfigCaches } from '@/lib/queryInvalidation' + +interface ServerHealthStatusProps { + onCreateConfig?: () => void + onOpenVersionDialog?: () => void +} + +export function ServerHealthStatus({ onCreateConfig, onOpenVersionDialog }: ServerHealthStatusProps) { + const queryClient = useQueryClient() + const { data: health } = useServerHealth() + + const restartServerMutation = useMutation({ + mutationFn: async () => settingsApi.restartOpenCodeServer(), + onSuccess: () => { + invalidateConfigCaches(queryClient) + }, + }) + + const upgradeOpenCodeMutation = useMutation({ + mutationFn: async () => settingsApi.upgradeOpenCode(), + onSuccess: (data) => { + if (data.upgraded && data.newVersion) { + queryClient.setQueryData(['health'], (old: Record | undefined) => { + if (!old) return old + return { ...old, opencodeVersion: data.newVersion } + }) + } + invalidateConfigCaches(queryClient) + if (data.upgraded) { + showToast.success(`Upgraded to v${data.newVersion} and server restarted`, { id: 'upgrade-opencode' }) + } else { + showToast.success('OpenCode is already up to date', { id: 'upgrade-opencode' }) + } + }, + onError: (error) => { + const defaultMessage = 'Failed to upgrade OpenCode' + + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { data?: { recovered?: boolean; recoveryMessage?: string; newVersion?: string } } }).response + const data = response?.data + + if (data?.recovered && data.newVersion) { + queryClient.setQueryData(['health'], (old: Record | undefined) => { + if (!old) return old + return { ...old, opencodeVersion: data.newVersion } + }) + showToast.success(`Upgrade failed but server recovered at v${data.newVersion}`, { id: 'upgrade-opencode' }) + } else { + showToast.error(data?.recoveryMessage || defaultMessage, { id: 'upgrade-opencode' }) + } + } else { + showToast.error(defaultMessage, { id: 'upgrade-opencode' }) + } + invalidateConfigCaches(queryClient) + }, + }) + + if (!health) { + return ( + + +
+ + Loading server status... +
+
+
+ ) + } + + const isUnhealthy = health.opencode !== 'healthy' + + return ( + + +
+
+
+

+ Server Status: {isUnhealthy ? 'Unhealthy' : 'Healthy'} +

+ {health.error && ( +

+ {health.error} +

+ )} + {health.opencodeVersion && ( +

+ OpenCode v{health.opencodeVersion} +

+ )} + {health.opencodeManagerVersion && ( +

+ Manager v{health.opencodeManagerVersion} +

+ )} +
+
+ + + + +
+
+ + + ) +} diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx index 019eec97..d1720183 100644 --- a/frontend/src/components/settings/SettingsDialog.tsx +++ b/frontend/src/components/settings/SettingsDialog.tsx @@ -4,6 +4,8 @@ import { GitSettings } from '@/components/settings/GitSettings' import { KeyboardShortcuts } from '@/components/settings/KeyboardShortcuts' import { OpenCodeConfigManager } from '@/components/settings/OpenCodeConfigManager' import { OpenCodeServerAuthSettings } from '@/components/settings/OpenCodeServerAuthSettings' +import { ManagerTokenSettings } from '@/components/settings/ManagerTokenSettings' +import { ServerHealthStatus } from '@/components/settings/ServerHealthStatus' import { ProviderSettings } from '@/components/settings/ProviderSettings' import { AccountSettings } from '@/components/settings/AccountSettings' import { VoiceSettings } from '@/components/settings/VoiceSettings' @@ -20,6 +22,8 @@ export function SettingsDialog() { const { isOpen, close, activeTab, setActiveTab } = useSettingsDialog() const [mobileView, setMobileView] = useState('menu') const [sectionHistory, setSectionHistory] = useState([]) + const [authSectionsOpen, setAuthSectionsOpen] = useState(true) + const toggleAuthSections = useCallback(() => setAuthSectionsOpen((open) => !open), []) const pushSectionHistory = useCallback((view: SettingsView) => { if (view === 'menu') return @@ -154,8 +158,12 @@ export function SettingsDialog() {
- - + +
+ + +
+
@@ -220,12 +228,14 @@ export function SettingsDialog() { {mobileView === 'voice' &&
} {mobileView === 'git' &&
} {mobileView === 'shortcuts' &&
} - {mobileView === 'opencode' && ( -
- - -
- )} + {mobileView === 'opencode' && ( +
+ + + + +
+ )} {mobileView === 'providers' &&
}
diff --git a/frontend/src/components/source-control/GitErrorBanner.tsx b/frontend/src/components/source-control/GitErrorBanner.tsx index 0d09d868..3e6a4a5c 100644 --- a/frontend/src/components/source-control/GitErrorBanner.tsx +++ b/frontend/src/components/source-control/GitErrorBanner.tsx @@ -1,6 +1,4 @@ -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { AlertCircle, X } from 'lucide-react' +import { ErrorBanner } from '@/components/ui/error-banner' interface GitErrorBannerProps { error: { summary: string; detail?: string } @@ -9,26 +7,11 @@ interface GitErrorBannerProps { export function GitErrorBanner({ error, onDismiss }: GitErrorBannerProps) { return ( - -
-
- - {error.summary} - -
- {error.detail && ( -
-            {error.detail}
-          
- )} -
-
+ ) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/error-banner.test.tsx b/frontend/src/components/ui/error-banner.test.tsx new file mode 100644 index 00000000..d4970cc3 --- /dev/null +++ b/frontend/src/components/ui/error-banner.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { ErrorBanner } from './error-banner' + +describe('ErrorBanner', () => { + it('renders summary text', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders title when provided', () => { + render() + expect(screen.getByText('Error')).toBeInTheDocument() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('renders detail when provided', () => { + render( + , + ) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByText('Stack trace here')).toBeInTheDocument() + }) + + it('does not render dismiss button when onDismiss is not provided', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders dismiss button when onDismiss is provided', () => { + const onDismiss = vi.fn() + render( + , + ) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('calls onDismiss when dismiss button is clicked', () => { + const onDismiss = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button')) + expect(onDismiss).toHaveBeenCalled() + }) + + it('applies custom className', () => { + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-class') + }) +}) \ No newline at end of file diff --git a/frontend/src/components/ui/error-banner.tsx b/frontend/src/components/ui/error-banner.tsx new file mode 100644 index 00000000..3897ca90 --- /dev/null +++ b/frontend/src/components/ui/error-banner.tsx @@ -0,0 +1,44 @@ +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { AlertCircle, X } from 'lucide-react' + +export interface ErrorBannerProps { + title?: string + summary: string + detail?: string + onDismiss?: () => void + className?: string +} + +export function ErrorBanner({ title, summary, detail, onDismiss, className }: ErrorBannerProps) { + return ( + +
+
+ +
+ {title && ( + {title} + )} + {summary} +
+ {onDismiss && ( + + )} +
+ {detail && ( +
+            {detail}
+          
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/hooks/useManagerToken.ts b/frontend/src/hooks/useManagerToken.ts new file mode 100644 index 00000000..418a75c4 --- /dev/null +++ b/frontend/src/hooks/useManagerToken.ts @@ -0,0 +1,24 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { getManagerToken, rotateManagerToken } from '@/api/settings' + +export function useManagerToken() { + const queryClient = useQueryClient() + + const query = useQuery({ + queryKey: ['settings', 'manager-token'], + queryFn: getManagerToken, + }) + + const rotate = useMutation({ + mutationFn: rotateManagerToken, + onSuccess: (data) => { + queryClient.setQueryData(['settings', 'manager-token'], data) + }, + }) + + return { + token: query.data?.token, + isLoading: query.isLoading, + rotate, + } +} diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index da45f131..91dcd44a 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -12,6 +12,7 @@ import type { paths, components } from "../api/opencode-types"; import { parseNetworkError } from "../lib/opencode-errors"; import { showToast } from "../lib/toast"; import { useSessionStatus } from "../stores/sessionStatusStore"; +import { useSendErrorStore } from "../stores/sendErrorStore"; type AssistantMessage = components["schemas"]["AssistantMessage"]; @@ -300,6 +301,22 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: if (model) { const parsedModel = parseModelString(model); if (parsedModel) { + const cachedProviders = queryClient.getQueryData<{ + providers: Array<{ id: string; models: Record }>; + }>(['opencode', 'providers', opcodeUrl, directory]); + if (cachedProviders?.providers) { + const provider = cachedProviders.providers.find( + (p) => p.id === parsedModel.providerID, + ); + if (!provider || !(parsedModel.modelID in provider.models)) { + throw new FetchError( + 'Selected model is no longer available. Pick a different model.', + 409, + 'MODEL_UNAVAILABLE', + ); + } + } + requestData.model = { providerID: parsedModel.providerID, modelID: parsedModel.modelID, @@ -342,9 +359,11 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: } const parsed = parseNetworkError(error); - showToast.error(parsed.title, { - description: parsed.message, - duration: 5000, + useSendErrorStore.getState().setError({ + sessionID, + title: parsed.title, + message: parsed.message, + detail: error instanceof FetchError ? error.detail : undefined, }); }, onSuccess: async (data, variables) => { @@ -352,6 +371,8 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: const { response } = data; const messagesQueryKey = ["opencode", "messages", opcodeUrl, sessionID, directory]; + useSendErrorStore.getState().clearError(sessionID); + if (data.queued || !response) { queryClient.invalidateQueries({ queryKey: messagesQueryKey }); return; diff --git a/frontend/src/hooks/useSendPrompt.test.ts b/frontend/src/hooks/useSendPrompt.test.ts new file mode 100644 index 00000000..4c36a341 --- /dev/null +++ b/frontend/src/hooks/useSendPrompt.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement } from 'react' +import { useSendPrompt } from './useOpenCode' +import { FetchError } from '../api/fetchWrapper' + +const mockSendPrompt = vi.fn() +const mockSendPromptAsync = vi.fn() + +vi.mock('../api/opencode', async () => { + const actual = await vi.importActual('../api/opencode') + return { + ...actual, + OpenCodeClient: vi.fn().mockImplementation(() => ({ + sendPrompt: mockSendPrompt, + sendPromptAsync: mockSendPromptAsync, + })), + } +}) + +vi.mock('@/stores/sessionStatusStore', () => ({ + useSessionStatus: vi.fn(() => vi.fn()), +})) + +vi.mock('../lib/toast', () => ({ + showToast: { error: vi.fn() }, +})) + +vi.mock('../lib/opencode-errors', () => ({ + parseNetworkError: vi.fn((err) => ({ + title: 'Error', + message: err.message, + isRetryable: false, + })), +})) + +const mockClearError = vi.fn() +const mockSetError = vi.fn() + +vi.mock('../stores/sendErrorStore', () => ({ + useSendErrorStore: { + getState: () => ({ + clearError: mockClearError, + setError: mockSetError, + getError: vi.fn(), + }), + }, +})) + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + +describe('useSendPrompt', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + queryClient = createTestQueryClient() + mockSendPrompt.mockResolvedValue({ + info: { id: 'test-response' }, + parts: [], + }) + mockSendPromptAsync.mockResolvedValue(undefined) + }) + + const renderHookWithProviders = () => + renderHook( + () => useSendPrompt('http://localhost:5551', '/test'), + { + wrapper: ({ children }) => + createElement(QueryClientProvider, { client: queryClient }, children), + } + ) + + it('proceeds when no providers in cache', async () => { + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'test-session', + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4', + }) + ).resolves.toBeDefined() + + expect(mockSendPrompt).toHaveBeenCalled() + }) + + it('throws FetchError with MODEL_UNAVAILABLE when model not in providers', async () => { + queryClient.setQueryData( + ['opencode', 'providers', 'http://localhost:5551', '/test'], + { + providers: [ + { + id: 'openai', + name: 'OpenAI', + models: { + 'gpt-4': { id: 'gpt-4', name: 'GPT-4' }, + }, + isConnected: true, + }, + ], + connected: ['openai'], + default: {}, + } + ) + + const { result } = renderHookWithProviders() + + let error: Error | undefined + try { + await result.current.mutateAsync({ + sessionID: 'test-session', + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4', + }) + } catch (e) { + error = e as Error + } + + expect(error).toBeInstanceOf(FetchError) + expect((error as FetchError).code).toBe('MODEL_UNAVAILABLE') + expect((error as FetchError).statusCode).toBe(409) + expect(error!.message).toBe('Selected model is no longer available. Pick a different model.') + expect(mockSendPrompt).not.toHaveBeenCalled() + }) + + it('proceeds when model exists in providers', async () => { + queryClient.setQueryData( + ['opencode', 'providers', 'http://localhost:5551', '/test'], + { + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, + }, + isConnected: true, + }, + ], + connected: ['anthropic'], + default: {}, + } + ) + + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'test-session', + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4', + }) + ).resolves.toBeDefined() + + expect(mockSendPrompt).toHaveBeenCalled() + }) + + it('clears stored send error on successful queued retry', async () => { + mockClearError.mockClear() + + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'session-1', + prompt: 'Hello', + queued: true, + }) + ).resolves.toEqual(expect.objectContaining({ queued: true })) + + expect(mockClearError).toHaveBeenCalledWith('session-1') + }) + + it('clears stored send error on successful non-queued response', async () => { + mockClearError.mockClear() + + const { result } = renderHookWithProviders() + + await expect( + result.current.mutateAsync({ + sessionID: 'session-2', + prompt: 'Hello', + }) + ).resolves.toBeDefined() + + expect(mockClearError).toHaveBeenCalledWith('session-2') + }) +}) diff --git a/frontend/src/index.css b/frontend/src/index.css index 38d1cd9a..854030bb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -330,6 +330,16 @@ body { } } +/* Normalize autofill backgrounds in dark mode */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-text-fill-color: inherit !important; + -webkit-box-shadow: 0 0 0px 1000px transparent inset !important; + transition: background-color 5000s ease-in-out 0s; +} + /* Syntax highlighting for markdown code blocks */ @import 'highlight.js/styles/github.css' layer(highlight-light); @import 'highlight.js/styles/github-dark.css' layer(highlight-dark); diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 982ccef6..c1100a9f 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -47,6 +47,7 @@ import { QuestionPrompt } from "@/components/session/QuestionPrompt"; import { MinimizedQuestionIndicator } from "@/components/session/MinimizedQuestionIndicator"; import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; import { SourceControlPanel } from "@/components/source-control"; +import { SessionSendErrorBanner } from "@/components/session/SessionSendErrorBanner"; import { SessionTodoDisplay } from "@/components/message/SessionTodoDisplay"; import { useDialogParam } from "@/hooks/useDialogParam"; import { useSidebarAction } from "@/hooks/useSidebarAction"; @@ -581,6 +582,7 @@ export function SessionDetail() { onMinimize={() => handleMinimizeQuestion(currentQuestion)} /> )} + ({ + showToast: { error: vi.fn() }, +})) + +describe('SessionDetail send error integration', () => { + beforeEach(() => { + vi.clearAllMocks() + useSendErrorStore.setState({ errors: {} }) + }) + + it('shows error banner with title, message, and detail when store is seeded', () => { + useSendErrorStore.getState().setError({ + sessionID: 'sess-1', + title: 'Model Unavailable', + message: 'Selected model is no longer available.', + detail: '409 Conflict', + }) + + render() + + expect(screen.getByText('Model Unavailable')).toBeInTheDocument() + expect(screen.getByText('Selected model is no longer available.')).toBeInTheDocument() + expect(screen.getByText('409 Conflict')).toBeInTheDocument() + }) + + it('dismisses banner and clears store entry on click', () => { + useSendErrorStore.getState().setError({ + sessionID: 'sess-2', + title: 'Error', + message: 'Something failed', + }) + + render() + expect(screen.getByText('Something failed')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button')) + + expect(screen.queryByText('Something failed')).not.toBeInTheDocument() + expect(useSendErrorStore.getState().getError('sess-2')).toBeNull() + }) + + it('does not trigger a toast error when banner renders', async () => { + const { showToast } = await import('@/lib/toast') + + useSendErrorStore.getState().setError({ + sessionID: 'sess-3', + title: 'Error', + message: 'No toast please', + }) + + render() + expect(screen.getByText('No toast please')).toBeInTheDocument() + expect(showToast.error).not.toHaveBeenCalled() + }) + + it('does not render banner when no error exists for session', () => { + render() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/stores/modelStore.test.ts b/frontend/src/stores/modelStore.test.ts new file mode 100644 index 00000000..db73f40f --- /dev/null +++ b/frontend/src/stores/modelStore.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useModelStore } from '@/stores/modelStore' +import type { Provider } from '@/api/providers' + +vi.mock('zustand/middleware', async () => { + const actual = await vi.importActual('zustand/middleware') + return { + ...actual, + persist: (config: any) => config, + } +}) + +function makeProvider(overrides: Partial): Provider { + return { + id: overrides.id ?? 'test-provider', + name: overrides.name ?? 'Test Provider', + models: overrides.models ?? {}, + env: [], + isConnected: true, + options: {}, + ...overrides, + } +} + +describe('validateAndSyncModel', () => { + beforeEach(() => { + useModelStore.setState({ + model: null, + agentModels: {}, + recentModels: [], + favoriteModels: [], + variants: {}, + lastConfigModel: undefined, + }) + }) + + it('falls back to syncFromConfig when providers is undefined', () => { + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', undefined) + + expect(useModelStore.getState().model).toEqual({ providerID: 'anthropic', modelID: 'claude-sonnet-4' }) + }) + + it('sets active model from configModel when current model not in providers but configModel parses to valid model', () => { + const providers = [ + makeProvider({ + id: 'openai', + models: { 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + }) + + useModelStore.getState().validateAndSyncModel('openai/gpt-4o', providers) + + expect(useModelStore.getState().model).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + }) + + it('clears active model when invalid and no config fallback', () => { + const providers = [ + makeProvider({ + id: 'openai', + models: { 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + }) + + useModelStore.getState().validateAndSyncModel(undefined, providers) + + expect(useModelStore.getState().model).toBeNull() + }) + + it('prunes stale recents and favorites, keeps valid entries', () => { + const providers = [ + makeProvider({ + id: 'anthropic', + models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + recentModels: [ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + { providerID: 'openai', modelID: 'gpt-4o' }, + ], + favoriteModels: [ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + { providerID: 'openai', modelID: 'gpt-4o' }, + ], + }) + + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', providers) + + expect(useModelStore.getState().recentModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) + expect(useModelStore.getState().favoriteModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) + }) + + it('is idempotent when re-running with same valid state', () => { + const providers = [ + makeProvider({ + id: 'anthropic', + models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' } }, + }), + ] + + useModelStore.setState({ + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + recentModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + }) + + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', providers) + + const afterFirst = useModelStore.getState() + + let updateCount = 0 + const unsubscribe = useModelStore.subscribe(() => { + updateCount++ + }) + + useModelStore.getState().validateAndSyncModel('anthropic/claude-sonnet-4', providers) + unsubscribe() + + const afterSecond = useModelStore.getState() + + expect(updateCount).toBe(0) + expect(afterSecond.model).toEqual(afterFirst.model) + expect(afterSecond.recentModels).toEqual(afterFirst.recentModels) + expect(afterSecond.favoriteModels).toEqual(afterFirst.favoriteModels) + }) +}) diff --git a/frontend/src/stores/modelStore.ts b/frontend/src/stores/modelStore.ts index 69108726..66c83bcf 100644 --- a/frontend/src/stores/modelStore.ts +++ b/frontend/src/stores/modelStore.ts @@ -129,15 +129,15 @@ export const useModelStore = create()( }, validateAndSyncModel: (configModel: string | undefined, providers?: Provider[]) => { - if (!configModel) return - - const state = get() - if (!providers) { - get().syncFromConfig(configModel) + if (configModel) { + get().syncFromConfig(configModel) + } return } + const state = get() + const modelExists = (model: ModelSelection) => providers.some( (p) => p.id === model.providerID && p.models && model.modelID in p.models @@ -157,7 +157,16 @@ export const useModelStore = create()( } if (!currentModelExists) { - get().syncFromConfig(configModel, true) + const parsedConfig = configModel ? parseModelString(configModel) : null + const configIsValid = parsedConfig + ? providers.some(p => p.id === parsedConfig.providerID && p.models && parsedConfig.modelID in p.models) + : false + + if (configIsValid && configModel) { + get().syncFromConfig(configModel, true) + } else { + set({ model: null, lastConfigModel: configModel }) + } } }, diff --git a/frontend/src/stores/sendErrorStore.test.ts b/frontend/src/stores/sendErrorStore.test.ts new file mode 100644 index 00000000..c5e9dad7 --- /dev/null +++ b/frontend/src/stores/sendErrorStore.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useSendErrorStore } from './sendErrorStore' + +describe('useSendErrorStore', () => { + beforeEach(() => { + useSendErrorStore.setState({ errors: {} }) + }) + + it('stores error keyed by sessionID', () => { + const err = { sessionID: 'session-1', title: 'Error', message: 'Something failed' } + useSendErrorStore.getState().setError(err) + expect(useSendErrorStore.getState().getError('session-1')).toEqual(err) + }) + + it('clears error only for the specified sessionID', () => { + useSendErrorStore.getState().setError({ sessionID: 'session-1', title: 'Error', message: 'msg1' }) + useSendErrorStore.getState().setError({ sessionID: 'session-2', title: 'Error', message: 'msg2' }) + useSendErrorStore.getState().clearError('session-1') + expect(useSendErrorStore.getState().getError('session-1')).toBeNull() + expect(useSendErrorStore.getState().getError('session-2')).not.toBeNull() + }) + + it('returns null when no error exists for sessionID', () => { + expect(useSendErrorStore.getState().getError('nonexistent')).toBeNull() + }) +}) diff --git a/frontend/src/stores/sendErrorStore.ts b/frontend/src/stores/sendErrorStore.ts new file mode 100644 index 00000000..0863e33a --- /dev/null +++ b/frontend/src/stores/sendErrorStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' + +export interface SendError { + sessionID: string + title: string + message: string + detail?: string +} + +interface SendErrorStore { + errors: Record + setError: (err: SendError) => void + clearError: (sessionID: string) => void + getError: (sessionID: string) => SendError | null +} + +export const useSendErrorStore = create((set, get) => ({ + errors: {}, + setError: (err: SendError) => { + set((state) => ({ + errors: { ...state.errors, [err.sessionID]: err }, + })) + }, + clearError: (sessionID: string) => { + set((state) => { + const newErrors = { ...state.errors } + delete newErrors[sessionID] + return { errors: newErrors } + }) + }, + getError: (sessionID: string) => { + return get().errors[sessionID] || null + }, +})) diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts new file mode 100644 index 00000000..7a14ec01 --- /dev/null +++ b/ocm-cli/bin/ocm.ts @@ -0,0 +1,420 @@ +import { spawn, spawnSync } from 'child_process' +import { basename } from 'path' +import { readState, writeState, clearState, getStatePath, type OcmState } from '../src/state.js' +import { getToken, setToken, deleteToken, KeychainError } from '../src/keychain.js' +import { ManagerApi } from '../src/manager-api.js' +import { mirrorUp, mirrorDown, prepareMirror } from '../src/mirror.js' +import type { RemoteRepoSummary } from '../src/mirror.js' +import { getBranchName } from '../src/local-repo.js' +import { resolveTarget } from '../src/resolve-target.js' + +const USAGE = `ocm - OpenCode Manager workspace launcher + +Usage: + ocm Attach to the Manager repo matching $PWD's git origin, + fall back to the last selected repo, or launch local + opencode when no Manager target applies + ocm login [token] Save manager URL + token (token via stdin if omitted) + ocm logout Forget saved token (Keychain) and state + ocm status Show current manager URL, repo, and whether token is set + ocm list List ready repos from the manager + ocm use Attach to a specific repo and remember it as last + ocm push [--force] [--create] [--yes] Mirror $PWD to the matching Manager repo (or create one) + ocm pull [--force] Mirror the matching Manager repo over $PWD + ocm --help Show this help +` + +interface ManagerRepo { + repoId: number + name: string + branch: string | null + cloneStatus: string + directory: string + originUrl?: string | null + extra: { repoId: number; localPath: string; fullPath: string } +} + +function die(msg: string, code = 1): never { + process.stderr.write(`ocm: ${msg}\n`) + process.exit(code) +} + +function info(msg: string): void { + process.stdout.write(`${msg}\n`) +} + +function requireState(): OcmState { + const state = readState() + if (!state || !state.managerUrl) { + die(`no manager configured. Run \`ocm login \` first.`) + } + return state +} + +function requireToken(state: OcmState): string { + const token = getToken(state.managerUrl) + if (!token) { + die(`no token in Keychain for ${state.managerUrl}. Run \`ocm login ${state.managerUrl}\`.`) + } + return token +} + +async function fetchRepos(managerUrl: string, token: string): Promise { + const res = await fetch(`${managerUrl}/api/internal/opencode-workspaces`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) { + throw new Error(`manager responded ${res.status} ${res.statusText}`) + } + const data = (await res.json()) as { workspaces: ManagerRepo[] } + return data.workspaces +} + +function attach(managerUrl: string, token: string, repo: ManagerRepo): never { + const proxyUrl = `${managerUrl}/api/opencode-proxy` + const args = [ + 'attach', + proxyUrl, + '--dir', repo.directory, + '--password', token, + '--username', 'opencode', + ] + const child = spawn('opencode', args, { stdio: 'inherit' }) + child.on('close', (code) => process.exit(code ?? 0)) + child.on('error', (err) => die(`failed to spawn opencode: ${err.message}`)) + // hand control to child + return undefined as never +} + +function findRepo(repos: ManagerRepo[], needle: string | number): ManagerRepo | undefined { + if (typeof needle === 'number') { + return repos.find((r) => r.repoId === needle) + } + const asNum = Number(needle) + if (!Number.isNaN(asNum)) { + const byId = repos.find((r) => r.repoId === asNum) + if (byId) return byId + } + return repos.find((r) => r.name === needle) ?? repos.find((r) => r.name.toLowerCase() === needle.toLowerCase()) +} + +async function cmdLogin(args: string[]): Promise { + const url = args[0] + if (!url) die('usage: ocm login [token]') + const normalisedUrl = url.replace(/\/+$/, '') + + let token = args[1] + if (!token) { + if (process.stdin.isTTY) { + process.stderr.write('Paste token (input hidden): ') + const res = spawnSync('bash', ['-c', 'read -s LINE && printf "%s" "$LINE"'], { + stdio: ['inherit', 'pipe', 'inherit'], + encoding: 'utf-8', + }) + process.stderr.write('\n') + token = (res.stdout ?? '').trim() + } else { + token = await new Promise((resolve) => { + const chunks: Buffer[] = [] + process.stdin.on('data', (c) => chunks.push(c)) + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim())) + }) + } + } + if (!token) die('no token provided') + + try { + setToken(normalisedUrl, token) + } catch (err) { + if (err instanceof KeychainError) die(`Keychain error: ${err.message}`) + throw err + } + + const existing = readState() + writeState({ + ...existing, + managerUrl: normalisedUrl, + }) + + info(`Saved token for ${normalisedUrl} in Keychain.`) + info(`State file: ${getStatePath()}`) +} + +async function cmdLogout(): Promise { + const state = readState() + if (!state || !state.managerUrl) { + info('Nothing to log out from.') + return + } + const deleted = deleteToken(state.managerUrl) + clearState() + info(deleted ? `Removed Keychain entry for ${state.managerUrl}.` : `No Keychain entry found.`) + info('State cleared.') +} + +async function cmdStatus(): Promise { + const state = readState() + if (!state) { + info('no state. run: ocm login ') + return + } + info(`manager url: ${state.managerUrl}`) + info(`token in kc: ${getToken(state.managerUrl) ? 'yes' : 'no'}`) + if (state.lastRepoId !== undefined) { + info(`last repo: ${state.lastRepoName} (id=${state.lastRepoId}, branch=${state.lastRepoBranch ?? 'n/a'})`) + info(`last repo dir: ${state.lastRepoDir}`) + } else { + info('last repo: (none)') + } + info(`state file: ${getStatePath()}`) +} + +async function cmdList(): Promise { + const state = requireState() + const token = requireToken(state) + const repos = await fetchRepos(state.managerUrl, token) + if (repos.length === 0) { + info('No ready repos.') + return + } + const idWidth = Math.max(...repos.map((r) => String(r.repoId).length)) + const nameWidth = Math.max(...repos.map((r) => r.name.length)) + for (const r of repos) { + const id = String(r.repoId).padStart(idWidth) + const name = r.name.padEnd(nameWidth) + const branch = r.branch ? ` (${r.branch})` : '' + info(`${id} ${name} ${r.cloneStatus}${branch}`) + } +} + +async function cmdUse(args: string[]): Promise { + const needle = args[0] + if (!needle) die('usage: ocm use ') + const state = requireState() + const token = requireToken(state) + const repos = await fetchRepos(state.managerUrl, token) + const repo = findRepo(repos, needle) + if (!repo) die(`repo not found: ${needle}`) + + writeState({ + ...state, + lastRepoId: repo.repoId, + lastRepoName: repo.name, + lastRepoDir: repo.directory, + lastRepoBranch: repo.branch, + }) + + attach(state.managerUrl, token, repo) +} + +async function cmdDefault(): Promise { + const state = requireState() + const token = requireToken(state) + + const last = state.lastRepoId !== undefined && state.lastRepoDir + ? { + repoId: state.lastRepoId, + name: state.lastRepoName ?? `repo-${state.lastRepoId}`, + directory: state.lastRepoDir, + branch: state.lastRepoBranch ?? null, + } + : undefined + + const repos = await fetchRepos(state.managerUrl, token) + const result = resolveTarget({ cwd: process.cwd(), repos, last }) + + switch (result.kind) { + case 'cwd-match': { + const repo = result.repo + info(`attaching to ${repo.name} (matched $PWD origin)`) + writeState({ + ...state, + lastRepoId: repo.repoId, + lastRepoName: repo.name, + lastRepoDir: repo.directory, + lastRepoBranch: repo.branch, + }) + attach(state.managerUrl, token, toManagerRepo(repo)) + return + } + case 'last': + attach(state.managerUrl, token, toManagerRepo(result.repo)) + return + case 'cwd-ambiguous': { + const names = result.matches.map((r) => `${r.name} (id=${r.repoId})`).join(', ') + die(`multiple Manager repos match origin ${result.localOrigin}: ${names}; disambiguate with \`ocm use \``) + } + case 'local': + runLocalOpencode(result.reason) + return + } +} + +function runLocalOpencode(reason: 'no-match' | 'no-target'): never { + const message = reason === 'no-match' + ? 'no Manager repo matches $PWD; launching local opencode' + : 'no last repo; launching local opencode' + process.stderr.write(`ocm: ${message}\n`) + const child = spawn('opencode', [], { stdio: 'inherit' }) + child.on('close', (code) => process.exit(code ?? 0)) + child.on('error', (err) => die(`failed to spawn opencode: ${err.message}`)) + return undefined as never +} + +function toManagerRepo(repo: { repoId: number; name: string; branch: string | null; directory: string }): ManagerRepo { + return { + repoId: repo.repoId, + name: repo.name, + branch: repo.branch, + cloneStatus: 'ready', + directory: repo.directory, + extra: { repoId: repo.repoId, localPath: '', fullPath: repo.directory }, + } +} + +export async function cmdPush(args: string[]): Promise { + let force = false + let create = false + let yes = false + + for (const arg of args) { + if (arg === '--force') force = true + else if (arg === '--create') create = true + else if (arg === '--yes') yes = true + } + + const state = requireState() + const token = requireToken(state) + const api = new ManagerApi(state.managerUrl, token) + const repos = await fetchRepos(state.managerUrl, token) + + const remotes: RemoteRepoSummary[] = repos.map((r) => ({ + repoId: r.repoId, + name: r.name, + originUrl: r.originUrl ?? null, + branch: r.branch, + })) + + const plan = prepareMirror(process.cwd(), remotes) + + if (plan.matched.length === 0) { + if (!create) { + die(`no matching Manager repo for origin ${plan.localOrigin}. Re-run with --create to create one.`) + } + + const name = basename(plan.repoRoot) + const branch = getBranchName(plan.repoRoot) + + if (process.stdin.isTTY && !yes) { + process.stderr.write(`Create Manager repo "${name}" by uploading ${plan.repoRoot} (origin: ${plan.localOrigin})? [y/N] `) + const res = spawnSync('bash', ['-c', 'read LINE && printf "%s" "$LINE"'], { + stdio: ['inherit', 'pipe', 'inherit'], + encoding: 'utf-8', + }) + const answer = (res.stdout ?? '').trim().toLowerCase() + if (answer !== 'y' && answer !== 'yes') { + die('aborted') + } + } else if (!process.stdin.isTTY && !yes) { + die('stdin is not a TTY; pass --yes to confirm creation') + } + + const result = await mirrorUp(plan, { + api, + force, + create: { name, originUrl: plan.localOrigin, branch }, + }) + + info(`pushed ${plan.repoRoot} -> ${result.created ? 'created' : 'updated'} (repoId=${result.repoId}, branch=${result.branch})`) + } else if (plan.matched.length === 1) { + const result = await mirrorUp(plan, { api, force }) + info(`pushed ${plan.repoRoot} -> ${plan.matched[0]!.name} (repoId=${result.repoId}, branch=${result.branch})`) + } else { + const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(', ') + die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm push \``) + } +} + +async function cmdPull(args: string[]): Promise { + let force = false + + for (const arg of args) { + if (arg === '--force') force = true + } + + const state = requireState() + const token = requireToken(state) + const api = new ManagerApi(state.managerUrl, token) + const repos = await fetchRepos(state.managerUrl, token) + + const remotes: RemoteRepoSummary[] = repos.map((r) => ({ + repoId: r.repoId, + name: r.name, + originUrl: r.originUrl ?? null, + branch: r.branch, + })) + + const plan = prepareMirror(process.cwd(), remotes) + + if (plan.matched.length === 0) { + die(`no matching Manager repo for origin ${plan.localOrigin}.`) + } + + if (plan.matched.length > 1) { + const names = plan.matched.map((r) => `${r.name} (id=${r.repoId})`).join(', ') + die(`multiple Manager repos match origin ${plan.localOrigin}: ${names}; disambiguate with \`ocm pull \``) + } + + await mirrorDown(plan.matched[0]!.repoId, plan.repoRoot, api, { force }) + info(`pulled ${plan.matched[0]!.name} -> ${plan.repoRoot}`) +} + +async function main(): Promise { + const [, , cmd, ...rest] = process.argv + + if (cmd === '--help' || cmd === '-h' || cmd === 'help') { + process.stdout.write(USAGE) + return + } + + try { + switch (cmd) { + case undefined: + await cmdDefault() + break + case 'login': + await cmdLogin(rest) + break + case 'logout': + await cmdLogout() + break + case 'status': + await cmdStatus() + break + case 'list': + case 'ls': + await cmdList() + break + case 'use': + case 'attach': + await cmdUse(rest) + break + case 'push': + await cmdPush(rest) + break + case 'pull': + await cmdPull(rest) + break + default: + die(`unknown command: ${cmd}. run \`ocm --help\``) + } + } catch (err) { + die(err instanceof Error ? err.message : String(err)) + } +} + +// Only run main when executed directly (not imported by tests). +const entryUrl = process.argv[1] ? `file://${process.argv[1]}` : '' +if (entryUrl === import.meta.url || process.argv[1]?.endsWith('/ocm.js') || process.argv[1]?.endsWith('/ocm')) { + void main() +} diff --git a/ocm-cli/package.json b/ocm-cli/package.json new file mode 100644 index 00000000..38fc7b2b --- /dev/null +++ b/ocm-cli/package.json @@ -0,0 +1,39 @@ +{ + "name": "@opencode-manager/ocm-cli", + "version": "0.1.0", + "description": "OpenCode Manager CLI: attach a local OpenCode TUI to a Manager-hosted repo.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/chriswritescode-dev/opencode-manager.git", + "directory": "ocm-cli" + }, + "type": "module", + "main": "./dist/plugin.js", + "exports": { + ".": { + "import": "./dist/plugin.js" + } + }, + "bin": { + "ocm": "./dist/ocm.js" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "bun scripts/build.ts", + "postinstall": "node scripts/postinstall.mjs || true", + "typecheck": "tsc --noEmit", + "test": "bun scripts/build.ts && vitest run", + "test:watch": "vitest", + "prepublishOnly": "bun scripts/build.ts" + }, + "dependencies": {}, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vitest": "^3.1.0" + } +} diff --git a/ocm-cli/scripts/build.ts b/ocm-cli/scripts/build.ts new file mode 100644 index 00000000..d1d50936 --- /dev/null +++ b/ocm-cli/scripts/build.ts @@ -0,0 +1,47 @@ +import { chmodSync, writeFileSync, rmSync, readFileSync } from 'fs' +import { join } from 'path' + +const root = join(import.meta.dir, '..') +const dist = join(root, 'dist') + +rmSync(dist, { recursive: true, force: true }) + +console.log('Bundling ocm CLI...') +const cliResult = await Bun.build({ + entrypoints: [join(root, 'bin', 'ocm.ts')], + outdir: dist, + target: 'node', + format: 'esm', + naming: { entry: 'ocm.js' }, +}) + +if (!cliResult.success) { + for (const log of cliResult.logs) { + console.error(log) + } + process.exit(1) +} + +console.log('Bundling opencode plugin entry...') +const pluginResult = await Bun.build({ + entrypoints: [join(root, 'src', 'plugin.ts')], + outdir: dist, + target: 'node', + format: 'esm', + naming: { entry: 'plugin.js' }, +}) + +if (!pluginResult.success) { + for (const log of pluginResult.logs) { + console.error(log) + } + process.exit(1) +} + +const ocmJsPath = join(dist, 'ocm.js') +const ocmJsContent = readFileSync(ocmJsPath, 'utf-8') +const withoutShebang = ocmJsContent.startsWith('#!') ? ocmJsContent.slice(ocmJsContent.indexOf('\n') + 1) : ocmJsContent +writeFileSync(ocmJsPath, `#!/usr/bin/env node\n${withoutShebang}`) +chmodSync(ocmJsPath, 0o755) + +console.log('Build complete') diff --git a/ocm-cli/scripts/postinstall.mjs b/ocm-cli/scripts/postinstall.mjs new file mode 100644 index 00000000..1b2bb257 --- /dev/null +++ b/ocm-cli/scripts/postinstall.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, symlinkSync, unlinkSync, lstatSync, readlinkSync, chmodSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const target = join(root, 'dist', 'ocm.js') + +if (!existsSync(target)) { + // dist not built yet — silently skip + process.exit(0) +} + +try { + chmodSync(target, 0o755) +} catch { + // best effort +} + +// If installed via `npm i -g` / `bun add -g`, the binary is already linked +// by the package manager via the `bin` field. Only do the ~/.local/bin +// symlink for local workspace installs. +if (process.env.npm_config_global === 'true' || process.env.npm_config_global === true) { + process.exit(0) +} + +const binDir = join(homedir(), '.local', 'bin') + +try { + mkdirSync(binDir, { recursive: true }) +} catch (err) { + process.stderr.write(`ocm-cli: cannot create ${binDir}: ${err.message}\n`) + process.exit(0) +} + +const link = join(binDir, 'ocm') + +try { + const stat = lstatSync(link) + if (stat.isSymbolicLink()) { + if (readlinkSync(link) === target) { + process.exit(0) + } + unlinkSync(link) + } else { + process.stderr.write(`ocm-cli: ${link} exists and is not a symlink; leaving alone\n`) + process.exit(0) + } +} catch { + // missing — fine +} + +try { + symlinkSync(target, link) +} catch (err) { + process.stderr.write(`ocm-cli: failed to symlink ${link}: ${err.message}\n`) + process.exit(0) +} + +process.stdout.write(`ocm installed at ${link}\n`) + +const path = process.env.PATH ?? '' +if (!path.split(':').includes(binDir)) { + process.stdout.write(`note: ${binDir} is not on your PATH. Add to your shell rc:\n`) + process.stdout.write(` export PATH="${binDir}:$PATH"\n`) +} diff --git a/ocm-cli/src/keychain.ts b/ocm-cli/src/keychain.ts new file mode 100644 index 00000000..50e4de20 --- /dev/null +++ b/ocm-cli/src/keychain.ts @@ -0,0 +1,51 @@ +import { spawnSync } from 'child_process' + +const SERVICE = 'opencode-manager' + +export class KeychainError extends Error { + constructor(message: string, public exitCode: number | null) { + super(message) + this.name = 'KeychainError' + } +} + +function runSecurity(args: string[], input?: string): { stdout: string; stderr: string; code: number | null } { + const res = spawnSync('security', args, { + input, + encoding: 'utf-8', + }) + return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', code: res.status } +} + +export function setToken(account: string, token: string): void { + const res = runSecurity([ + 'add-generic-password', + '-s', SERVICE, + '-a', account, + '-w', token, + '-U', + ]) + if (res.code !== 0) { + throw new KeychainError(`Failed to store token in Keychain: ${res.stderr.trim()}`, res.code) + } +} + +export function getToken(account: string): string | null { + const res = runSecurity([ + 'find-generic-password', + '-s', SERVICE, + '-a', account, + '-w', + ]) + if (res.code !== 0) return null + return res.stdout.trim() || null +} + +export function deleteToken(account: string): boolean { + const res = runSecurity([ + 'delete-generic-password', + '-s', SERVICE, + '-a', account, + ]) + return res.code === 0 +} diff --git a/ocm-cli/src/local-repo.ts b/ocm-cli/src/local-repo.ts new file mode 100644 index 00000000..22f660fd --- /dev/null +++ b/ocm-cli/src/local-repo.ts @@ -0,0 +1,45 @@ +import { spawnSync } from 'child_process' + +function git(cwd: string, args: string[]): string | null { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8' }) + if (res.status !== 0) return null + return (res.stdout ?? '').trim() +} + +export function getRepoRoot(cwd: string): string | null { + return git(cwd, ['rev-parse', '--show-toplevel']) +} + +export function getOriginUrl(dir: string): string | null { + return git(dir, ['remote', 'get-url', 'origin']) +} + +export function getDirtyPaths(dir: string): Set { + const out = git(dir, ['status', '--porcelain', '-z', '--untracked-files=all']) + if (!out) return new Set() + const paths = new Set() + for (const record of out.split('\0')) { + if (!record) continue + const path = record.slice(3) + if (path) paths.add(path) + } + return paths +} + +function normalizeUrl(url: string): string { + return url + .trim() + .replace(/\.git$/, '') + .replace(/^git@([^:]+):/, 'ssh://git@$1/') + .replace(/\/+$/, '') + .toLowerCase() +} + +export function getBranchName(dir: string): string | null { + return git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']) +} + +export function urlsEqual(a: string | null | undefined, b: string | null | undefined): boolean { + if (!a || !b) return false + return normalizeUrl(a) === normalizeUrl(b) +} diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts new file mode 100644 index 00000000..ecfcd22f --- /dev/null +++ b/ocm-cli/src/manager-api.ts @@ -0,0 +1,50 @@ +export class ManagerApi { + constructor( + private baseUrl: string, + private token: string, + ) {} + + private headers(extra: Record = {}): Record { + return { Authorization: `Bearer ${this.token}`, ...extra } + } + + async mirrorUp( + repoId: number, + body: ReadableStream, + opts: { force?: boolean; create?: { name: string; originUrl: string | null; branch: string | null } }, + ): Promise<{ repoId: number; branch: string; head: string; created: boolean }> { + const params = new URLSearchParams() + if (opts.force) params.set('force', '1') + if (opts.create) { + params.set('create', '1') + params.set('name', opts.create.name) + if (opts.create.originUrl) params.set('originUrl', opts.create.originUrl) + if (opts.create.branch) params.set('branch', opts.create.branch) + } + + const qs = params.toString() ? `?${params.toString()}` : '' + const url = `${this.baseUrl}/api/internal/repos/${repoId}/mirror${qs}` + + const res = await fetch(url, { + method: 'POST', + headers: { + ...this.headers(), + 'Content-Type': 'application/x-tar', + }, + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + + if (!res.ok) throw new Error(`mirror ${res.status}: ${await res.text()}`) + return (await res.json()) as { repoId: number; branch: string; head: string; created: boolean } + } + + async mirrorDown(repoId: number): Promise> { + const res = await fetch(`${this.baseUrl}/api/internal/repos/${repoId}/mirror`, { + headers: this.headers(), + }) + + if (!res.ok) throw new Error(`mirror ${res.status}: ${await res.text()}`) + return res.body! + } +} diff --git a/ocm-cli/src/mirror.ts b/ocm-cli/src/mirror.ts new file mode 100644 index 00000000..6240273b --- /dev/null +++ b/ocm-cli/src/mirror.ts @@ -0,0 +1,179 @@ +import { spawnSync, spawn } from 'child_process' +import { existsSync } from 'fs' +import * as fsp from 'fs/promises' +import { Readable } from 'stream' +import { join } from 'path' +import { tmpdir } from 'os' +import { getRepoRoot, getOriginUrl, getDirtyPaths, urlsEqual } from './local-repo.js' +import type { ManagerApi } from './manager-api.js' + +const HARDCODED_EXCLUDES = ['node_modules', 'dist', '.next', '.venv', '__pycache__', '.turbo'] + +function getGitignoreExclusions(repoRoot: string): string[] { + const res = spawnSync('git', ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'], { + cwd: repoRoot, + encoding: 'utf-8', + }) + if (res.status !== 0) return [] + return (res.stdout ?? '').split('\n').filter((line) => line.length > 0) +} + +export class MirrorAbort extends Error { + constructor(message: string) { + super(message) + this.name = 'MirrorAbort' + } +} + +export interface RemoteRepoSummary { + repoId: number + name: string + originUrl: string | null + branch: string | null +} + +export interface MirrorPlan { + repoRoot: string + localOrigin: string + matched: RemoteRepoSummary[] +} + +export function prepareMirror(cwd: string, remotes: RemoteRepoSummary[]): MirrorPlan { + const repoRoot = getRepoRoot(cwd) + if (!repoRoot) throw new MirrorAbort('not in a git repository') + + const localOrigin = getOriginUrl(repoRoot) + if (!localOrigin) throw new MirrorAbort('no origin URL found') + + const matched = remotes.filter((r) => urlsEqual(localOrigin, r.originUrl)) + + return { repoRoot, localOrigin, matched } +} + +export interface MirrorUpOpts { + api: ManagerApi + force: boolean + create?: { name: string; originUrl: string | null; branch: string | null } +} + +export async function mirrorUp( + plan: MirrorPlan, + opts: MirrorUpOpts, +): Promise<{ repoId: number; branch: string; head: string; created: boolean }> { + const tarArgs = ['-c', '-C', plan.repoRoot] + for (const dir of HARDCODED_EXCLUDES) tarArgs.push('--exclude', dir) + + const ignoredPaths = getGitignoreExclusions(plan.repoRoot) + let excludeFile: string | null = null + + if (ignoredPaths.length > 0) { + excludeFile = join(tmpdir(), `ocm-exclude-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`) + await fsp.writeFile(excludeFile, ignoredPaths.join('\n')) + tarArgs.push('--exclude-from', excludeFile) + } + + tarArgs.push('.') + + const child = spawn('tar', tarArgs, { stdio: ['pipe', 'pipe', 'pipe'] }) + + const stderrChunks: Buffer[] = [] + child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)) + + const tarExit = new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve() + else { + const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim() + reject(new Error(`tar exited with code ${code}${stderr ? `: ${stderr}` : ''}`)) + } + }) + child.on('error', reject) + }) + + const body = Readable.toWeb(child.stdout) as ReadableStream + const repoId = opts.create ? 0 : plan.matched[0]!.repoId + + try { + const [result] = await Promise.all([ + opts.api.mirrorUp(repoId, body, { + force: opts.force, + create: opts.create, + }), + tarExit, + ]) + return result + } finally { + if (excludeFile) { + await fsp.rm(excludeFile, { force: true }).catch(() => {}) + } + } +} + +export async function mirrorDown( + repoId: number, + repoRoot: string, + api: ManagerApi, + opts: { force: boolean } = { force: false }, +): Promise { + if (!opts.force && getDirtyPaths(repoRoot).size > 0) { + throw new MirrorAbort('working tree has uncommitted changes; rerun with --force') + } + + const staging = `${repoRoot}.ocm-recv-${Date.now()}` + await fsp.mkdir(staging, { recursive: true }) + + try { + const tarball = await api.mirrorDown(repoId) + + const child = spawn('tar', ['-x', '-f', '-', '-C', staging], { stdio: ['pipe', 'pipe', 'pipe'] }) + + const stderrChunks: Buffer[] = [] + child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)) + + const tarDone = new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve() + else { + const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim() + reject(new Error(`tar exited with code ${code}${stderr ? `: ${stderr}` : ''}`)) + } + }) + child.on('error', reject) + }) + + const stdinWritable = Readable.fromWeb(tarball as unknown as Parameters[0]) + stdinWritable.pipe(child.stdin) + + await tarDone + + const backupDir = `${repoRoot}.ocm-backup-${Date.now()}` + await fsp.mkdir(backupDir, { recursive: true }) + + if (existsSync(repoRoot)) { + const entries = await fsp.readdir(repoRoot) + for (const entry of entries) { + await fsp.rename(join(repoRoot, entry), join(backupDir, entry)) + } + } + + try { + const stagingEntries = await fsp.readdir(staging) + for (const entry of stagingEntries) { + await fsp.rename(join(staging, entry), join(repoRoot, entry)) + } + + await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {}) + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) + } catch (swapError) { + const backupEntries = await fsp.readdir(backupDir).catch(() => []) + for (const entry of backupEntries) { + await fsp.rename(join(backupDir, entry), join(repoRoot, entry)).catch(() => {}) + } + await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {}) + throw swapError + } + } catch (error) { + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) + throw error + } +} diff --git a/ocm-cli/src/plugin.ts b/ocm-cli/src/plugin.ts new file mode 100644 index 00000000..4c1d450c --- /dev/null +++ b/ocm-cli/src/plugin.ts @@ -0,0 +1,59 @@ +import { mkdirSync, symlinkSync, lstatSync, readlinkSync, unlinkSync, existsSync, chmodSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const OCM_TARGET = join(PACKAGE_ROOT, 'dist', 'ocm.js') +const BIN_DIR = join(homedir(), '.local', 'bin') +const BIN_LINK = join(BIN_DIR, 'ocm') + +function ensureSymlink(): { installed: boolean; message?: string } { + if (!existsSync(OCM_TARGET)) { + return { installed: false, message: `ocm-cli: missing ${OCM_TARGET}, skipping bin install` } + } + + try { + chmodSync(OCM_TARGET, 0o755) + } catch { + // best effort + } + + try { + mkdirSync(BIN_DIR, { recursive: true }) + } catch (err) { + return { installed: false, message: `ocm-cli: cannot create ${BIN_DIR}: ${(err as Error).message}` } + } + + try { + const stat = lstatSync(BIN_LINK) + if (stat.isSymbolicLink()) { + if (readlinkSync(BIN_LINK) === OCM_TARGET) { + return { installed: false } + } + unlinkSync(BIN_LINK) + } else { + return { installed: false, message: `ocm-cli: ${BIN_LINK} exists and is not a symlink; leaving alone` } + } + } catch { + // missing — fine + } + + try { + symlinkSync(OCM_TARGET, BIN_LINK) + } catch (err) { + return { installed: false, message: `ocm-cli: failed to symlink ${BIN_LINK}: ${(err as Error).message}` } + } + + const onPath = (process.env.PATH ?? '').split(':').includes(BIN_DIR) + const pathHint = onPath ? '' : ` (add "${BIN_DIR}" to your PATH to run \`ocm\`)` + return { installed: true, message: `ocm-cli: installed \`ocm\` at ${BIN_LINK}${pathHint}` } +} + +const result = ensureSymlink() +if (result.message) { + process.stderr.write(`${result.message}\n`) +} + +const plugin = async (): Promise> => ({}) +export default plugin diff --git a/ocm-cli/src/resolve-target.ts b/ocm-cli/src/resolve-target.ts new file mode 100644 index 00000000..c4fc9710 --- /dev/null +++ b/ocm-cli/src/resolve-target.ts @@ -0,0 +1,51 @@ +import { getRepoRoot, getOriginUrl, urlsEqual } from './local-repo.js' + +export interface TargetRepo { + repoId: number + name: string + branch: string | null + directory: string + originUrl?: string | null +} + +export type ResolveResult = + | { kind: 'cwd-match'; repo: TargetRepo; repoRoot: string } + | { kind: 'last'; repo: TargetRepo } + | { kind: 'cwd-ambiguous'; matches: TargetRepo[]; localOrigin: string; repoRoot: string } + | { kind: 'local'; reason: 'no-match' | 'no-target'; repoRoot: string | null } + +export interface ResolveInput { + cwd: string + repos: TargetRepo[] + last?: { repoId: number; name: string; directory: string; branch: string | null } +} + +export function resolveTarget(input: ResolveInput): ResolveResult { + const repoRoot = getRepoRoot(input.cwd) + const localOrigin = repoRoot ? getOriginUrl(repoRoot) : null + + if (repoRoot && localOrigin) { + const matches = input.repos.filter((r) => urlsEqual(localOrigin, r.originUrl)) + if (matches.length === 1) { + return { kind: 'cwd-match', repo: matches[0]!, repoRoot } + } + if (matches.length > 1) { + return { kind: 'cwd-ambiguous', matches, localOrigin, repoRoot } + } + return { kind: 'local', reason: 'no-match', repoRoot } + } + + if (input.last) { + return { kind: 'last', repo: toTarget(input.last) } + } + return { kind: 'local', reason: 'no-target', repoRoot } +} + +function toTarget(last: NonNullable): TargetRepo { + return { + repoId: last.repoId, + name: last.name, + branch: last.branch, + directory: last.directory, + } +} diff --git a/ocm-cli/src/state.ts b/ocm-cli/src/state.ts new file mode 100644 index 00000000..6eeb3995 --- /dev/null +++ b/ocm-cli/src/state.ts @@ -0,0 +1,43 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' + +export interface OcmState { + managerUrl: string + lastRepoId?: number + lastRepoName?: string + lastRepoDir?: string + lastRepoBranch?: string | null + updatedAt?: number +} + +const STATE_DIR = join(homedir(), '.config', 'opencode-manager') +const STATE_FILE = join(STATE_DIR, 'state.json') + +export function getStatePath(): string { + return STATE_FILE +} + +export function readState(): OcmState | null { + if (!existsSync(STATE_FILE)) return null + try { + const raw = readFileSync(STATE_FILE, 'utf-8') + const parsed = JSON.parse(raw) as OcmState + if (!parsed.managerUrl) return null + return parsed + } catch { + return null + } +} + +export function writeState(state: OcmState): void { + mkdirSync(dirname(STATE_FILE), { recursive: true }) + const next: OcmState = { ...state, updatedAt: Date.now() } + writeFileSync(STATE_FILE, JSON.stringify(next, null, 2), { mode: 0o600 }) +} + +export function clearState(): void { + if (existsSync(STATE_FILE)) { + writeFileSync(STATE_FILE, '{}', { mode: 0o600 }) + } +} diff --git a/ocm-cli/test/mirror.test.ts b/ocm-cli/test/mirror.test.ts new file mode 100644 index 00000000..6bd43303 --- /dev/null +++ b/ocm-cli/test/mirror.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { spawnSync, execSync } from 'child_process' +import { prepareMirror, MirrorAbort, mirrorDown, mirrorUp } from '../src/mirror' + +describe('prepareMirror', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('rejects when not in a git repo', () => { + const nonGitDir = join(tmpDir, 'non-git') + mkdirSync(nonGitDir) + + expect(() => prepareMirror(nonGitDir, [])).toThrow(MirrorAbort) + expect(() => prepareMirror(nonGitDir, [])).toThrow('not in a git repository') + }) + + it('rejects when no origin URL found', () => { + const gitDir = join(tmpDir, 'git-no-origin') + mkdirSync(gitDir) + spawnSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' }) + + expect(() => prepareMirror(gitDir, [])).toThrow(MirrorAbort) + expect(() => prepareMirror(gitDir, [])).toThrow('no origin URL found') + }) + + it('returns empty matched array when no remote matches', () => { + const gitDir = join(tmpDir, 'git-mismatch') + mkdirSync(gitDir) + spawnSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' }) + spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/other/repo.git'], { cwd: gitDir, stdio: 'ignore' }) + + const remotes = [ + { repoId: 1, name: 'my-repo', originUrl: 'https://github.com/me/repo.git', branch: 'main' }, + ] + + const plan = prepareMirror(gitDir, remotes) + expect(plan.matched).toHaveLength(0) + expect(plan.localOrigin).toContain('other/repo') + }) + + it('returns matching repos when origin matches', () => { + const gitDir = join(tmpDir, 'git-match') + mkdirSync(gitDir) + spawnSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' }) + spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/me/repo.git'], { cwd: gitDir, stdio: 'ignore' }) + + const remotes = [ + { repoId: 1, name: 'my-repo', originUrl: 'https://github.com/me/repo.git', branch: 'main' }, + { repoId: 2, name: 'other-repo', originUrl: 'https://github.com/other/repo.git', branch: 'main' }, + ] + + const plan = prepareMirror(gitDir, remotes) + expect(plan.matched).toHaveLength(1) + expect(plan.matched[0]!.repoId).toBe(1) + expect(plan.localOrigin).toContain('me/repo') + }) +}) + +describe('cmdPush', () => { + let originalArgv: string[] + let originalIsTTY: boolean | undefined + + beforeEach(() => { + originalArgv = process.argv.slice() + originalIsTTY = process.stdin.isTTY + vi.restoreAllMocks() + }) + + afterEach(() => { + process.argv = originalArgv + if (originalIsTTY !== undefined) process.stdin.isTTY = originalIsTTY + }) + + it('errors with "stdin is not a TTY" when --create requested non-interactively and --yes omitted', async () => { + process.stdin.isTTY = false + process.argv = ['node', 'ocm', 'push', '--create'] + + let stderrOutput = '' + vi.spyOn(process.stderr, 'write').mockImplementation((msg: string | Uint8Array) => { + stderrOutput += typeof msg === 'string' ? msg : new TextDecoder().decode(msg) + return true + }) + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new Error(stderrOutput.trim()) + }) + + const mockState = { managerUrl: 'http://localhost:5003' } + vi.doMock('../src/state.js', () => ({ + readState: () => mockState, + writeState: () => {}, + clearState: () => {}, + getStatePath: () => '/tmp/state.json', + })) + vi.doMock('../src/keychain.js', () => ({ + getToken: () => 'test-token', + setToken: () => {}, + deleteToken: () => true, + KeychainError: class extends Error {}, + })) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ workspaces: [] }), + })) + + const { cmdPush } = await import('../bin/ocm') + + await expect(cmdPush(['--create'])).rejects.toThrow('stdin is not a TTY; pass --yes to confirm creation') + }) +}) + +describe('mirrorDown', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-down-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function createTarball(dir: string): Buffer { + const tarFile = join(tmpDir, 'test.tar') + execSync(`tar -cf "${tarFile}" -C "${dir}" .`) + return require('fs').readFileSync(tarFile) + } + + it('stages tarball in sibling directory next to repoRoot', async () => { + const repoRoot = join(tmpDir, 'repo') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + + const contentDir = join(tmpDir, 'content') + mkdirSync(contentDir) + writeFileSync(join(contentDir, 'file.txt'), 'hello') + + const tarData = createTarball(contentDir) + + const mockApi = { + mirrorDown: vi.fn().mockResolvedValue( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(tarData)) + controller.close() + }, + }) + ), + } as any + + await mirrorDown(1, repoRoot, mockApi, { force: true }) + + expect(existsSync(join(repoRoot, 'file.txt'))).toBe(true) + + const entries = readdirSync(tmpDir).filter((e) => e.startsWith('repo.ocm-recv-')) + expect(entries.length).toBe(0) + }) + + it('restores original repo when swap fails after creating backup', async () => { + const repoRoot = join(tmpDir, 'repo-swap-fail') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'file.txt'), 'original content') + + const contentDir = join(tmpDir, 'content-fail') + mkdirSync(contentDir) + writeFileSync(join(contentDir, 'new-file.txt'), 'new content') + + const tarData = createTarball(contentDir) + + const mockApi = { + mirrorDown: vi.fn().mockResolvedValue( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(tarData)) + controller.close() + }, + }) + ), + } as any + + try { + await mirrorDown(1, repoRoot, mockApi, { force: true }) + } catch { + const entries = readdirSync(tmpDir).filter((e) => e.startsWith('repo-swap-fail.ocm-backup-')) + expect(entries.length).toBe(0) + + expect(existsSync(repoRoot)).toBe(true) + expect(existsSync(join(repoRoot, 'file.txt'))).toBe(true) + } + + const entriesAfterSuccess = readdirSync(tmpDir).filter((e) => e.startsWith('repo-swap-fail.ocm-backup-')) + expect(entriesAfterSuccess.length).toBe(0) + }) + + it('throws MirrorAbort when working tree has uncommitted changes and force is false', async () => { + const repoRoot = join(tmpDir, 'repo-dirty') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'dirty.txt'), 'dirty') + spawnSync('git', ['add', '.'], { cwd: repoRoot, stdio: 'ignore' }) + spawnSync('git', ['commit', '-m', 'initial'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'dirty.txt'), 'dirty-modified') + + const mockApi = { + mirrorDown: vi.fn(), + } as any + + await expect(mirrorDown(1, repoRoot, mockApi, { force: false })).rejects.toThrow(MirrorAbort) + await expect(mirrorDown(1, repoRoot, mockApi, { force: false })).rejects.toThrow('working tree has uncommitted changes; rerun with --force') + }) + + it('preserves directory inode so relative paths work after pull from inside repo', async () => { + const repoRoot = join(tmpDir, 'repo-inode') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'original.txt'), 'original content') + + const contentDir = join(tmpDir, 'content-inode') + mkdirSync(contentDir) + writeFileSync(join(contentDir, 'new-file.txt'), 'new content') + writeFileSync(join(contentDir, 'updated.txt'), 'updated content') + + const tarData = createTarball(contentDir) + + const mockApi = { + mirrorDown: vi.fn().mockResolvedValue( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(tarData)) + controller.close() + }, + }) + ), + } as any + + const originalCwd = process.cwd() + process.chdir(repoRoot) + + try { + await mirrorDown(1, repoRoot, mockApi, { force: true }) + + expect(existsSync(join(repoRoot, 'new-file.txt'))).toBe(true) + expect(existsSync(join(repoRoot, 'updated.txt'))).toBe(true) + expect(readFileSync(join(repoRoot, 'new-file.txt'), 'utf-8')).toBe('new content') + expect(readFileSync(join(repoRoot, 'updated.txt'), 'utf-8')).toBe('updated content') + + expect(existsSync(join(repoRoot, 'original.txt'))).toBe(false) + } finally { + process.chdir(originalCwd) + } + }) + + it('replaces contents while keeping directory inode intact', async () => { + const repoRoot = join(tmpDir, 'repo-replace') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + writeFileSync(join(repoRoot, 'old-file.txt'), 'old content') + + const contentDir = join(tmpDir, 'content-replace') + mkdirSync(contentDir) + writeFileSync(join(contentDir, 'new-file.txt'), 'new content') + + const tarData = createTarball(contentDir) + + const mockApi = { + mirrorDown: vi.fn().mockResolvedValue( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(tarData)) + controller.close() + }, + }) + ), + } as any + + await mirrorDown(1, repoRoot, mockApi, { force: true }) + + expect(existsSync(join(repoRoot, 'new-file.txt'))).toBe(true) + expect(existsSync(join(repoRoot, 'old-file.txt'))).toBe(false) + + const entries = readdirSync(tmpDir).filter((e) => e.startsWith('repo-replace.ocm-backup-')) + expect(entries.length).toBe(0) + + const stagingEntries = readdirSync(tmpDir).filter((e) => e.startsWith('repo-replace.ocm-recv-')) + expect(stagingEntries.length).toBe(0) + }) +}) + +describe('mirrorUp with gitignore', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mirror-up-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('excludes gitignored files from tar stream', async () => { + const repoRoot = join(tmpDir, 'repo-up-gitignore') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + + writeFileSync(join(repoRoot, 'tracked.txt'), 'tracked content') + writeFileSync(join(repoRoot, 'secrets.env'), 'SECRET_KEY=abc123') + writeFileSync(join(repoRoot, '.gitignore'), 'secrets.env\n') + + const mockApi = { + mirrorUp: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head: 'abc123', created: false }), + } as any + + const plan = { + repoRoot, + localOrigin: 'https://github.com/test/repo.git', + matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + } + + await mirrorUp(plan, { api: mockApi, force: false }) + + expect(mockApi.mirrorUp).toHaveBeenCalledTimes(1) + expect(mockApi.mirrorUp.mock.calls[0][0]).toBe(1) + + const opts = mockApi.mirrorUp.mock.calls[0][2] + expect(opts.force).toBe(false) + }) + + it('excludes node_modules from tar stream', async () => { + const repoRoot = join(tmpDir, 'repo-up-node-modules') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + + writeFileSync(join(repoRoot, 'file.txt'), 'content') + mkdirSync(join(repoRoot, 'node_modules')) + writeFileSync(join(repoRoot, 'node_modules', 'dep.js'), 'dep content') + + const mockApi = { + mirrorUp: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head: 'abc123', created: false }), + } as any + + const plan = { + repoRoot, + localOrigin: 'https://github.com/test/repo.git', + matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + } + + await mirrorUp(plan, { api: mockApi, force: false }) + + expect(mockApi.mirrorUp).toHaveBeenCalledTimes(1) + expect(mockApi.mirrorUp.mock.calls[0][0]).toBe(1) + expect(mockApi.mirrorUp.mock.calls[0][2].force).toBe(false) + }) + + it('does not create exclude file when no gitignored paths exist', async () => { + const repoRoot = join(tmpDir, 'repo-up-no-ignore') + mkdirSync(repoRoot) + spawnSync('git', ['init'], { cwd: repoRoot, stdio: 'ignore' }) + + writeFileSync(join(repoRoot, 'file.txt'), 'content') + + const mockApi = { + mirrorUp: vi.fn().mockResolvedValue({ repoId: 1, branch: 'main', head: 'abc123', created: false }), + } as any + + const plan = { + repoRoot, + localOrigin: 'https://github.com/test/repo.git', + matched: [{ repoId: 1, name: 'test-repo', originUrl: 'https://github.com/test/repo.git', branch: 'main' }], + } + + await mirrorUp(plan, { api: mockApi, force: false }) + + expect(mockApi.mirrorUp).toHaveBeenCalledTimes(1) + expect(mockApi.mirrorUp.mock.calls[0][0]).toBe(1) + const opts = mockApi.mirrorUp.mock.calls[0][2] + expect(opts.force).toBe(false) + }) +}) diff --git a/ocm-cli/test/resolve-target.test.ts b/ocm-cli/test/resolve-target.test.ts new file mode 100644 index 00000000..f04caa4f --- /dev/null +++ b/ocm-cli/test/resolve-target.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { spawnSync } from 'child_process' +import { resolveTarget, type TargetRepo } from '../src/resolve-target' + +const LAST = { + repoId: 99, + name: 'last-repo', + directory: '/manager/last', + branch: 'main', +} + +function gitInit(dir: string, originUrl?: string): void { + mkdirSync(dir, { recursive: true }) + spawnSync('git', ['init'], { cwd: dir, stdio: 'ignore' }) + if (originUrl) { + spawnSync('git', ['remote', 'add', 'origin', originUrl], { cwd: dir, stdio: 'ignore' }) + } +} + +describe('resolveTarget', () => { + let tmp: string + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'resolve-target-')) + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + }) + + const repo = (id: number, originUrl: string, name = `repo-${id}`): TargetRepo => ({ + repoId: id, + name, + branch: 'main', + directory: `/manager/${name}`, + originUrl, + }) + + it('returns cwd-match when $PWD origin matches exactly one Manager repo', () => { + const dir = join(tmp, 'work') + gitInit(dir, 'https://github.com/me/repo.git') + + const result = resolveTarget({ + cwd: dir, + repos: [repo(1, 'https://github.com/me/repo.git', 'my-repo'), repo(2, 'https://github.com/other/repo.git')], + last: LAST, + }) + + expect(result.kind).toBe('cwd-match') + if (result.kind === 'cwd-match') { + expect(result.repo.repoId).toBe(1) + expect(result.repo.name).toBe('my-repo') + } + }) + + it('returns cwd-ambiguous when multiple Manager repos match', () => { + const dir = join(tmp, 'work') + gitInit(dir, 'https://github.com/me/repo.git') + + const result = resolveTarget({ + cwd: dir, + repos: [ + repo(1, 'https://github.com/me/repo.git', 'a'), + repo(2, 'https://github.com/me/repo.git', 'b'), + ], + last: LAST, + }) + + expect(result.kind).toBe('cwd-ambiguous') + if (result.kind === 'cwd-ambiguous') { + expect(result.matches).toHaveLength(2) + } + }) + + it('returns local(no-match) when in a git repo with no origin match (even if last is set)', () => { + const dir = join(tmp, 'work') + gitInit(dir, 'https://github.com/me/repo.git') + + const result = resolveTarget({ + cwd: dir, + repos: [repo(1, 'https://github.com/other/repo.git')], + last: LAST, + }) + + expect(result.kind).toBe('local') + if (result.kind === 'local') { + expect(result.reason).toBe('no-match') + expect(result.repoRoot).toContain('work') + } + }) + + it('returns last when not in a git repo and last is set', () => { + const dir = join(tmp, 'not-git') + mkdirSync(dir) + + const result = resolveTarget({ + cwd: dir, + repos: [repo(1, 'https://github.com/me/repo.git')], + last: LAST, + }) + + expect(result.kind).toBe('last') + if (result.kind === 'last') { + expect(result.repo.repoId).toBe(LAST.repoId) + } + }) + + it('returns local(no-target) when not in a git repo and no last', () => { + const dir = join(tmp, 'not-git') + mkdirSync(dir) + + const result = resolveTarget({ + cwd: dir, + repos: [repo(1, 'https://github.com/me/repo.git')], + }) + + expect(result.kind).toBe('local') + if (result.kind === 'local') { + expect(result.reason).toBe('no-target') + expect(result.repoRoot).toBeNull() + } + }) + + it('normalises .git suffix when matching origin', () => { + const dir = join(tmp, 'work') + gitInit(dir, 'https://github.com/me/repo') + + const result = resolveTarget({ + cwd: dir, + repos: [repo(1, 'https://github.com/me/repo.git', 'my-repo')], + }) + + expect(result.kind).toBe('cwd-match') + }) +}) diff --git a/ocm-cli/tsconfig.json b/ocm-cli/tsconfig.json new file mode 100644 index 00000000..36d5230d --- /dev/null +++ b/ocm-cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*", "bin/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/ocm-cli/vitest.config.ts b/ocm-cli/vitest.config.ts new file mode 100644 index 00000000..d9be7c84 --- /dev/null +++ b/ocm-cli/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}) diff --git a/package.json b/package.json index 9c1fddff..4d243ff7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "lint:backend": "pnpm --filter backend lint", "lint:fix": "pnpm run lint:frontend -- --fix && pnpm run lint:backend -- --fix", "generate:openapi": "bun scripts/generate-openapi.ts", + "test:cli": "pnpm --filter @opencode-manager/ocm-cli test", + "typecheck:cli": "pnpm --filter @opencode-manager/ocm-cli typecheck", "docker:build": "docker-compose build", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down -v", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dba0c015..4895b1a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.4.17 - version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) + version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) '@hono/node-server': specifier: ^1.19.5 version: 1.19.7(hono@4.11.7) @@ -38,7 +38,7 @@ importers: version: 7.0.1 better-auth: specifier: ^1.4.17 - version: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + version: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4) croner: specifier: ^10.0.1 version: 10.0.1 @@ -99,13 +99,13 @@ importers: version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) frontend: dependencies: '@better-auth/passkey': specifier: ^1.4.17 - version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) + version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -153,13 +153,13 @@ importers: version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': specifier: ^4.1.14 - version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)) + version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.90.5 version: 5.90.16(react@19.2.3) better-auth: specifier: ^1.4.17 - version: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + version: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -250,7 +250,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.0.4 - version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)) + version: 5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0)) autoprefixer: specifier: ^10.4.21 version: 10.4.23(postcss@8.5.6) @@ -286,10 +286,22 @@ importers: version: 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.7 - version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) + + ocm-cli: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.19)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) shared: dependencies: @@ -1710,6 +1722,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} @@ -3638,6 +3653,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -3670,6 +3695,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + solid-js@1.9.13: + resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -3899,6 +3927,9 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4139,6 +4170,11 @@ packages: yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4349,14 +4385,14 @@ snapshots: nanostores: 1.1.0 zod: 4.3.5 - '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0)': + '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0)': dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@simplewebauthn/browser': 13.2.2 '@simplewebauthn/server': 13.2.2 - better-auth: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + better-auth: 1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4) better-call: 1.1.8(zod@4.3.2) nanostores: 1.1.0 zod: 4.3.5 @@ -5356,12 +5392,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))': + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) '@tanstack/query-core@5.90.16': {} @@ -5591,6 +5627,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.4': dependencies: undici-types: 7.16.0 @@ -5711,7 +5751,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))': + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -5719,7 +5759,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -5738,7 +5778,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -5750,13 +5790,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))': + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) + vite: 7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) + + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5787,7 +5835,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -5909,7 +5957,7 @@ snapshots: baseline-browser-mapping@2.9.11: {} - better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4): + better-auth@1.4.17(better-sqlite3@12.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.13)(vitest@3.2.4): dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) @@ -5927,7 +5975,8 @@ snapshots: better-sqlite3: 12.9.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) + solid-js: 1.9.13 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) better-call@1.1.8(zod@4.3.2): dependencies: @@ -7873,6 +7922,14 @@ snapshots: semver@7.7.3: {} + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + optional: true + + seroval@1.5.4: + optional: true + set-cookie-parser@2.7.2: {} shebang-command@2.0.0: @@ -7901,6 +7958,13 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + solid-js@1.9.13: + dependencies: + csstype: 3.2.3 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + optional: true + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -8129,6 +8193,8 @@ snapshots: ufo@1.6.1: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} unified@11.0.5: @@ -8213,13 +8279,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0): + vite-node@3.2.4(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) + vite: 7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -8234,7 +8300,44 @@ snapshots: - tsx - yaml - vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0): + vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.46.0 + yaml: 2.9.0 + + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -8248,12 +8351,13 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 terser: 5.46.0 + yaml: 2.9.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.19)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)) + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8271,8 +8375,52 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) - vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0) + vite: 7.3.0(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.19)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.19.19 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -8372,6 +8520,9 @@ snapshots: yaml-ast-parser@0.0.43: {} + yaml@2.9.0: + optional: true + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b576f275..f71b7606 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,5 @@ packages: - 'shared' - 'backend' - 'frontend' + - 'ocm-cli' - '!workspace/**' diff --git a/shared/package.json b/shared/package.json index eedb7dcf..e9e1db49 100644 --- a/shared/package.json +++ b/shared/package.json @@ -20,6 +20,9 @@ "jsonc-parser": "^3.3.1", "zod": "^4.1.12" }, + "scripts": { + "typecheck": "tsc --noEmit" + }, "optionalDependencies": { "dotenv": "^17.2.3" }, diff --git a/shared/src/schemas/opencode-target.ts b/shared/src/schemas/opencode-target.ts new file mode 100644 index 00000000..11553422 --- /dev/null +++ b/shared/src/schemas/opencode-target.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' + +export const OpenCodeTargetStateSchema = z.enum([ + 'missing', + 'starting', + 'healthy', + 'unhealthy', + 'failed', + 'stopped', +]) + +export const OpenCodeTargetSchema = z.object({ + repoId: z.number(), + state: OpenCodeTargetStateSchema, + openCodeUrl: z.string().optional(), + token: z.string().optional(), + startedAt: z.number().optional(), + lastUsedAt: z.number().optional(), + lastError: z.string().optional(), + reused: z.boolean(), +}) + +export const EnsureOpenCodeTargetRequestSchema = z.object({ + workspaceId: z.string().optional(), + clientId: z.string().optional(), +}) + +export const EnsureOpenCodeTargetResponseSchema = z.object({ + repoId: z.number(), + state: OpenCodeTargetStateSchema, + openCodeUrl: z.string(), + headers: z.record(z.string(), z.string()), + reused: z.boolean(), +}) + +export const SyncRepoSessionRequestSchema = z.object({ + sessionId: z.string(), + reason: z.enum(['idle', 'completed', 'stop', 'manual']), +}) + +export const SyncRepoSessionResponseSchema = z.object({ + repoId: z.number(), + sessionId: z.string(), + replayedEvents: z.number(), +}) diff --git a/shared/src/types/opencode-target.ts b/shared/src/types/opencode-target.ts new file mode 100644 index 00000000..14bfc272 --- /dev/null +++ b/shared/src/types/opencode-target.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { + OpenCodeTargetStateSchema, + OpenCodeTargetSchema, + EnsureOpenCodeTargetRequestSchema, + EnsureOpenCodeTargetResponseSchema, + SyncRepoSessionRequestSchema, + SyncRepoSessionResponseSchema, +} from '../schemas/opencode-target' + +export type OpenCodeTargetState = z.infer +export type OpenCodeTarget = z.infer +export type EnsureOpenCodeTargetRequest = z.infer +export type EnsureOpenCodeTargetResponse = z.infer +export type SyncRepoSessionRequest = z.infer +export type SyncRepoSessionResponse = z.infer