mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
212 lines
7.0 KiB
TypeScript
212 lines
7.0 KiB
TypeScript
// Private build only: the `cloud` target for `orca worktree create --on <target>`.
|
|
// orca worktree create --on cloud [--name <n>] [--repo <url-or-path>] [--base-branch <ref>]
|
|
// [--size standard|large] [--agent claude|codex --prompt <text>] [--json]
|
|
// --on cloud:<project> uses an Orca Cloud project's repo and environment defaults.
|
|
// It creates an Orca Cloud workspace through the public API with the same credentials as
|
|
// `orca cloud …`, so it works on a desktop and inside a VM (no Orca runtime involved).
|
|
import { execFileSync } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
import { isAbsolute, resolve } from 'node:path'
|
|
import type { HandlerContext } from '../dispatch'
|
|
import { getOptionalStringFlag } from '../flags'
|
|
import { RuntimeClientError } from '../runtime/types'
|
|
import { formatCloudWorkspace, printCloudResult } from './cloud-format'
|
|
import {
|
|
CLOUD_AGENTS,
|
|
cloudClient,
|
|
createCloudWorkspace,
|
|
defaultCloudHandlerDeps,
|
|
getSizeFlag,
|
|
normalizeCloudRepoUrl,
|
|
resolveCloudProjectId,
|
|
type CloudHandlerDeps,
|
|
type CloudWorkspaceCreateInput
|
|
} from './cloud-operations'
|
|
|
|
export const CLOUD_ON_TARGET = 'cloud'
|
|
|
|
/** Flags `worktree create` accepts that mean something for a cloud workspace. */
|
|
const SUPPORTED = new Set([
|
|
'on',
|
|
'name',
|
|
'repo',
|
|
'base-branch',
|
|
'size',
|
|
'agent',
|
|
'prompt',
|
|
'progress',
|
|
'json',
|
|
'help'
|
|
])
|
|
|
|
export type CloudOnDeps = CloudHandlerDeps & {
|
|
/** `git remote get-url origin` for a checkout, or null. */
|
|
originUrl: (dir: string) => string | null
|
|
}
|
|
|
|
function gitOriginUrl(dir: string): string | null {
|
|
try {
|
|
const out = execFileSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
timeout: 5000
|
|
}).trim()
|
|
return out || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const defaultCloudOnDeps = (): CloudOnDeps => ({
|
|
...defaultCloudHandlerDeps(),
|
|
originUrl: gitOriginUrl
|
|
})
|
|
|
|
/** `--on cloud` or `--on cloud:<project>`; anything else belongs to the generic --on parser. */
|
|
export function readCloudOnTarget(
|
|
flags: Map<string, string | boolean>
|
|
): { project: string | null } | null {
|
|
const raw = flags.get('on')
|
|
if (typeof raw !== 'string') {
|
|
return null
|
|
}
|
|
const value = raw.trim()
|
|
if (value === CLOUD_ON_TARGET) {
|
|
return { project: null }
|
|
}
|
|
if (value.startsWith(`${CLOUD_ON_TARGET}:`)) {
|
|
const project = value.slice(CLOUD_ON_TARGET.length + 1).trim()
|
|
if (!project) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
'--on cloud:<project> needs a project id or name.',
|
|
{
|
|
field: 'on'
|
|
}
|
|
)
|
|
}
|
|
return { project }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function looksLikeGitUrl(value: string): boolean {
|
|
return /^(https?|ssh|git):\/\//.test(value) || /^[\w.-]+@[\w.-]+:/.test(value)
|
|
}
|
|
|
|
/** --repo is a git URL or a local checkout (its origin is used); without it, the cwd checkout. */
|
|
export function resolveCloudRepo(
|
|
flags: Map<string, string | boolean>,
|
|
cwd: string,
|
|
originUrl: CloudOnDeps['originUrl']
|
|
): { url: string } | null {
|
|
const repo = getOptionalStringFlag(flags, 'repo')
|
|
if (repo && looksLikeGitUrl(repo)) {
|
|
return { url: normalizeCloudRepoUrl(repo) }
|
|
}
|
|
if (repo) {
|
|
const dir = isAbsolute(repo) ? repo : resolve(cwd, repo)
|
|
const origin = existsSync(dir) ? originUrl(dir) : null
|
|
if (!origin) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`With --on cloud, --repo is a git URL or a local checkout with an origin remote; "${repo}" is neither.`,
|
|
{ field: 'repo' }
|
|
)
|
|
}
|
|
return { url: normalizeCloudRepoUrl(origin) }
|
|
}
|
|
const origin = originUrl(cwd)
|
|
return origin ? { url: normalizeCloudRepoUrl(origin) } : null
|
|
}
|
|
|
|
export async function createWorktreeOnCloud(
|
|
context: Pick<HandlerContext, 'flags' | 'cwd' | 'json'>,
|
|
getDeps: () => CloudOnDeps = defaultCloudOnDeps
|
|
): Promise<void> {
|
|
const { flags, cwd, json } = context
|
|
const target = readCloudOnTarget(flags)
|
|
if (!target) {
|
|
throw new RuntimeClientError('invalid_argument', '--on cloud expected.', { field: 'on' })
|
|
}
|
|
const unsupported = [...flags.keys()].filter((name) => !SUPPORTED.has(name))
|
|
if (unsupported.length > 0) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --size, --agent/--prompt.`,
|
|
{ field: unsupported[0] }
|
|
)
|
|
}
|
|
const agent = getOptionalStringFlag(flags, 'agent')
|
|
const prompt = getOptionalStringFlag(flags, 'prompt')
|
|
if (agent !== undefined && !(CLOUD_AGENTS as readonly string[]).includes(agent)) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
`--on cloud runs ${CLOUD_AGENTS.join(' or ')}; --agent ${agent} is not available there yet.`,
|
|
{ field: 'agent' }
|
|
)
|
|
}
|
|
if (prompt && !agent) {
|
|
throw new RuntimeClientError('invalid_argument', '--prompt requires --agent', {
|
|
field: 'agent'
|
|
})
|
|
}
|
|
const progress = flags.get('progress') ?? (json ? 'none' : 'text')
|
|
if (progress !== 'text' && progress !== 'json' && progress !== 'none') {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
'--progress must be one of: text, json, none',
|
|
{
|
|
field: 'progress'
|
|
}
|
|
)
|
|
}
|
|
const deps = getDeps()
|
|
const machineSize = getSizeFlag(flags)
|
|
const ref = getOptionalStringFlag(flags, 'base-branch')
|
|
const repo = target.project ? null : resolveCloudRepo(flags, cwd, deps.originUrl)
|
|
if (ref && !repo && !target.project) {
|
|
throw new RuntimeClientError(
|
|
'invalid_argument',
|
|
'--base-branch needs a repo (--repo or a checkout as cwd).',
|
|
{
|
|
field: 'base-branch'
|
|
}
|
|
)
|
|
}
|
|
const api = cloudClient(deps)
|
|
const name = getOptionalStringFlag(flags, 'name')
|
|
const input: CloudWorkspaceCreateInput = {
|
|
...(name ? { name } : {}),
|
|
...(target.project ? { projectId: await resolveCloudProjectId(api, target.project) } : {}),
|
|
...(repo ? { repo: { url: repo.url, ...(ref ? { ref } : {}) } } : {}),
|
|
...(machineSize ? { machineSize } : {}),
|
|
...(prompt && agent
|
|
? { message: { text: prompt, agent: agent as (typeof CLOUD_AGENTS)[number] } }
|
|
: {})
|
|
}
|
|
const phase = (name: string, text: string) => {
|
|
if (progress === 'json') {
|
|
process.stderr.write(`${JSON.stringify({ event: 'phase', phase: name, at: deps.now() })}\n`)
|
|
} else if (progress === 'text') {
|
|
console.error(`- ${text}`)
|
|
}
|
|
}
|
|
phase('create', `Creating an Orca Cloud workspace${repo ? ` from ${repo.url}` : ''}...`)
|
|
const created = await createCloudWorkspace(api, input, deps.now)
|
|
phase('ready', `Workspace ${created.workspace.id} is ${created.workspace.status}.`)
|
|
printCloudResult(
|
|
{
|
|
...created,
|
|
host: {
|
|
id: created.workspace.id,
|
|
origin: { target: target.project ? `cloud:${target.project}` : CLOUD_ON_TARGET },
|
|
lifecycle: 'per-workspace',
|
|
state: created.workspace.status
|
|
}
|
|
},
|
|
json,
|
|
(value) => formatCloudWorkspace(value, deps.now())
|
|
)
|
|
}
|