Add project host setup CLI commands

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:58 -07:00
co-authored by Orca
parent a6e31235de
commit 0a4a4ddbd1
11 changed files with 410 additions and 42 deletions
+2
View File
@@ -77,6 +77,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
if (
[
'automations',
'project',
'repo',
'worktree',
'terminal',
@@ -105,6 +106,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
(commandPath.length === 1 &&
[
'automations',
'project',
'repo',
'worktree',
'terminal',
+2
View File
@@ -2,6 +2,7 @@ import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { CORE_HANDLERS } from './handlers/core'
import { AUTOMATION_HANDLERS } from './handlers/automations'
import { PROJECT_HANDLERS } from './handlers/project'
import { REPO_HANDLERS } from './handlers/repo'
import { WORKTREE_HANDLERS } from './handlers/worktree'
import { FILE_HANDLERS } from './handlers/file'
@@ -36,6 +37,7 @@ function buildHandlers(): Map<string, CommandHandler> {
const groups = [
CORE_HANDLERS,
AUTOMATION_HANDLERS,
PROJECT_HANDLERS,
REPO_HANDLERS,
WORKTREE_HANDLERS,
FILE_HANDLERS,
+5
View File
@@ -22,6 +22,11 @@ export {
formatListWindows
} from './computer-format'
export type { ComputerActionFollowUpTarget } from './computer-format'
export {
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectList
} from './project-format'
export {
formatTerminalClose,
formatTerminalCreate,
+61
View File
@@ -0,0 +1,61 @@
import type {
Project,
ProjectHostSetup,
ProjectHostSetupExistingFolderArgs,
ProjectHostSetupResult,
RepoKind
} from '../../shared/types'
import type { CommandHandler } from '../dispatch'
import {
formatProjectHostSetupList,
formatProjectHostSetupResult,
formatProjectList,
printResult
} from '../format'
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
import { resolveRepoPathArgument } from '../repo-path-arguments'
import { RuntimeClientError } from '../runtime-client'
function getOptionalRepoKind(flags: Map<string, string | boolean>): RepoKind | undefined {
const kind = getOptionalStringFlag(flags, 'kind')
if (kind === undefined) {
return undefined
}
if (kind === 'git' || kind === 'folder') {
return kind
}
throw new RuntimeClientError('invalid_argument', '--kind must be git or folder')
}
export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
'project list': async ({ client, json }) => {
const result = await client.call<{ projects: Project[] }>('project.list')
printResult(result, json, formatProjectList)
},
'project setups': async ({ flags, client, json }) => {
const projectFilter = getOptionalStringFlag(flags, 'project')
const hostFilter = getOptionalStringFlag(flags, 'host')
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
const setups = result.result.setups.filter(
(setup) =>
(projectFilter === undefined || setup.projectId === projectFilter) &&
(hostFilter === undefined || setup.hostId === hostFilter)
)
printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList)
},
'project setup-existing-folder': async ({ flags, client, cwd, json }) => {
const rawPath = getRequiredStringFlag(flags, 'path')
const args: ProjectHostSetupExistingFolderArgs = {
projectId: getRequiredStringFlag(flags, 'project'),
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'],
path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'),
kind: getOptionalRepoKind(flags),
displayName: getOptionalStringFlag(flags, 'display-name')
}
const result = await client.call<{ result: ProjectHostSetupResult }>(
'projectHostSetup.setupExistingFolder',
args
)
printResult(result, json, formatProjectHostSetupResult)
}
}
+2 -27
View File
@@ -1,33 +1,8 @@
import { resolve as resolvePath } from 'path'
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
import { RuntimeClientError } from '../runtime-client'
function isAbsoluteServerPath(value: string): boolean {
return (
value.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith('\\\\') ||
value.startsWith('//')
)
}
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
if (!isRemote) {
return resolvePath(cwd, inputPath)
}
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
// Relative remote paths would silently target the wrong machine.
if (!isAbsoluteServerPath(inputPath)) {
throw new RuntimeClientError(
'invalid_argument',
'Remote repo add requires --path to be an absolute path on the remote server.'
)
}
return inputPath
}
import { resolveRepoPathArgument } from '../repo-path-arguments'
export const REPO_HANDLERS: Record<string, CommandHandler> = {
'repo list': async ({ client, json }) => {
@@ -37,7 +12,7 @@ export const REPO_HANDLERS: Record<string, CommandHandler> = {
'repo add': async ({ flags, client, cwd, json }) => {
const repoPath = getRequiredStringFlag(flags, 'path')
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
path: resolveRepoPathArgument(repoPath, cwd, client.isRemote, 'Remote repo add')
})
printResult(result, json, formatRepoShow)
},
+8
View File
@@ -29,6 +29,11 @@ Automations:
automations run Run an Orca automation now
automations runs List automation run history
Projects:
project list List durable projects known to Orca
project setups List project host setups
project setup-existing-folder Make a project available on a host by importing an existing folder
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
@@ -201,6 +206,9 @@ Common Commands:
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
orca terminal switch [--terminal <handle>] [--json]
orca terminal close [--terminal <handle>] [--json]
orca project list [--json]
orca project setups [--project <id>] [--host <host-id>] [--json]
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
+220 -15
View File
@@ -141,6 +141,9 @@ describe('orca root help', () => {
expect(logSpy.mock.calls[0][0]).toContain(
'computer press-key Press a single key such as Return or Escape'
)
expect(logSpy.mock.calls[0][0]).toContain(
'project setup-existing-folder Make a project available on a host by importing an existing folder'
)
expect(callMock).not.toHaveBeenCalled()
})
})
@@ -247,7 +250,9 @@ describe('orca cli worktree awareness', () => {
await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
@@ -996,6 +1001,174 @@ describe('orca cli worktree awareness', () => {
})
})
it('lists projects through the project-first runtime API', async () => {
queueFixtures(
callMock,
okFixture('req_project_list', {
projects: [
{
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
providerIdentity: {
provider: 'github',
owner: 'stablyai',
repo: 'orca'
},
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
}
]
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['project', 'list', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('project.list')
})
it('filters project host setups locally after fetching setup compatibility state', async () => {
queueFixtures(
callMock,
okFixture('req_project_setups', {
setups: [
{
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-local',
path: '/tmp/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-remote',
projectId: 'github:stablyai/orca',
hostId: 'runtime:gpu',
repoId: 'repo-remote',
path: '/srv/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
]
})
)
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['project', 'setups', '--project', 'github:stablyai/orca', '--host', 'runtime:gpu'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('projectHostSetup.list')
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote')
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local')
})
it('sets up an existing project folder with a path resolved against the local cli cwd', async () => {
queueFixtures(
callMock,
okFixture('req_project_setup', {
result: {
project: {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#7c3aed',
sourceRepoIds: ['repo-1'],
createdAt: 1,
updatedAt: 1
},
setup: {
id: 'setup-local',
projectId: 'github:stablyai/orca',
hostId: 'local',
repoId: 'repo-1',
path: path.resolve('/tmp/orca'),
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
},
repo: {
id: 'repo-1',
path: path.resolve('/tmp/orca'),
displayName: 'Orca',
badgeColor: '#7c3aed',
addedAt: 1
}
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
[
'project',
'setup-existing-folder',
'--project',
'github:stablyai/orca',
'--host',
'local',
'--path',
'..',
'--kind',
'git',
'--display-name',
'Orca',
'--json'
],
'/tmp/orca/worktrees/feature'
)
expect(callMock).toHaveBeenCalledWith('projectHostSetup.setupExistingFolder', {
projectId: 'github:stablyai/orca',
hostId: 'local',
path: path.resolve('/tmp/orca/worktrees'),
kind: 'git',
displayName: 'Orca'
})
})
it('rejects remote project setup relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
[
'project',
'setup-existing-folder',
'--project',
'github:stablyai/orca',
'--host',
'runtime:gpu',
'--path',
'./orca',
'--pairing-code',
'remote-runtime',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
'Remote project setup requires --path to be an absolute path on the remote server.'
)
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -2118,7 +2291,9 @@ describe('orca cli worktree awareness', () => {
await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', {
worktree: undefined
})
})
it('passes emulator gesture points through to the runtime', async () => {
@@ -2254,7 +2429,9 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
@@ -2442,8 +2619,12 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_create', { automation: { id: 'auto-1', name: 'Daily review' } }),
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
okFixture('req_create', {
automation: { id: 'auto-1', name: 'Daily review' }
}),
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -2468,7 +2649,9 @@ describe('orca cli worktree awareness', () => {
)
await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(
2,
'automation.create',
@@ -2532,8 +2715,16 @@ describe('orca cli worktree awareness', () => {
})
it.each([
{ flag: 'enabled', value: 'false', message: '--enabled does not take a value' },
{ flag: 'disabled', value: 'false', message: '--disabled does not take a value' }
{
flag: 'enabled',
value: 'false',
message: '--enabled does not take a value'
},
{
flag: 'disabled',
value: 'false',
message: '--disabled does not take a value'
}
])('rejects automation create --$flag with a string value', async ({ flag, value, message }) => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -2571,7 +2762,9 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_automation_create', { automation: { id: 'auto-1', name: 'Daily review' } })
okFixture('req_automation_create', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -2594,7 +2787,9 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.create', {
name: 'Daily review',
prompt: 'Review open changes',
@@ -2615,7 +2810,9 @@ describe('orca cli worktree awareness', () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Daily review' } })
okFixture('req_edit', {
automation: { id: 'auto-1', name: 'Daily review' }
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -2624,7 +2821,9 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo/feature/src'
)
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
limit: 10_000
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.update', {
id: 'auto-1',
updates: {
@@ -2692,9 +2891,15 @@ describe('orca cli worktree awareness', () => {
missedRunGraceMinutes: undefined
}
})
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', { id: 'auto-1' })
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', {
id: 'auto-1'
})
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', {
id: 'auto-1'
})
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', {
id: 'auto-1'
})
})
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
+41
View File
@@ -0,0 +1,41 @@
import type { Project, ProjectHostSetup, ProjectHostSetupResult } from '../shared/types'
export function formatProjectList(result: { projects: Project[] }): string {
if (result.projects.length === 0) {
return 'No projects found.'
}
return result.projects
.map((project) => {
const identity = project.providerIdentity
? `${project.providerIdentity.provider}:${project.providerIdentity.owner}/${project.providerIdentity.repo}`
: 'no-provider'
return `${project.id} ${project.displayName} ${identity}`
})
.join('\n')
}
export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] }): string {
if (result.setups.length === 0) {
return 'No project host setups found.'
}
return result.setups
.map(
(setup) =>
`${setup.id} project:${setup.projectId} host:${setup.hostId} ${setup.setupState} ${setup.path}`
)
.join('\n')
}
export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string {
const { project, setup, repo } = result.result
return [
`projectId: ${project.id}`,
`project: ${project.displayName}`,
`setupId: ${setup.id}`,
`hostId: ${setup.hostId}`,
`path: ${setup.path}`,
`state: ${setup.setupState}`,
`method: ${setup.setupMethod}`,
`repoId: ${repo.id}`
].join('\n')
}
+31
View File
@@ -0,0 +1,31 @@
import { resolve as resolvePath } from 'path'
import { RuntimeClientError } from './runtime-client'
function isAbsoluteServerPath(value: string): boolean {
return (
value.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(value) ||
value.startsWith('\\\\') ||
value.startsWith('//')
)
}
export function resolveRepoPathArgument(
inputPath: string,
cwd: string,
isRemote: boolean,
remotePathSubject = 'Remote repo path'
): string {
if (!isRemote) {
return resolvePath(cwd, inputPath)
}
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
// Relative remote paths would silently target the wrong machine.
if (!isAbsoluteServerPath(inputPath)) {
throw new RuntimeClientError(
'invalid_argument',
`${remotePathSubject} requires --path to be an absolute path on the remote server.`
)
}
return inputPath
}
+2
View File
@@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { AUTOMATION_COMMAND_SPECS } from './automations'
import { CORE_COMMAND_SPECS } from './core'
import { FILE_COMMAND_SPECS } from './file'
import { PROJECT_COMMAND_SPECS } from './project'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
@@ -13,6 +14,7 @@ import { EMULATOR_COMMAND_SPECS } from './emulator'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...PROJECT_COMMAND_SPECS,
...FILE_COMMAND_SPECS,
...AUTOMATION_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
+36
View File
@@ -0,0 +1,36 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
{
path: ['project', 'list'],
summary: 'List durable projects known to Orca',
usage: 'orca project list [--json]',
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca project list', 'orca project list --json']
},
{
path: ['project', 'setups'],
summary: 'List project host setups',
usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'],
notes: ['A setup means a project is available on a host at a concrete filesystem path.'],
examples: [
'orca project setups',
'orca project setups --project github:stablyai/orca',
'orca project setups --host local'
]
},
{
path: ['project', 'setup-existing-folder'],
summary: 'Make a project available on a host by importing an existing folder',
usage:
'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'],
notes: ['For remote runtimes, --path must be an absolute path on the remote server.'],
examples: [
'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca',
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json'
]
}
]