mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
import chalk from 'chalk'
|
|
import { existsSync, statSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { isAbsolute, resolve } from 'node:path'
|
|
import { startRunner } from '@/runner/run'
|
|
import {
|
|
checkIfRunnerRunningAndCleanupStaleState,
|
|
listRunnerSessions,
|
|
stopRunner,
|
|
stopRunnerSession
|
|
} from '@/runner/controlClient'
|
|
import { getLatestRunnerLog } from '@/ui/logger'
|
|
import { spawnHappyCLI } from '@/utils/spawnHappyCLI'
|
|
import { runDoctorCommand } from '@/ui/doctor'
|
|
import { initializeToken } from '@/ui/tokenInit'
|
|
import type { CommandDefinition } from './types'
|
|
|
|
/**
|
|
* Parses repeated `--workspace-root <path>` / `--workspace-root=<path>` from
|
|
* the runner's positional args. Returns resolved absolute paths or exits
|
|
* the process with a clear error. Mutates `args` to remove the consumed
|
|
* entries so subcommand dispatch still works.
|
|
*/
|
|
function extractWorkspaceRootArgs(args: string[]): string[] | undefined {
|
|
const workspaceRoots: string[] = []
|
|
|
|
for (let i = 0; i < args.length;) {
|
|
const arg = args[i]
|
|
let value: string | undefined
|
|
let consumed = 0
|
|
if (arg === '--workspace-root') {
|
|
const next = args[i + 1]
|
|
if (next === undefined || next.startsWith('--')) {
|
|
console.error('--workspace-root requires a path argument')
|
|
process.exit(1)
|
|
}
|
|
value = next
|
|
consumed = 2
|
|
} else if (arg?.startsWith('--workspace-root=')) {
|
|
value = arg.slice('--workspace-root='.length)
|
|
consumed = 1
|
|
}
|
|
if (value === undefined) {
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
const trimmed = value.trim()
|
|
if (!trimmed) {
|
|
console.error('--workspace-root requires a non-empty path')
|
|
process.exit(1)
|
|
}
|
|
// Handle `~` / `~/foo` since the shell only expands unquoted tildes.
|
|
let expanded = trimmed
|
|
if (expanded === '~') {
|
|
expanded = homedir()
|
|
} else if (expanded.startsWith('~/')) {
|
|
expanded = resolve(homedir(), expanded.slice(2))
|
|
}
|
|
const absolute = isAbsolute(expanded) ? expanded : resolve(expanded)
|
|
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
|
|
console.error(`--workspace-root path does not exist or is not a directory: ${absolute}`)
|
|
process.exit(1)
|
|
}
|
|
workspaceRoots.push(absolute)
|
|
args.splice(i, consumed)
|
|
}
|
|
|
|
const uniqueWorkspaceRoots = Array.from(new Set(workspaceRoots))
|
|
return uniqueWorkspaceRoots.length > 0 ? uniqueWorkspaceRoots : undefined
|
|
}
|
|
|
|
async function waitForRunnerToStop(maxAttempts = 50): Promise<boolean> {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
if (!(await checkIfRunnerRunningAndCleanupStaleState())) {
|
|
return true
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export const runnerCommand: CommandDefinition = {
|
|
name: 'runner',
|
|
requiresRuntimeAssets: true,
|
|
run: async ({ commandArgs }) => {
|
|
const mutableArgs = [...commandArgs]
|
|
const workspaceRoots = extractWorkspaceRootArgs(mutableArgs)
|
|
const runnerSubcommand = mutableArgs[0]
|
|
|
|
if (runnerSubcommand === 'list') {
|
|
try {
|
|
const sessions = await listRunnerSessions()
|
|
|
|
if (sessions.length === 0) {
|
|
console.log('No active sessions this runner is aware of (they might have been started by a previous version of the runner)')
|
|
} else {
|
|
console.log('Active sessions:')
|
|
console.log(JSON.stringify(sessions, null, 2))
|
|
}
|
|
} catch {
|
|
console.log('No runner running')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (runnerSubcommand === 'stop-session') {
|
|
const sessionId = mutableArgs[1]
|
|
if (!sessionId) {
|
|
console.error('Session ID required')
|
|
process.exit(1)
|
|
}
|
|
|
|
try {
|
|
const success = await stopRunnerSession(sessionId)
|
|
console.log(success ? 'Session stopped' : 'Failed to stop session')
|
|
} catch {
|
|
console.log('No runner running')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (runnerSubcommand === 'start') {
|
|
if (await checkIfRunnerRunningAndCleanupStaleState()) {
|
|
console.log('Existing runner detected, stopping it before starting a new one...')
|
|
await stopRunner()
|
|
|
|
if (!(await waitForRunnerToStop())) {
|
|
console.error('Failed to stop existing runner')
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
const childArgs = ['runner', 'start-sync']
|
|
if (workspaceRoots?.length) {
|
|
for (const workspaceRoot of workspaceRoots) {
|
|
childArgs.push('--workspace-root', workspaceRoot)
|
|
}
|
|
}
|
|
const child = spawnHappyCLI(childArgs, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env: process.env
|
|
})
|
|
child.unref()
|
|
|
|
let started = false
|
|
for (let i = 0; i < 50; i++) {
|
|
if (await checkIfRunnerRunningAndCleanupStaleState()) {
|
|
started = true
|
|
break
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
}
|
|
|
|
if (started) {
|
|
console.log('Runner started successfully')
|
|
} else {
|
|
console.error('Failed to start runner')
|
|
process.exit(1)
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
if (runnerSubcommand === 'start-sync') {
|
|
await initializeToken()
|
|
await startRunner({ workspaceRoots })
|
|
process.exit(0)
|
|
}
|
|
|
|
if (runnerSubcommand === 'stop') {
|
|
await stopRunner()
|
|
process.exit(0)
|
|
}
|
|
|
|
if (runnerSubcommand === 'status') {
|
|
await runDoctorCommand('runner')
|
|
process.exit(0)
|
|
}
|
|
|
|
if (runnerSubcommand === 'logs') {
|
|
const latest = await getLatestRunnerLog()
|
|
if (!latest) {
|
|
console.log('No runner logs found')
|
|
} else {
|
|
console.log(latest.path)
|
|
}
|
|
process.exit(0)
|
|
}
|
|
|
|
console.log(`
|
|
${chalk.bold('hapi runner')} - Runner management
|
|
|
|
${chalk.bold('Usage:')}
|
|
hapi runner start Start the runner (replaces existing runner)
|
|
hapi runner stop Stop the runner (sessions stay alive)
|
|
hapi runner status Show runner status
|
|
hapi runner list List active sessions
|
|
|
|
${chalk.bold('Options:')}
|
|
--workspace-root <path> Restrict the runner to this directory.
|
|
Repeat to allow multiple directories/drives.
|
|
Browse & spawn reject paths outside them.
|
|
Supports \`~\` / \`~/foo\` expansion.
|
|
Omit to leave browsing off (legacy mode).
|
|
|
|
If you want to kill all hapi related processes run
|
|
${chalk.cyan('hapi doctor clean')}
|
|
|
|
${chalk.bold('Note:')} The runner runs in the background and manages Claude sessions.
|
|
Running ${chalk.cyan('hapi runner start')} stops any existing runner first so new flags and environment variables take effect.
|
|
|
|
${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor clean')}
|
|
`)
|
|
}
|
|
}
|