diff --git a/backend/src/index.ts b/backend/src/index.ts index 428542eaf..86f1f47cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -325,7 +325,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) 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, openCodeClient, sessionPermissionModeService, repoWorkspaces)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 64aa096d8..346b9414e 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -19,6 +19,7 @@ import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' import type { SessionPermissionModeService } from '../../services/session-permission-modes' import type { RepoWorkspaceService } from '../../services/repo-workspace' +import type { GitAuthService } from '../../services/git-auth' export function createInternalRoutes( db: Database, @@ -28,6 +29,7 @@ export function createInternalRoutes( openCodeClient: OpenCodeClient, permissionModes: SessionPermissionModeService, repoWorkspaces: RepoWorkspaceService, + gitAuthService: GitAuthService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) @@ -36,7 +38,7 @@ export function createInternalRoutes( app.route('/settings', createInternalSettingsRoutes(settingsService)) app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true })) const repos = new Hono() - repos.route('/', createInternalRepoRoutes(db, settingsService)) + repos.route('/', createInternalRepoRoutes(db, settingsService, gitAuthService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) repos.route('/', createInternalRepoSyncRoutes(db)) repos.route('/', mirrorRoutes(db)) diff --git a/backend/src/routes/internal/repos.ts b/backend/src/routes/internal/repos.ts index ed0de0b49..a8d820994 100644 --- a/backend/src/routes/internal/repos.ts +++ b/backend/src/routes/internal/repos.ts @@ -1,11 +1,15 @@ import { Hono } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Database } from 'bun:sqlite' +import { InternalCloneRepoRequestSchema } from '@opencode-manager/shared/schemas' import type { SettingsService } from '../../services/settings' +import type { GitAuthService } from '../../services/git-auth' import { listRepos } from '../../db/queries' +import { cloneRepo } from '../../services/repo' import { logger } from '../../utils/logger' -import { getErrorMessage } from '../../utils/error-utils' +import { getErrorMessage, getStatusCode } from '../../utils/error-utils' -export function createInternalRepoRoutes(db: Database, settingsService: SettingsService) { +export function createInternalRepoRoutes(db: Database, settingsService: SettingsService, gitAuthService: GitAuthService) { const app = new Hono() app.get('/', (c) => { @@ -19,5 +23,20 @@ export function createInternalRepoRoutes(db: Database, settingsService: Settings } }) + app.post('/', async (c) => { + const parsed = InternalCloneRepoRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400) + } + try { + const { repoUrl, branch, directoryName } = parsed.data + const repo = await cloneRepo(db, gitAuthService, repoUrl, { branch, directoryName }) + return c.json(repo) + } catch (error) { + logger.error('Failed to clone internal repo:', error) + return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode) + } + }) + return app -} \ No newline at end of file +} diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index af0a7d40c..c01dcbdb2 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -831,12 +831,12 @@ Only changes to how the OpenCode process is launched need a user restart from Se export function buildReposSkill(): string { return `--- name: repo-management -description: List repos available to OpenCode Manager with the ${MANAGER_TOOL_NAME} tool +description: List and clone repos in OpenCode Manager with the ${MANAGER_TOOL_NAME} tool --- ## When to Load -Load this skill when you need to discover repos, look up repo IDs, or need to reference repo information before managing schedules. Load it before the schedule-management skill if you don't know the repo ID. +Load this skill when you need to discover repos, look up repo IDs, add a repo by cloning a git URL, or need to reference repo information before managing schedules. Load it before the schedule-management skill if you don't know the repo ID. ## Tool @@ -892,11 +892,40 @@ List all repos available to OpenCode Manager. The repos are returned in the orde } \`\`\` +### POST /repos + +Clone a git repository into the repos root and register it with OpenCode Manager. Accepts HTTPS and SSH URLs. If the same URL and branch is already registered, the existing repo is returned instead of cloning again. + +**Body:** +\`\`\`ts +{ + repoUrl: string // e.g. https://github.com/owner/name + branch?: string // branch to check out; created locally if the remote has none + directoryName?: string // directory under the repos root; defaults to the repo name +} +\`\`\` + +**Example:** +\`\`\`json +{ + "action": "request", + "params": { + "method": "POST", + "path": "/repos", + "body": { "repoUrl": "https://github.com/owner/name" } + } +} +\`\`\` + +**Response:** the repo object (same shape as an entry in \`GET /repos\`) with \`cloneStatus: 'ready'\` when the clone has finished, or \`'cloning'\` when an identical clone is still running. When it is \`'cloning'\`, poll \`GET /repos\` until it becomes \`'ready'\` before using the repo. + ## Notes - Use \`id\` as \`:repoId\` in other API endpoints (e.g., \`/repos/:repoId/schedules\`) - \`fullPath\` is the absolute local path - use it for file operations -- This endpoint is read-only - there are no POST/PUT/DELETE operations for repos +- Only clone a repo the user asked for; there is no delete or update operation for repos through this tool +- Large clones can outlast the tool request timeout while the clone keeps running; if the request times out, poll \`GET /repos\` until the repo shows \`cloneStatus: 'ready'\` +- A \`409\` means the target directory already holds a different repository; retry with a different \`directoryName\` - \`currentBranch\` is not included in the response - it requires git operations to determine - Repo order is controlled by the \`repoOrder\` preference in settings ` diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index 1f32c01b9..786357de9 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -14,6 +14,7 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'PATCH /opencode-config', 'POST /assistant/reload', 'GET /repos', + 'POST /repos', 'GET /repos/*/git-info', 'GET /opencode-workspaces', 'GET /sessions', @@ -111,7 +112,7 @@ function buildManagerToolDescription(): string { 'The action runs inside OpenCode Manager itself, so it needs no token and no network access from the agent shell, and it works in sandboxed sessions and scheduled runs.', 'Actions:', '- send_notification: send a push notification to every device the user has registered.', - '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos, OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.', + '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos (list, inspect, clone from a git URL with POST /repos and body { repoUrl, branch?, directoryName? }), OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.', 'Allowed request routes:', ] .concat(MANAGER_TOOL_ALLOWED_ROUTES.map((route) => `- ${route}`)) diff --git a/backend/test/routes/internal-assistant.test.ts b/backend/test/routes/internal-assistant.test.ts index 1de4b65fc..ea4bc953a 100644 --- a/backend/test/routes/internal-assistant.test.ts +++ b/backend/test/routes/internal-assistant.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -45,7 +46,7 @@ describe('internal/assistant routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts index 0f7cdf975..36b90f237 100644 --- a/backend/test/routes/internal-notifications.test.ts +++ b/backend/test/routes/internal-notifications.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -31,7 +32,7 @@ describe('internal/notifications routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index 4467ac03d..01170e5b5 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { readFile, writeFile } from 'fs/promises' @@ -61,7 +62,7 @@ describe('internal/opencode-config routes', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index 21b5201d7..d302cd97a 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -84,7 +85,7 @@ describe('internal-opencode-workspaces routes', () => { forwardRaw: vi.fn(), } as unknown as OpenCodeClient app = new Hono() - app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-repos.test.ts b/backend/test/routes/internal-repos.test.ts index 338945b7a..4dc3ec0c9 100644 --- a/backend/test/routes/internal-repos.test.ts +++ b/backend/test/routes/internal-repos.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -32,7 +33,7 @@ describe('internal-repos routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) }) @@ -109,6 +110,46 @@ describe('internal-repos routes', () => { expect(body.repos[1]?.id).toBe(repo1.id) }) + it('POST /api/internal/repos returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/repos', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ repoUrl: 'https://github.com/owner/name' }), + }) + expect(res.status).toBe(401) + }) + + it('POST /api/internal/repos rejects a missing repoUrl, unknown keys, and invalid JSON', async () => { + const bodies = [JSON.stringify({}), JSON.stringify({ repoUrl: 'https://github.com/owner/name', localPath: '/etc' }), 'not json'] + for (const body of bodies) { + const res = await app.request('/api/internal/repos', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body, + }) + expect(res.status).toBe(400) + } + }) + + it('POST /api/internal/repos returns the already registered repo for the same URL', async () => { + const existing = createRepo(db, { + repoUrl: 'https://github.com/owner/name', + localPath: 'name', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + const res = await app.request('/api/internal/repos', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ repoUrl: 'https://github.com/owner/name.git' }), + }) + expect(res.status).toBe(200) + const body = await res.json() as { id: number } + expect(body.id).toBe(existing.id) + }) + it('GET /api/internal/repos/:id/schedules still works after adding repos route', async () => { const repoInput: CreateRepoInput = { localPath: 'test-repo', diff --git a/backend/test/routes/internal-sandbox.test.ts b/backend/test/routes/internal-sandbox.test.ts index 4a0d5bf33..b30f8fe83 100644 --- a/backend/test/routes/internal-sandbox.test.ts +++ b/backend/test/routes/internal-sandbox.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { mkdirSync, rmSync } from 'node:fs' @@ -95,7 +96,7 @@ describe('internal sandbox routes', () => { const notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) repoDir = path.join(getReposPath(), 'sandbox-route-test') mkdirSync(repoDir, { recursive: true }) diff --git a/backend/test/routes/internal-schedules.test.ts b/backend/test/routes/internal-schedules.test.ts index 1e15ce2bf..b28c2def7 100644 --- a/backend/test/routes/internal-schedules.test.ts +++ b/backend/test/routes/internal-schedules.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -30,7 +31,7 @@ describe('internal-schedules routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sessions.test.ts b/backend/test/routes/internal-sessions.test.ts index 98580c4c5..374912716 100644 --- a/backend/test/routes/internal-sessions.test.ts +++ b/backend/test/routes/internal-sessions.test.ts @@ -8,6 +8,7 @@ import type { SettingsService } from '../../src/services/settings' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import type { Repo } from '../../src/types/repo' const mockDb = { @@ -157,7 +158,7 @@ describe('internal-sessions routes', () => { app = new Hono() app.route( '/api/internal', - createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService), + createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService), ) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index acf8d2cbf..8316b0541 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -31,7 +32,7 @@ describe('internal/settings routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index fa630bac5..0e7ace3ea 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' import path from 'path' import { access, readFile, writeFile } from 'fs/promises' import { Hono } from 'hono' @@ -310,7 +311,7 @@ describe('ensureAssistantMode', () => { const reposSkillContent = await readFile(reposSkillPath, 'utf8') expect(reposSkillContent).toContain('name: repo-management') - expect(reposSkillContent).toContain('List repos available') + expect(reposSkillContent).toContain('List and clone repos') const sessionsSkillContent = await readFile(sessionsSkillPath, 'utf8') expect(sessionsSkillContent).toContain('name: session-management') @@ -687,7 +688,7 @@ describe('assistant-mode end-to-end', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) const app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) const unauth = await app.request('/api/internal/schedules/all') expect(unauth.status).toBe(401) diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index 505e8f64a..836ba3cf9 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -24,6 +24,12 @@ export const InternalRepoListResponseSchema = z.object({ repos: z.array(RepoSchema), }) +export const InternalCloneRepoRequestSchema = z.object({ + repoUrl: z.string().trim().min(1), + branch: z.string().trim().min(1).optional(), + directoryName: z.string().trim().min(1).optional(), +}).strict() + export const CreateRepoRequestSchema = z.object({ repoUrl: z.string().url().optional(), localPath: z.string().optional(),