From 5570048578e5cd0e45f37b419c79c00a9fb5488f Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 14:38:13 +0200 Subject: [PATCH 01/22] feat(hydra): scaffold CLI with init command and config types --- bun.lock | 3 + package.json | 2 + packages/hydra/package.json | 1 + packages/hydra/src/base-command.ts | 9 ++ packages/hydra/src/cli.ts | 28 ++++- packages/hydra/src/commands/index.ts | 1 + packages/hydra/src/commands/init.ts | 168 +++++++++++++++++++++++++++ packages/hydra/src/constants.ts | 12 ++ packages/hydra/src/types.ts | 32 +++++ 9 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 packages/hydra/src/base-command.ts create mode 100644 packages/hydra/src/commands/index.ts create mode 100644 packages/hydra/src/commands/init.ts create mode 100644 packages/hydra/src/constants.ts create mode 100644 packages/hydra/src/types.ts diff --git a/bun.lock b/bun.lock index e5d36e4..2c08917 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,9 @@ "bin": { "hydra": "./dist/cli.js", }, + "dependencies": { + "@r5n/cli-core": "workspace:*", + }, "devDependencies": { "@clack/prompts": "1.1.0", "@r5n/tools": "workspace:*", diff --git a/package.json b/package.json index a7cd58d..d4e2ef3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "release:version": "bun sis version", "sis": "bun --bun packages/sisyphus/src/cli.ts", "sis:dbg": "bun --inspect-brk packages/sisyphus/src/cli.ts", + "hydra": "bun --bun packages/hydra/src/cli.ts", + "hydra:dbg": "bun --inspect-brk packages/hydra/src/cli.ts", "test": "bun test", "type-check": "bun --elide-lines=0 --filter '*' type-check", "type-check:ci": "bun --filter '*' type-check", diff --git a/packages/hydra/package.json b/packages/hydra/package.json index a21e4e5..437bb12 100644 --- a/packages/hydra/package.json +++ b/packages/hydra/package.json @@ -2,6 +2,7 @@ "author": "@ice-chillios", "bin": { "hydra": "./dist/cli.js" }, "description": "A tool for spawning multiple local GitHub self-hosted runners", + "dependencies": { "@r5n/cli-core": "workspace:*" }, "devDependencies": { "@clack/prompts": "1.1.0", "@r5n/tools": "workspace:*" }, "files": ["dist", "schema.json", "LICENSE"], "keywords": ["github", "runner", "self-hosted", "ci", "cd", "automation"], diff --git a/packages/hydra/src/base-command.ts b/packages/hydra/src/base-command.ts new file mode 100644 index 0000000..985d348 --- /dev/null +++ b/packages/hydra/src/base-command.ts @@ -0,0 +1,9 @@ +import { AbstractCommand, type ArgDefinition, type BaseCtx, type PositionalDefinition } from "@r5n/cli-core"; +import type { HydraConfig } from "./types"; + +export abstract class BaseCommand extends AbstractCommand {} + +export type Ctx< + TArgDefs extends Record = Record, + TPositionalDefs extends Record = Record, +> = BaseCtx; diff --git a/packages/hydra/src/cli.ts b/packages/hydra/src/cli.ts index 702f428..23471fe 100644 --- a/packages/hydra/src/cli.ts +++ b/packages/hydra/src/cli.ts @@ -1 +1,27 @@ -console.log("hello"); +import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; +import { version } from "../package.json"; +import { InitCommand } from "./commands"; +import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants"; +import type { HydraConfig } from "./types"; + +class HydraCLI extends AbstractCLI { + constructor() { + super(new ConfigManager(HYDRA_CONFIG_FILE, DEFAULT_CONFIG), { + bin: CLI_BIN, + clearOnStart: true, + exitLabel: "Exit", + goodbyeMessage: "Heads down.", + name: "HYDRA", + promptMessage: "What do you need?", + version, + }); + } + + init() { + this.registerCommands([new InitCommand()]); + } +} + +const cli = new HydraCLI(); + +void cli.run(); diff --git a/packages/hydra/src/commands/index.ts b/packages/hydra/src/commands/index.ts new file mode 100644 index 0000000..6e78778 --- /dev/null +++ b/packages/hydra/src/commands/index.ts @@ -0,0 +1 @@ +export { InitCommand } from "./init"; diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts new file mode 100644 index 0000000..e4bb30b --- /dev/null +++ b/packages/hydra/src/commands/init.ts @@ -0,0 +1,168 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { Cancel, Exit, args, color, confirm, group, note, positionals, text } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../base-command"; +import { CLI_BIN, RUNNERS_DIR, SHARED_DIR } from "../constants"; +import type { Profile } from "../types"; + +const initPositionals = positionals({ + url: { description: "GitHub repository or organization URL" }, +}); + +const initArgs = args({ + force: { alias: "f", default: false, description: "Overwrite existing config", type: "boolean" }, + labels: { alias: "l", description: "Comma-separated runner labels", type: "string" }, + name: { alias: "n", description: "Base name for runners", type: "string" }, + runners: { alias: "c", description: "Number of runners to create", type: "number" }, +}); + +type InitCtx = Ctx; + +export class InitCommand extends BaseCommand { + name = "init"; + description = "Initialize Hydra runner manager"; + positionals = initPositionals; + args = initArgs; + prompts = true; + + async execute(ctx: InitCtx) { + if (ctx.config.exists() && !ctx.args.force) { + if (ctx.interactive) { + const overwrite = await confirm({ + initialValue: false, + message: "Hydra is already initialized. Overwrite config?", + }); + if (!overwrite) return; + } else { + throw new Exit("Config already exists. Use --force to overwrite."); + } + } + + await this.ensureDirectories(); + + const profile = ctx.interactive ? await this.runInitForm() : this.buildProfileFromArgs(ctx); + + ctx.config.set("profiles", { default: profile }); + ctx.config.set("defaultProfile", "default"); + + note( + [ + `${color.dim("Profile:")} default`, + `${color.dim("URL:")} ${profile.url}`, + `${color.dim("Runners:")} ${profile.numberOfMachines}`, + `${color.dim("Name:")} ${profile.name}`, + profile.labels ? `${color.dim("Labels:")} ${profile.labels}` : "", + "", + `Run ${color.green(`${CLI_BIN} --help`)} to see available commands.`, + ] + .filter(Boolean) + .join("\n"), + color.green("Hydra initialized"), + ); + } + + private async ensureDirectories() { + if (!existsSync(SHARED_DIR)) { + await mkdir(SHARED_DIR, { recursive: true }); + } + if (!existsSync(RUNNERS_DIR)) { + await mkdir(RUNNERS_DIR, { recursive: true }); + } + } + + private async runInitForm(): Promise { + const values = await group( + { + url: () => + text({ + message: "GitHub repository or organization URL", + placeholder: "https://github.com/owner/repo", + validate: (v) => { + if (!v) return "URL is required"; + try { + new URL(v); + } catch { + return "Must be a valid URL"; + } + }, + }), + name: () => + text({ + initialValue: "runner", + message: "Base name for runners", + placeholder: "runner", + }), + runners: () => + text({ + initialValue: "1", + message: "Number of runners", + placeholder: "1", + validate: (v) => { + const n = Number.parseInt(v, 10); + if (Number.isNaN(n) || n < 1) return "Must be a positive number"; + }, + }), + labels: () => + text({ + message: "Additional labels (comma-separated, optional)", + placeholder: "self-hosted,macOS,ARM64", + }), + }, + { onCancel: () => { throw new Cancel(); } }, + ); + + return { + directory: RUNNERS_DIR, + labels: values.labels || undefined, + name: values.name, + numberOfMachines: Number.parseInt(values.runners, 10) || 1, + os: this.detectOs(), + overwrite: false, + provider: "github", + run: false, + url: values.url, + }; + } + + private buildProfileFromArgs(ctx: InitCtx): Profile { + if (!ctx.positionals.url) { + throw new Exit("URL is required", "Usage: hydra init "); + } + + this.validateUrl(ctx.positionals.url); + this.validateRunnerCount(ctx.args.runners); + + return { + directory: RUNNERS_DIR, + labels: ctx.args.labels, + name: ctx.args.name ?? "runner", + numberOfMachines: ctx.args.runners ?? 1, + os: this.detectOs(), + overwrite: false, + provider: "github", + run: false, + url: ctx.positionals.url, + }; + } + + private validateUrl(url: string) { + try { + new URL(url); + } catch { + throw new Exit(`Invalid URL: ${url}`); + } + } + + private validateRunnerCount(count?: number) { + if (count !== undefined && count < 1) { + throw new Exit("Runner count must be at least 1"); + } + } + + private detectOs() { + const platform = process.platform; + if (platform === "darwin") return "osx" as const; + if (platform === "win32") return "windows" as const; + return "linux" as const; + } +} diff --git a/packages/hydra/src/constants.ts b/packages/hydra/src/constants.ts new file mode 100644 index 0000000..b527624 --- /dev/null +++ b/packages/hydra/src/constants.ts @@ -0,0 +1,12 @@ +import type { HydraConfig } from "./types"; + +export const CLI_BIN = "hydra"; + +export const HYDRA_DIR = ".hydra"; +export const HYDRA_CONFIG_FILE = `${HYDRA_DIR}/config.json`; +export const SHARED_DIR = `${HYDRA_DIR}/shared`; +export const RUNNERS_DIR = `${HYDRA_DIR}/runners`; + +export const DEFAULT_CONFIG: HydraConfig = { + profiles: {}, +}; diff --git a/packages/hydra/src/types.ts b/packages/hydra/src/types.ts new file mode 100644 index 0000000..b52c8b8 --- /dev/null +++ b/packages/hydra/src/types.ts @@ -0,0 +1,32 @@ +export type Provider = "github" | "gitlab"; + +export type Os = "osx" | "linux" | "windows"; + +export type Profile = { + directory: string; + labels?: string; + name: string; + numberOfMachines: number; + os: Os; + overwrite: boolean; + provider: Provider; + run: boolean; + runnerGroup?: string; + url: string; +}; + +export type RunnerEntry = { + id: string; + name: string; + profile: string; + provider: Provider; + url: string; + directory: string; + createdAt: string; +}; + +export type HydraConfig = { + defaultProfile?: string; + profiles: Record; + runners?: RunnerEntry[]; +}; From 66815d9783cecfac2c8e74b143942f8dee979801 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 17:21:30 +0200 Subject: [PATCH 02/22] feat(hydra): add GitHub runner provider with hard-link and symlink disk optimization --- .../src/providers/GitHubRunnerProvider.ts | 263 ++++++++++++++++++ packages/hydra/src/providers/index.ts | 15 + packages/hydra/src/providers/types.ts | 23 ++ 3 files changed, 301 insertions(+) create mode 100644 packages/hydra/src/providers/GitHubRunnerProvider.ts create mode 100644 packages/hydra/src/providers/index.ts create mode 100644 packages/hydra/src/providers/types.ts diff --git a/packages/hydra/src/providers/GitHubRunnerProvider.ts b/packages/hydra/src/providers/GitHubRunnerProvider.ts new file mode 100644 index 0000000..ede419b --- /dev/null +++ b/packages/hydra/src/providers/GitHubRunnerProvider.ts @@ -0,0 +1,263 @@ +import { existsSync } from "node:fs"; +import { cp, link, mkdir, readFile, readdir, symlink, unlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { SHARED_DIR } from "../constants"; +import type { Profile } from "../types"; +import type { DownloadResult, RunnerInfo, RunnerProvider } from "./types"; + +const RUNNER_REPO = "actions/runner"; +const PLATFORM_MAP = { linux: "linux-x64", osx: "osx-arm64", windows: "win-x64" } as const; +const HARDLINK_DIRS = ["bin"]; +const SYMLINK_DIRS = ["externals"]; + +export class GitHubRunnerProvider implements RunnerProvider { + private sharedPath: string | null = null; + + constructor(private profile: Profile) {} + + async download(): Promise { + const platform = PLATFORM_MAP[this.profile.os]; + const version = await this.getLatestVersion(); + const versionDir = join(SHARED_DIR, "github", version); + + if (existsSync(versionDir)) { + this.sharedPath = versionDir; + return { path: versionDir, version }; + } + + await mkdir(versionDir, { recursive: true }); + + const tarball = `actions-runner-${platform}-${version}.tar.gz`; + const tarballPath = join(versionDir, tarball); + const downloadUrl = `https://github.com/${RUNNER_REPO}/releases/download/v${version}/${tarball}`; + + await Bun.$`curl -sL -o ${tarballPath} ${downloadUrl}`; + await Bun.$`tar xzf ${tarballPath} -C ${versionDir}`; + await unlink(tarballPath); + + this.sharedPath = versionDir; + return { path: versionDir, version }; + } + + async create(count: number): Promise { + const shared = await this.ensureDownloaded(); + const runners: RunnerInfo[] = []; + + for (let i = 1; i <= count; i++) { + const name = `${this.profile.name}-${i}`; + const runnerDir = join(this.profile.directory, name); + + await this.setupRunnerDir(shared, runnerDir); + await this.registerRunner(runnerDir, name); + + runners.push({ directory: runnerDir, id: name, name, status: "registered" }); + } + + return runners; + } + + async remove(ids: string[]): Promise { + for (const id of ids) { + const runnerDir = join(this.profile.directory, id); + if (!existsSync(join(runnerDir, ".runner"))) continue; + + const token = await this.fetchRemovalToken(runnerDir); + await Bun.$`bash ./config.sh remove --token ${token}`.cwd(runnerDir).quiet().nothrow(); + } + } + + async start(ids: string[]): Promise { + for (const id of ids) { + const runnerDir = join(this.profile.directory, id); + const proc = Bun.spawn(["bash", "./run.sh"], { + cwd: resolve(runnerDir), + stderr: "ignore", + stdout: "ignore", + }); + await this.writePidFile(runnerDir, proc.pid); + } + } + + async stop(ids: string[]): Promise { + for (const id of ids) { + const runnerDir = join(this.profile.directory, id); + const pid = await this.readPidFile(runnerDir); + if (!pid) continue; + + try { + process.kill(pid, "SIGTERM"); + } catch {} + + await this.removePidFile(runnerDir); + } + } + + async list(): Promise { + const runnersDir = this.profile.directory; + if (!existsSync(runnersDir)) return []; + + const entries = await readdir(runnersDir, { withFileTypes: true }); + const runners: RunnerInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const runnerDir = join(runnersDir, entry.name); + runners.push(await this.getRunnerStatus(runnerDir, entry.name)); + } + + return runners; + } + + private async getRunnerStatus(runnerDir: string, id: string): Promise { + const name = await this.readRunnerName(runnerDir); + const pid = await this.readPidFile(runnerDir); + const isRunning = pid !== null && this.isProcessRunning(pid); + + let status: RunnerInfo["status"] = "unknown"; + if (isRunning) status = "running"; + else if (existsSync(join(runnerDir, ".runner"))) status = "registered"; + + return { + directory: runnerDir, + id, + name, + pid: isRunning ? pid : undefined, + status, + }; + } + + private async ensureDownloaded(): Promise { + if (this.sharedPath) return this.sharedPath; + const { path } = await this.download(); + return path; + } + + private async setupRunnerDir(sharedPath: string, runnerDir: string) { + await mkdir(runnerDir, { recursive: true }); + + for (const dir of HARDLINK_DIRS) { + await this.hardLinkDir(join(sharedPath, dir), join(runnerDir, dir)); + } + + for (const dir of SYMLINK_DIRS) { + const target = resolve(sharedPath, dir); + const linkPath = join(runnerDir, dir); + if (!existsSync(linkPath)) { + await symlink(target, linkPath); + } + } + + for (const pattern of ["*.sh", "*.sh.template"]) { + const files = await Array.fromAsync(new Bun.Glob(pattern).scan(sharedPath)); + for (const file of files) { + await cp(join(sharedPath, file), join(runnerDir, file)); + } + } + } + + private async registerRunner(runnerDir: string, name: string) { + const regToken = await this.fetchRegistrationToken(); + const configArgs = ["--url", this.profile.url, "--token", regToken, "--name", name, "--unattended"]; + if (this.profile.labels) configArgs.push("--labels", this.profile.labels); + if (this.profile.runnerGroup) configArgs.push("--runnergroup", this.profile.runnerGroup); + + await Bun.$`bash ./config.sh ${configArgs}`.cwd(runnerDir); + } + + private async fetchRegistrationToken(): Promise { + const { owner, repo } = this.parseUrl(this.profile.url); + const result = await Bun.$`gh api repos/${owner}/${repo}/actions/runners/registration-token --method POST --jq .token`.quiet(); + const token = result.stdout.toString().trim(); + + if (!token) { + throw new Error(`Failed to fetch registration token for ${owner}/${repo}`); + } + + return token; + } + + private async fetchRemovalToken(runnerDir: string): Promise { + const content = await readFile(join(runnerDir, ".runner"), "utf-8"); + const config = JSON.parse(this.stripBom(content)); + const url = config.gitHubUrl; + if (!url) throw new Error("Cannot determine GitHub URL from runner config"); + + const { owner, repo } = this.parseUrl(url); + const result = await Bun.$`gh api repos/${owner}/${repo}/actions/runners/remove-token --method POST --jq .token`.quiet(); + return result.stdout.toString().trim(); + } + + private async getLatestVersion(): Promise { + const result = await Bun.$`gh api repos/${RUNNER_REPO}/releases/latest --jq .tag_name`.quiet(); + return result.stdout.toString().trim().replace(/^v/, ""); + } + + private parseUrl(url: string): { owner: string; repo: string } { + const match = url.match(/github\.com\/([^/]+)\/([^/.]+)/); + if (!match?.[1] || !match[2]) { + throw new Error(`Invalid GitHub URL: ${url}`); + } + return { owner: match[1], repo: match[2] }; + } + + private pidFilePath(runnerDir: string) { + return resolve(runnerDir, ".pid"); + } + + private async writePidFile(runnerDir: string, pid: number) { + await writeFile(this.pidFilePath(runnerDir), String(pid)); + } + + private async readPidFile(runnerDir: string): Promise { + try { + const content = await readFile(this.pidFilePath(runnerDir), "utf-8"); + return Number.parseInt(content.trim(), 10); + } catch { + return null; + } + } + + private async removePidFile(runnerDir: string) { + try { + await unlink(this.pidFilePath(runnerDir)); + } catch {} + } + + private async readRunnerName(runnerDir: string): Promise { + try { + const content = await readFile(join(runnerDir, ".runner"), "utf-8"); + return JSON.parse(this.stripBom(content)).agentName ?? "unknown"; + } catch { + return "unknown"; + } + } + + private async hardLinkDir(source: string, dest: string) { + await mkdir(dest, { recursive: true }); + + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = join(source, entry.name); + const destPath = join(dest, entry.name); + + if (entry.isDirectory()) { + await this.hardLinkDir(srcPath, destPath); + } else { + await link(srcPath, destPath); + } + } + } + + private stripBom(content: string) { + return content.replace(/^\uFEFF/, ""); + } + + private isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } +} diff --git a/packages/hydra/src/providers/index.ts b/packages/hydra/src/providers/index.ts new file mode 100644 index 0000000..7676433 --- /dev/null +++ b/packages/hydra/src/providers/index.ts @@ -0,0 +1,15 @@ +import type { Profile } from "../types"; +import { GitHubRunnerProvider } from "./GitHubRunnerProvider"; +import type { RunnerProvider } from "./types"; + +export { GitHubRunnerProvider } from "./GitHubRunnerProvider"; +export type { DownloadResult, RunnerInfo, RunnerProvider, RunnerStatus } from "./types"; + +export function createProvider(profile: Profile): RunnerProvider { + switch (profile.provider) { + case "github": + return new GitHubRunnerProvider(profile); + default: + throw new Error(`Runner provider "${profile.provider}" is not yet implemented`); + } +} diff --git a/packages/hydra/src/providers/types.ts b/packages/hydra/src/providers/types.ts new file mode 100644 index 0000000..8b23199 --- /dev/null +++ b/packages/hydra/src/providers/types.ts @@ -0,0 +1,23 @@ +export type RunnerStatus = "running" | "stopped" | "registered" | "unknown"; + +export type RunnerInfo = { + id: string; + name: string; + directory: string; + status: RunnerStatus; + pid?: number; +}; + +export type DownloadResult = { + version: string; + path: string; +}; + +export interface RunnerProvider { + download(): Promise; + create(count: number): Promise; + remove(ids: string[]): Promise; + start(ids: string[]): Promise; + stop(ids: string[]): Promise; + list(): Promise; +} From 3c14254a12530975bbfb11bdefcbebbec78a1110 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 18:08:25 +0200 Subject: [PATCH 03/22] feat(hydra): add `create` command create(count) (multi) -> create(name) (single) to make UI creation visuals possible --- packages/hydra/src/cli.ts | 4 +- packages/hydra/src/commands/create.ts | 70 +++++++++++++++++++ packages/hydra/src/commands/index.ts | 1 + packages/hydra/src/commands/init.ts | 8 +-- packages/hydra/src/constants.ts | 2 + .../src/providers/GitHubRunnerProvider.ts | 19 ++--- packages/hydra/src/providers/types.ts | 2 +- 7 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 packages/hydra/src/commands/create.ts diff --git a/packages/hydra/src/cli.ts b/packages/hydra/src/cli.ts index 23471fe..93afcd7 100644 --- a/packages/hydra/src/cli.ts +++ b/packages/hydra/src/cli.ts @@ -1,6 +1,6 @@ import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; import { version } from "../package.json"; -import { InitCommand } from "./commands"; +import { CreateCommand, InitCommand } from "./commands"; import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants"; import type { HydraConfig } from "./types"; @@ -18,7 +18,7 @@ class HydraCLI extends AbstractCLI { } init() { - this.registerCommands([new InitCommand()]); + this.registerCommands([new InitCommand(), new CreateCommand()]); } } diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts new file mode 100644 index 0000000..2d22708 --- /dev/null +++ b/packages/hydra/src/commands/create.ts @@ -0,0 +1,70 @@ +import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../base-command"; +import { DEFAULT_PROFILE } from "../constants"; +import { createProvider } from "../providers"; +import type { RunnerEntry } from "../types"; + +const createPositionals = positionals({ + count: { description: "Number of runners to create (overrides profile)" }, +}); + +const createArgs = args({ + profile: { alias: "p", description: "Profile name to use", type: "string" }, +}); + +type CreateCtx = Ctx; + +export class CreateCommand extends BaseCommand { + name = "create"; + description = "Create and register runners"; + positionals = createPositionals; + args = createArgs; + + async execute(ctx: CreateCtx) { + const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + if (!profileName) { + throw new Exit("No profile specified", "Run hydra init first or use --profile"); + } + + const profiles = ctx.config.get("profiles"); + const profile = profiles[profileName]; + if (!profile) { + throw new Exit(`Profile "${profileName}" not found`); + } + + const count = ctx.positionals.count ? Number.parseInt(ctx.positionals.count, 10) : profile.numberOfMachines; + if (!count || count < 1) { + throw new Exit("Runner count must be at least 1"); + } + + const provider = createProvider(profile); + + const s = spinner(); + s.start("Downloading runner binary..."); + const { version } = await provider.download(); + s.stop(`Runner v${version} ready`); + + const entries: RunnerEntry[] = ctx.config.get("runners") ?? []; + + for (let i = 1; i <= count; i++) { + const name = `${profile.name}-${i}`; + s.start(`Registering ${name}...`); + const runner = await provider.create(name); + s.stop(`${color.green("+")} ${name}`); + + entries.push({ + createdAt: new Date().toISOString(), + directory: runner.directory, + id: runner.id, + name: runner.name, + profile: profileName, + provider: profile.provider, + url: profile.url, + }); + } + + ctx.config.set("runners", entries); + + log.info(`\n${color.green("Created")} ${count} runner(s). Run ${color.green("hydra start")} to start them.`); + } +} diff --git a/packages/hydra/src/commands/index.ts b/packages/hydra/src/commands/index.ts index 6e78778..2f2400f 100644 --- a/packages/hydra/src/commands/index.ts +++ b/packages/hydra/src/commands/index.ts @@ -1 +1,2 @@ +export { CreateCommand } from "./create"; export { InitCommand } from "./init"; diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts index e4bb30b..71e8c2e 100644 --- a/packages/hydra/src/commands/init.ts +++ b/packages/hydra/src/commands/init.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { Cancel, Exit, args, color, confirm, group, note, positionals, text } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; -import { CLI_BIN, RUNNERS_DIR, SHARED_DIR } from "../constants"; +import { CLI_BIN, DEFAULT_PROFILE, RUNNERS_DIR, SHARED_DIR } from "../constants"; import type { Profile } from "../types"; const initPositionals = positionals({ @@ -42,12 +42,12 @@ export class InitCommand extends BaseCommand { const profile = ctx.interactive ? await this.runInitForm() : this.buildProfileFromArgs(ctx); - ctx.config.set("profiles", { default: profile }); - ctx.config.set("defaultProfile", "default"); + ctx.config.set("profiles", { [DEFAULT_PROFILE]: profile }); + ctx.config.set("defaultProfile", DEFAULT_PROFILE); note( [ - `${color.dim("Profile:")} default`, + `${color.dim("Profile:")} ${DEFAULT_PROFILE}`, `${color.dim("URL:")} ${profile.url}`, `${color.dim("Runners:")} ${profile.numberOfMachines}`, `${color.dim("Name:")} ${profile.name}`, diff --git a/packages/hydra/src/constants.ts b/packages/hydra/src/constants.ts index b527624..42dcd06 100644 --- a/packages/hydra/src/constants.ts +++ b/packages/hydra/src/constants.ts @@ -7,6 +7,8 @@ export const HYDRA_CONFIG_FILE = `${HYDRA_DIR}/config.json`; export const SHARED_DIR = `${HYDRA_DIR}/shared`; export const RUNNERS_DIR = `${HYDRA_DIR}/runners`; +export const DEFAULT_PROFILE = "default"; + export const DEFAULT_CONFIG: HydraConfig = { profiles: {}, }; diff --git a/packages/hydra/src/providers/GitHubRunnerProvider.ts b/packages/hydra/src/providers/GitHubRunnerProvider.ts index ede419b..0ca9e25 100644 --- a/packages/hydra/src/providers/GitHubRunnerProvider.ts +++ b/packages/hydra/src/providers/GitHubRunnerProvider.ts @@ -39,21 +39,14 @@ export class GitHubRunnerProvider implements RunnerProvider { return { path: versionDir, version }; } - async create(count: number): Promise { + async create(name: string): Promise { const shared = await this.ensureDownloaded(); - const runners: RunnerInfo[] = []; - - for (let i = 1; i <= count; i++) { - const name = `${this.profile.name}-${i}`; - const runnerDir = join(this.profile.directory, name); + const runnerDir = join(this.profile.directory, name); - await this.setupRunnerDir(shared, runnerDir); - await this.registerRunner(runnerDir, name); + await this.setupRunnerDir(shared, runnerDir); + await this.registerRunner(runnerDir, name); - runners.push({ directory: runnerDir, id: name, name, status: "registered" }); - } - - return runners; + return { directory: runnerDir, id: name, name, status: "registered" }; } async remove(ids: string[]): Promise { @@ -161,7 +154,7 @@ export class GitHubRunnerProvider implements RunnerProvider { if (this.profile.labels) configArgs.push("--labels", this.profile.labels); if (this.profile.runnerGroup) configArgs.push("--runnergroup", this.profile.runnerGroup); - await Bun.$`bash ./config.sh ${configArgs}`.cwd(runnerDir); + await Bun.$`bash ./config.sh ${configArgs}`.cwd(runnerDir).quiet(); } private async fetchRegistrationToken(): Promise { diff --git a/packages/hydra/src/providers/types.ts b/packages/hydra/src/providers/types.ts index 8b23199..8f6a6d0 100644 --- a/packages/hydra/src/providers/types.ts +++ b/packages/hydra/src/providers/types.ts @@ -15,7 +15,7 @@ export type DownloadResult = { export interface RunnerProvider { download(): Promise; - create(count: number): Promise; + create(name: string): Promise; remove(ids: string[]): Promise; start(ids: string[]): Promise; stop(ids: string[]): Promise; From 73e9749563c0499cfbbaf327fccc7196e2912e2f Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 18:30:13 +0200 Subject: [PATCH 04/22] fix(hydra): ensure create targets total count and skip existing runners --- packages/hydra/src/commands/create.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index 2d22708..0b02fd9 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -45,9 +45,21 @@ export class CreateCommand extends BaseCommand { s.stop(`Runner v${version} ready`); const entries: RunnerEntry[] = ctx.config.get("runners") ?? []; + const existing = entries.filter((e) => e.profile === profileName); + const toCreate = count - existing.length; - for (let i = 1; i <= count; i++) { - const name = `${profile.name}-${i}`; + if (toCreate <= 0) { + log.warn(`Already have ${existing.length} runner(s) for profile "${profileName}". Nothing to create.`); + return; + } + + const existingIds = new Set(entries.map((e) => e.id)); + let nextIndex = 1; + + for (let created = 0; created < toCreate; ) { + const name = `${profile.name}-${nextIndex++}`; + if (existingIds.has(name)) continue; + created++; s.start(`Registering ${name}...`); const runner = await provider.create(name); s.stop(`${color.green("+")} ${name}`); @@ -65,6 +77,6 @@ export class CreateCommand extends BaseCommand { ctx.config.set("runners", entries); - log.info(`\n${color.green("Created")} ${count} runner(s). Run ${color.green("hydra start")} to start them.`); + log.info(`${color.green("Created")} ${toCreate} runner(s). Total: ${entries.length}. Run ${color.green("hydra start")} to start them.`); } } From abda1dd6311be747a3dd3dcc839c35eeb0a2b84c Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 18:34:40 +0200 Subject: [PATCH 05/22] refactor(hydra): unify provider interface to use id arrays consistently --- packages/hydra/src/commands/create.ts | 11 ++++++----- .../hydra/src/providers/GitHubRunnerProvider.ts | 14 +++++++++----- packages/hydra/src/providers/types.ts | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index 0b02fd9..7884414 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -57,12 +57,13 @@ export class CreateCommand extends BaseCommand { let nextIndex = 1; for (let created = 0; created < toCreate; ) { - const name = `${profile.name}-${nextIndex++}`; - if (existingIds.has(name)) continue; + const id = `${profile.name}-${nextIndex++}`; + if (existingIds.has(id)) continue; created++; - s.start(`Registering ${name}...`); - const runner = await provider.create(name); - s.stop(`${color.green("+")} ${name}`); + + s.start(`Registering ${id}...`); + const [runner] = await provider.create([id]); + s.stop(`${color.green("+")} ${id}`); entries.push({ createdAt: new Date().toISOString(), diff --git a/packages/hydra/src/providers/GitHubRunnerProvider.ts b/packages/hydra/src/providers/GitHubRunnerProvider.ts index 0ca9e25..15fba03 100644 --- a/packages/hydra/src/providers/GitHubRunnerProvider.ts +++ b/packages/hydra/src/providers/GitHubRunnerProvider.ts @@ -39,14 +39,18 @@ export class GitHubRunnerProvider implements RunnerProvider { return { path: versionDir, version }; } - async create(name: string): Promise { + async create(ids: string[]): Promise { const shared = await this.ensureDownloaded(); - const runnerDir = join(this.profile.directory, name); + const runners: RunnerInfo[] = []; - await this.setupRunnerDir(shared, runnerDir); - await this.registerRunner(runnerDir, name); + for (const id of ids) { + const runnerDir = join(this.profile.directory, id); + await this.setupRunnerDir(shared, runnerDir); + await this.registerRunner(runnerDir, id); + runners.push({ directory: runnerDir, id, name: id, status: "registered" }); + } - return { directory: runnerDir, id: name, name, status: "registered" }; + return runners; } async remove(ids: string[]): Promise { diff --git a/packages/hydra/src/providers/types.ts b/packages/hydra/src/providers/types.ts index 8f6a6d0..16243d6 100644 --- a/packages/hydra/src/providers/types.ts +++ b/packages/hydra/src/providers/types.ts @@ -15,7 +15,7 @@ export type DownloadResult = { export interface RunnerProvider { download(): Promise; - create(name: string): Promise; + create(ids: string[]): Promise; remove(ids: string[]): Promise; start(ids: string[]): Promise; stop(ids: string[]): Promise; From c8cf6d2e5642d9ab4a3f5b71f29eecdd81e3db6e Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 18:42:43 +0200 Subject: [PATCH 06/22] feat(hydra): add `remove` command with profile-scoped runner deregistration --- packages/hydra/src/cli.ts | 4 +- packages/hydra/src/commands/index.ts | 1 + packages/hydra/src/commands/remove.ts | 71 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 packages/hydra/src/commands/remove.ts diff --git a/packages/hydra/src/cli.ts b/packages/hydra/src/cli.ts index 93afcd7..9a26e3c 100644 --- a/packages/hydra/src/cli.ts +++ b/packages/hydra/src/cli.ts @@ -1,6 +1,6 @@ import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; import { version } from "../package.json"; -import { CreateCommand, InitCommand } from "./commands"; +import { CreateCommand, InitCommand, RemoveCommand } from "./commands"; import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants"; import type { HydraConfig } from "./types"; @@ -18,7 +18,7 @@ class HydraCLI extends AbstractCLI { } init() { - this.registerCommands([new InitCommand(), new CreateCommand()]); + this.registerCommands([new InitCommand(), new CreateCommand(), new RemoveCommand()]); } } diff --git a/packages/hydra/src/commands/index.ts b/packages/hydra/src/commands/index.ts index 2f2400f..28bec26 100644 --- a/packages/hydra/src/commands/index.ts +++ b/packages/hydra/src/commands/index.ts @@ -1,2 +1,3 @@ export { CreateCommand } from "./create"; export { InitCommand } from "./init"; +export { RemoveCommand } from "./remove"; diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts new file mode 100644 index 0000000..38aa962 --- /dev/null +++ b/packages/hydra/src/commands/remove.ts @@ -0,0 +1,71 @@ +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../base-command"; +import { DEFAULT_PROFILE } from "../constants"; +import { createProvider } from "../providers"; + +const removePositionals = positionals({ + ids: { description: "Runner IDs to remove, or 'all'", variadic: true }, +}); + +const removeArgs = args({ + profile: { alias: "p", description: "Profile name to use", type: "string" }, +}); + +type RemoveCtx = Ctx; + +export class RemoveCommand extends BaseCommand { + name = "remove"; + description = "Deregister and remove runners"; + positionals = removePositionals; + args = removeArgs; + + async execute(ctx: RemoveCtx) { + const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profile = ctx.config.get("profiles")[profileName]; + if (!profile) { + throw new Exit(`Profile "${profileName}" not found`); + } + + const entries = ctx.config.get("runners") ?? []; + const profileEntries = entries.filter((e) => e.profile === profileName); + if (profileEntries.length === 0) { + throw new Exit(`No runners found for profile "${profileName}"`, "Use --profile to specify a different profile"); + } + + const ids = this.resolveIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + const provider = createProvider(profile); + const s = spinner(); + + for (const id of ids) { + s.start(`Removing ${id}...`); + await provider.remove([id]); + await rm(join(profile.directory, id), { force: true, recursive: true }); + s.stop(`${color.red("-")} ${id}`); + } + + const remaining = entries.filter((e) => !ids.includes(e.id)); + ctx.config.set("runners", remaining); + + log.info(`${color.green("Removed")} ${ids.length} runner(s).`); + } + + private resolveIds(input: string[], available: string[]): string[] { + if (input.length === 0) { + throw new Exit("Specify runner IDs or 'all'", "Usage: hydra remove or hydra remove all"); + } + + if (input.length === 1 && input[0] === "all") { + return available; + } + + for (const id of input) { + if (!available.includes(id)) { + throw new Exit(`Runner "${id}" not found`); + } + } + + return input; + } +} From b8baa4217f531d17106fd27c970502d1654eda00 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 19:37:29 +0200 Subject: [PATCH 07/22] =?UTF-8?q?fix(hydra):=20fix=20process=20lifecycle?= =?UTF-8?q?=20=E2=80=94=20unref=20spawned=20runners=20and=20pkill=20proces?= =?UTF-8?q?s=20tree=20on=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hydra/src/providers/GitHubRunnerProvider.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/hydra/src/providers/GitHubRunnerProvider.ts b/packages/hydra/src/providers/GitHubRunnerProvider.ts index 15fba03..94a892f 100644 --- a/packages/hydra/src/providers/GitHubRunnerProvider.ts +++ b/packages/hydra/src/providers/GitHubRunnerProvider.ts @@ -66,11 +66,15 @@ export class GitHubRunnerProvider implements RunnerProvider { async start(ids: string[]): Promise { for (const id of ids) { const runnerDir = join(this.profile.directory, id); + const existingPid = await this.readPidFile(runnerDir); + if (existingPid && this.isProcessRunning(existingPid)) continue; + const proc = Bun.spawn(["bash", "./run.sh"], { cwd: resolve(runnerDir), - stderr: "ignore", - stdout: "ignore", + stderr: "pipe", + stdout: "pipe", }); + proc.unref(); await this.writePidFile(runnerDir, proc.pid); } } @@ -81,9 +85,8 @@ export class GitHubRunnerProvider implements RunnerProvider { const pid = await this.readPidFile(runnerDir); if (!pid) continue; - try { - process.kill(pid, "SIGTERM"); - } catch {} + const absDir = resolve(runnerDir); + await Bun.$`pkill -f ${absDir}`.quiet().nothrow(); await this.removePidFile(runnerDir); } From dd461da2d05980f6ad2ca968222fbd164bb32723 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 19:40:15 +0200 Subject: [PATCH 08/22] feat(hydra): add `list`, `start`, `stop` commands with status-aware feedback --- packages/hydra/src/cli.ts | 11 +++++- packages/hydra/src/commands/index.ts | 3 ++ packages/hydra/src/commands/list.ts | 48 +++++++++++++++++++++++ packages/hydra/src/commands/remove.ts | 20 +--------- packages/hydra/src/commands/start.ts | 55 +++++++++++++++++++++++++++ packages/hydra/src/commands/stop.ts | 55 +++++++++++++++++++++++++++ packages/hydra/src/utils.ts | 19 +++++++++ 7 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 packages/hydra/src/commands/list.ts create mode 100644 packages/hydra/src/commands/start.ts create mode 100644 packages/hydra/src/commands/stop.ts create mode 100644 packages/hydra/src/utils.ts diff --git a/packages/hydra/src/cli.ts b/packages/hydra/src/cli.ts index 9a26e3c..3f8c89f 100644 --- a/packages/hydra/src/cli.ts +++ b/packages/hydra/src/cli.ts @@ -1,6 +1,6 @@ import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; import { version } from "../package.json"; -import { CreateCommand, InitCommand, RemoveCommand } from "./commands"; +import { CreateCommand, InitCommand, ListCommand, RemoveCommand, StartCommand, StopCommand } from "./commands"; import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants"; import type { HydraConfig } from "./types"; @@ -18,7 +18,14 @@ class HydraCLI extends AbstractCLI { } init() { - this.registerCommands([new InitCommand(), new CreateCommand(), new RemoveCommand()]); + this.registerCommands([ + new InitCommand(), + new CreateCommand(), + new StartCommand(), + new StopCommand(), + new ListCommand(), + new RemoveCommand(), + ]); } } diff --git a/packages/hydra/src/commands/index.ts b/packages/hydra/src/commands/index.ts index 28bec26..77aa45d 100644 --- a/packages/hydra/src/commands/index.ts +++ b/packages/hydra/src/commands/index.ts @@ -1,3 +1,6 @@ export { CreateCommand } from "./create"; export { InitCommand } from "./init"; +export { ListCommand } from "./list"; export { RemoveCommand } from "./remove"; +export { StartCommand } from "./start"; +export { StopCommand } from "./stop"; diff --git a/packages/hydra/src/commands/list.ts b/packages/hydra/src/commands/list.ts new file mode 100644 index 0000000..9f9ddcd --- /dev/null +++ b/packages/hydra/src/commands/list.ts @@ -0,0 +1,48 @@ +import { Exit, args, color, log } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../base-command"; +import { DEFAULT_PROFILE } from "../constants"; +import { createProvider } from "../providers"; + +const listArgs = args({ + profile: { alias: "p", description: "Profile name to use", type: "string" }, +}); + +type ListCtx = Ctx; + +const STATUS_COLORS: Record string> = { + registered: color.yellow, + running: color.green, + stopped: color.red, + unknown: color.dim, +}; + +export class ListCommand extends BaseCommand { + name = "list"; + description = "List runners and their status"; + args = listArgs; + + async execute(ctx: ListCtx) { + const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profile = ctx.config.get("profiles")[profileName]; + if (!profile) { + throw new Exit(`Profile "${profileName}" not found`); + } + + const provider = createProvider(profile); + const runners = await provider.list(); + + if (runners.length === 0) { + log.info(color.dim("No runners found.")); + return; + } + + for (const runner of runners) { + const statusColor = STATUS_COLORS[runner.status] ?? color.dim; + const pid = runner.pid ? color.dim(` (pid: ${runner.pid})`) : ""; + log.info(` ${statusColor("●")} ${runner.name} ${statusColor(runner.status)}${pid}`); + } + + const running = runners.filter((r) => r.status === "running").length; + log.info(color.dim(` ${runners.length} runner(s), ${running} running`)); + } +} diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts index 38aa962..5c777e4 100644 --- a/packages/hydra/src/commands/remove.ts +++ b/packages/hydra/src/commands/remove.ts @@ -4,6 +4,7 @@ import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; +import { resolveRunnerIds } from "../utils"; const removePositionals = positionals({ ids: { description: "Runner IDs to remove, or 'all'", variadic: true }, @@ -34,7 +35,7 @@ export class RemoveCommand extends BaseCommand { throw new Exit(`No runners found for profile "${profileName}"`, "Use --profile to specify a different profile"); } - const ids = this.resolveIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + const ids = resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); const provider = createProvider(profile); const s = spinner(); @@ -51,21 +52,4 @@ export class RemoveCommand extends BaseCommand { log.info(`${color.green("Removed")} ${ids.length} runner(s).`); } - private resolveIds(input: string[], available: string[]): string[] { - if (input.length === 0) { - throw new Exit("Specify runner IDs or 'all'", "Usage: hydra remove or hydra remove all"); - } - - if (input.length === 1 && input[0] === "all") { - return available; - } - - for (const id of input) { - if (!available.includes(id)) { - throw new Exit(`Runner "${id}" not found`); - } - } - - return input; - } } diff --git a/packages/hydra/src/commands/start.ts b/packages/hydra/src/commands/start.ts new file mode 100644 index 0000000..e3a47f2 --- /dev/null +++ b/packages/hydra/src/commands/start.ts @@ -0,0 +1,55 @@ +import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../base-command"; +import { DEFAULT_PROFILE } from "../constants"; +import { createProvider } from "../providers"; +import { resolveRunnerIds } from "../utils"; + +const startPositionals = positionals({ + ids: { description: "Runner IDs to start, or 'all'", variadic: true }, +}); + +const startArgs = args({ + profile: { alias: "p", description: "Profile name to use", type: "string" }, +}); + +type StartCtx = Ctx; + +export class StartCommand extends BaseCommand { + name = "start"; + description = "Start runners"; + positionals = startPositionals; + args = startArgs; + + async execute(ctx: StartCtx) { + const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profile = ctx.config.get("profiles")[profileName]; + if (!profile) { + throw new Exit(`Profile "${profileName}" not found`); + } + + const entries = ctx.config.get("runners") ?? []; + const profileEntries = entries.filter((e) => e.profile === profileName); + const ids = resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + + const provider = createProvider(profile); + const s = spinner(); + const statuses = await provider.list(); + const runningIds = new Set(statuses.filter((r) => r.status === "running").map((r) => r.id)); + + let started = 0; + for (const id of ids) { + if (runningIds.has(id)) { + s.start(""); + s.stop(`${color.dim("●")} ${id} ${color.dim("already running")}`); + continue; + } + + s.start(`Starting ${id}...`); + await provider.start([id]); + s.stop(`${color.green("▶")} ${id}`); + started++; + } + + log.info(`${color.green("Started")} ${started} runner(s).`); + } +} diff --git a/packages/hydra/src/commands/stop.ts b/packages/hydra/src/commands/stop.ts new file mode 100644 index 0000000..2cbd10f --- /dev/null +++ b/packages/hydra/src/commands/stop.ts @@ -0,0 +1,55 @@ +import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../base-command"; +import { DEFAULT_PROFILE } from "../constants"; +import { createProvider } from "../providers"; +import { resolveRunnerIds } from "../utils"; + +const stopPositionals = positionals({ + ids: { description: "Runner IDs to stop, or 'all'", variadic: true }, +}); + +const stopArgs = args({ + profile: { alias: "p", description: "Profile name to use", type: "string" }, +}); + +type StopCtx = Ctx; + +export class StopCommand extends BaseCommand { + name = "stop"; + description = "Stop runners"; + positionals = stopPositionals; + args = stopArgs; + + async execute(ctx: StopCtx) { + const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profile = ctx.config.get("profiles")[profileName]; + if (!profile) { + throw new Exit(`Profile "${profileName}" not found`); + } + + const entries = ctx.config.get("runners") ?? []; + const profileEntries = entries.filter((e) => e.profile === profileName); + const ids = resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + + const provider = createProvider(profile); + const s = spinner(); + const statuses = await provider.list(); + const runningIds = new Set(statuses.filter((r) => r.status === "running").map((r) => r.id)); + + let stopped = 0; + for (const id of ids) { + if (!runningIds.has(id)) { + s.start(""); + s.stop(`${color.dim("●")} ${id} ${color.dim("not running")}`); + continue; + } + + s.start(`Stopping ${id}...`); + await provider.stop([id]); + s.stop(`${color.red("■")} ${id}`); + stopped++; + } + + log.info(`${color.green("Stopped")} ${stopped} runner(s).`); + } +} diff --git a/packages/hydra/src/utils.ts b/packages/hydra/src/utils.ts new file mode 100644 index 0000000..26e995b --- /dev/null +++ b/packages/hydra/src/utils.ts @@ -0,0 +1,19 @@ +import { Exit } from "@r5n/cli-core"; + +export function resolveRunnerIds(input: string[], available: string[]): string[] { + if (input.length === 0) { + throw new Exit("Specify runner IDs or 'all'", "Example: hydra all"); + } + + if (input.length === 1 && input[0] === "all") { + return available; + } + + for (const id of input) { + if (!available.includes(id)) { + throw new Exit(`Runner "${id}" not found`); + } + } + + return input; +} From ec5237e93328665b95b56a83b5368c5ce71b4090 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 19:48:07 +0200 Subject: [PATCH 09/22] feat(hydra): support named profiles via --profile flag in init --- packages/hydra/src/commands/init.ts | 33 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts index 71e8c2e..2437a14 100644 --- a/packages/hydra/src/commands/init.ts +++ b/packages/hydra/src/commands/init.ts @@ -13,6 +13,7 @@ const initArgs = args({ force: { alias: "f", default: false, description: "Overwrite existing config", type: "boolean" }, labels: { alias: "l", description: "Comma-separated runner labels", type: "string" }, name: { alias: "n", description: "Base name for runners", type: "string" }, + profile: { alias: "p", description: "Profile name", type: "string" }, runners: { alias: "c", description: "Number of runners to create", type: "number" }, }); @@ -26,38 +27,42 @@ export class InitCommand extends BaseCommand { prompts = true; async execute(ctx: InitCtx) { - if (ctx.config.exists() && !ctx.args.force) { - if (ctx.interactive) { - const overwrite = await confirm({ - initialValue: false, - message: "Hydra is already initialized. Overwrite config?", - }); - if (!overwrite) return; - } else { - throw new Exit("Config already exists. Use --force to overwrite."); + const profileName = ctx.args.profile ?? DEFAULT_PROFILE; + const isNewInit = !ctx.config.exists(); + + const profileExists = !isNewInit && ctx.config.get("profiles")[profileName]; + if (profileExists && !ctx.args.force) { + if (!ctx.interactive) { + throw new Exit(`Profile "${profileName}" already exists. Use --force to overwrite.`); } + const overwrite = await confirm({ initialValue: false, message: `Profile "${profileName}" already exists. Overwrite?` }); + if (!overwrite) return; } await this.ensureDirectories(); const profile = ctx.interactive ? await this.runInitForm() : this.buildProfileFromArgs(ctx); + const profiles = isNewInit ? {} : { ...ctx.config.get("profiles") }; + profiles[profileName] = profile; - ctx.config.set("profiles", { [DEFAULT_PROFILE]: profile }); - ctx.config.set("defaultProfile", DEFAULT_PROFILE); + ctx.config.set("profiles", profiles); + if (isNewInit) { + ctx.config.set("defaultProfile", profileName); + } note( [ - `${color.dim("Profile:")} ${DEFAULT_PROFILE}`, + `${color.dim("Profile:")} ${profileName}`, `${color.dim("URL:")} ${profile.url}`, `${color.dim("Runners:")} ${profile.numberOfMachines}`, `${color.dim("Name:")} ${profile.name}`, profile.labels ? `${color.dim("Labels:")} ${profile.labels}` : "", "", - `Run ${color.green(`${CLI_BIN} --help`)} to see available commands.`, + `Run ${color.green(`${CLI_BIN} create`)}${profileName !== DEFAULT_PROFILE ? color.green(` --profile ${profileName}`) : ""} to provision runners.`, ] .filter(Boolean) .join("\n"), - color.green("Hydra initialized"), + color.green(isNewInit ? "Hydra initialized" : `Profile "${profileName}" added`), ); } From f119356e3fb5a1c592b1a99e9786fcc9446dd79a Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 19:55:14 +0200 Subject: [PATCH 10/22] chore(hydra): remove dead code --- packages/hydra/src/commands/create.ts | 4 ---- packages/hydra/src/commands/remove.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index 7884414..78ad9e1 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -22,10 +22,6 @@ export class CreateCommand extends BaseCommand { async execute(ctx: CreateCtx) { const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; - if (!profileName) { - throw new Exit("No profile specified", "Run hydra init first or use --profile"); - } - const profiles = ctx.config.get("profiles"); const profile = profiles[profileName]; if (!profile) { diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts index 5c777e4..d888668 100644 --- a/packages/hydra/src/commands/remove.ts +++ b/packages/hydra/src/commands/remove.ts @@ -51,5 +51,4 @@ export class RemoveCommand extends BaseCommand { log.info(`${color.green("Removed")} ${ids.length} runner(s).`); } - } From a713afc3f90403a610abe7145b9afcc0541ddecd Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Tue, 31 Mar 2026 20:01:01 +0200 Subject: [PATCH 11/22] fix(hydra): resolve type errors and ensure build passes --- packages/hydra/src/commands/create.ts | 9 +++++---- packages/hydra/src/commands/init.ts | 7 +++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index 78ad9e1..d14aefa 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; @@ -58,14 +59,14 @@ export class CreateCommand extends BaseCommand { created++; s.start(`Registering ${id}...`); - const [runner] = await provider.create([id]); + await provider.create([id]); s.stop(`${color.green("+")} ${id}`); entries.push({ createdAt: new Date().toISOString(), - directory: runner.directory, - id: runner.id, - name: runner.name, + directory: join(profile.directory, id), + id, + name: id, profile: profileName, provider: profile.provider, url: profile.url, diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts index 2437a14..5b7938b 100644 --- a/packages/hydra/src/commands/init.ts +++ b/packages/hydra/src/commands/init.ts @@ -82,13 +82,14 @@ export class InitCommand extends BaseCommand { text({ message: "GitHub repository or organization URL", placeholder: "https://github.com/owner/repo", - validate: (v) => { + validate: (v): string | undefined => { if (!v) return "URL is required"; try { new URL(v); } catch { return "Must be a valid URL"; } + return undefined; }, }), name: () => @@ -102,9 +103,11 @@ export class InitCommand extends BaseCommand { initialValue: "1", message: "Number of runners", placeholder: "1", - validate: (v) => { + validate: (v): string | undefined => { + if (!v) return "Must be a positive number"; const n = Number.parseInt(v, 10); if (Number.isNaN(n) || n < 1) return "Must be a positive number"; + return undefined; }, }), labels: () => From fb0e4a224bfef0b841fb6e5edb78f102f2bbb743 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 00:21:54 +0200 Subject: [PATCH 12/22] fix(core): catch Exit errors in interactive mode and return to menu --- packages/core/src/command-router.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/command-router.ts b/packages/core/src/command-router.ts index 60dc096..3b473a6 100644 --- a/packages/core/src/command-router.ts +++ b/packages/core/src/command-router.ts @@ -1,9 +1,10 @@ import { Cancel } from "./cancel"; import type { AbstractCommand, ArgValue, CommandContext } from "./command"; import type { ConfigManager } from "./config-manager"; +import { Exit } from "./exit"; import { log, select } from "./prompts"; import type { CliMetadata } from "./types"; -import { handleUnknownItem, mapPositionals, parseCommandArgs, validatePositionals } from "./util"; +import { color, handleUnknownItem, mapPositionals, parseCommandArgs, validatePositionals } from "./util"; const EXIT_MENU_VALUE = "__exit__"; @@ -144,6 +145,11 @@ export class CommandRouter { await subRouter.route([], true); } catch (error) { if (error instanceof Cancel) return; + if (error instanceof Exit) { + log.warn(color.yellow(error.message)); + if (error.hint) log.info(color.dim(error.hint)); + return; + } throw error; } } From 4a3f42dfdc9bffc1feb3c03071e87b3ede05c8d8 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 00:33:27 +0200 Subject: [PATCH 13/22] feat(hydra): add interactive prompts for start, stop, remove and always list all profiles --- packages/hydra/src/commands/create.ts | 26 ++++++++++-- packages/hydra/src/commands/init.ts | 12 +++++- packages/hydra/src/commands/list.ts | 57 ++++++++++++++++++--------- packages/hydra/src/commands/remove.ts | 24 ++++++++--- packages/hydra/src/commands/start.ts | 35 ++++++++++++++-- packages/hydra/src/commands/stop.ts | 35 ++++++++++++++-- packages/hydra/src/utils.ts | 24 ++++++++++- 7 files changed, 177 insertions(+), 36 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index d14aefa..04f57a0 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -1,9 +1,10 @@ import { join } from "node:path"; -import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { Exit, args, color, log, positionals, spinner, text } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; import type { RunnerEntry } from "../types"; +import { selectProfile } from "../utils"; const createPositionals = positionals({ count: { description: "Number of runners to create (overrides profile)" }, @@ -22,14 +23,20 @@ export class CreateCommand extends BaseCommand { args = createArgs; async execute(ctx: CreateCtx) { - const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profileName = ctx.interactive + ? await selectProfile(ctx.config) + : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + const profiles = ctx.config.get("profiles"); const profile = profiles[profileName]; if (!profile) { throw new Exit(`Profile "${profileName}" not found`); } - const count = ctx.positionals.count ? Number.parseInt(ctx.positionals.count, 10) : profile.numberOfMachines; + const count = ctx.interactive + ? await this.promptCount(profile.numberOfMachines) + : (ctx.positionals.count ? Number.parseInt(ctx.positionals.count, 10) : profile.numberOfMachines); + if (!count || count < 1) { throw new Exit("Runner count must be at least 1"); } @@ -77,4 +84,17 @@ export class CreateCommand extends BaseCommand { log.info(`${color.green("Created")} ${toCreate} runner(s). Total: ${entries.length}. Run ${color.green("hydra start")} to start them.`); } + + private async promptCount(defaultCount: number): Promise { + const value = await text({ + initialValue: String(defaultCount), + message: "Number of runners to create", + validate: (v): string | undefined => { + const n = Number.parseInt(v ?? "", 10); + if (Number.isNaN(n) || n < 1) return "Must be a positive number"; + return undefined; + }, + }); + return Number.parseInt(value, 10); + } } diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts index 5b7938b..f8b4f2d 100644 --- a/packages/hydra/src/commands/init.ts +++ b/packages/hydra/src/commands/init.ts @@ -27,7 +27,10 @@ export class InitCommand extends BaseCommand { prompts = true; async execute(ctx: InitCtx) { - const profileName = ctx.args.profile ?? DEFAULT_PROFILE; + const profileName = ctx.interactive + ? await this.promptProfileName(ctx) + : (ctx.args.profile ?? DEFAULT_PROFILE); + const isNewInit = !ctx.config.exists(); const profileExists = !isNewInit && ctx.config.get("profiles")[profileName]; @@ -66,6 +69,13 @@ export class InitCommand extends BaseCommand { ); } + private async promptProfileName(ctx: InitCtx): Promise { + return text({ + initialValue: ctx.args.profile ?? DEFAULT_PROFILE, + message: "Profile name", + }); + } + private async ensureDirectories() { if (!existsSync(SHARED_DIR)) { await mkdir(SHARED_DIR, { recursive: true }); diff --git a/packages/hydra/src/commands/list.ts b/packages/hydra/src/commands/list.ts index 9f9ddcd..e8ddf6d 100644 --- a/packages/hydra/src/commands/list.ts +++ b/packages/hydra/src/commands/list.ts @@ -1,13 +1,10 @@ -import { Exit, args, color, log } from "@r5n/cli-core"; +import { Exit, color, log, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; -import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; +import type { RunnerInfo } from "../providers"; +import type { Profile } from "../types"; -const listArgs = args({ - profile: { alias: "p", description: "Profile name to use", type: "string" }, -}); - -type ListCtx = Ctx; +type ListCtx = Ctx; const STATUS_COLORS: Record string> = { registered: color.yellow, @@ -19,20 +16,47 @@ const STATUS_COLORS: Record string> = { export class ListCommand extends BaseCommand { name = "list"; description = "List runners and their status"; - args = listArgs; async execute(ctx: ListCtx) { - const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; - const profile = ctx.config.get("profiles")[profileName]; - if (!profile) { - throw new Exit(`Profile "${profileName}" not found`); + const profiles = ctx.config.get("profiles"); + const profileNames = Object.keys(profiles); + + if (profileNames.length === 0) { + throw new Exit("No profiles configured", "Run hydra init to set up a profile"); + } + + const entries = ctx.config.get("runners") ?? []; + const firstProfile = profiles[profileNames[0] as string] as Profile; + const s = spinner(); + + s.start("Fetching runner status..."); + const provider = createProvider(firstProfile); + const allStatuses = await provider.list(); + s.stop("Runner status fetched"); + + const statusMap = new Map(allStatuses.map((r) => [r.id, r])); + let totalRunners = 0; + let totalRunning = 0; + + for (const name of profileNames) { + const profile = profiles[name] as Profile; + log.info(`${color.bold(name)} ${color.dim(profile.url)}`); + const profileRunnerIds = entries.filter((e) => e.profile === name).map((e) => e.id); + const runners = profileRunnerIds + .map((id) => statusMap.get(id)) + .filter((r): r is RunnerInfo => r !== undefined); + + this.printRunners(runners); + totalRunners += runners.length; + totalRunning += runners.filter((r) => r.status === "running").length; } - const provider = createProvider(profile); - const runners = await provider.list(); + log.info(color.dim(` ${totalRunners} runner(s) total, ${totalRunning} running`)); + } + private printRunners(runners: RunnerInfo[]) { if (runners.length === 0) { - log.info(color.dim("No runners found.")); + log.info(color.dim(" No runners.")); return; } @@ -41,8 +65,5 @@ export class ListCommand extends BaseCommand { const pid = runner.pid ? color.dim(` (pid: ${runner.pid})`) : ""; log.info(` ${statusColor("●")} ${runner.name} ${statusColor(runner.status)}${pid}`); } - - const running = runners.filter((r) => r.status === "running").length; - log.info(color.dim(` ${runners.length} runner(s), ${running} running`)); } } diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts index d888668..2f68bc7 100644 --- a/packages/hydra/src/commands/remove.ts +++ b/packages/hydra/src/commands/remove.ts @@ -1,10 +1,10 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; -import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { Exit, args, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; -import { resolveRunnerIds } from "../utils"; +import { resolveRunnerIds, selectProfile } from "../utils"; const removePositionals = positionals({ ids: { description: "Runner IDs to remove, or 'all'", variadic: true }, @@ -23,7 +23,10 @@ export class RemoveCommand extends BaseCommand { args = removeArgs; async execute(ctx: RemoveCtx) { - const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profileName = ctx.interactive + ? await selectProfile(ctx.config) + : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + const profile = ctx.config.get("profiles")[profileName]; if (!profile) { throw new Exit(`Profile "${profileName}" not found`); @@ -32,10 +35,13 @@ export class RemoveCommand extends BaseCommand { const entries = ctx.config.get("runners") ?? []; const profileEntries = entries.filter((e) => e.profile === profileName); if (profileEntries.length === 0) { - throw new Exit(`No runners found for profile "${profileName}"`, "Use --profile to specify a different profile"); + throw new Exit(`No runners found for profile "${profileName}"`, "Run hydra create to provision runners"); } - const ids = resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + const ids = ctx.interactive + ? await this.promptRunnerSelection(profileEntries.map((e) => e.id)) + : resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + const provider = createProvider(profile); const s = spinner(); @@ -51,4 +57,12 @@ export class RemoveCommand extends BaseCommand { log.info(`${color.green("Removed")} ${ids.length} runner(s).`); } + + private async promptRunnerSelection(ids: string[]): Promise { + return multiselect({ + message: "Select runners to remove", + options: ids.map((id) => ({ label: id, value: id })), + required: true, + }); + } } diff --git a/packages/hydra/src/commands/start.ts b/packages/hydra/src/commands/start.ts index e3a47f2..5ecf489 100644 --- a/packages/hydra/src/commands/start.ts +++ b/packages/hydra/src/commands/start.ts @@ -1,8 +1,8 @@ -import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { Exit, args, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; -import { resolveRunnerIds } from "../utils"; +import { resolveRunnerIds, selectProfile } from "../utils"; const startPositionals = positionals({ ids: { description: "Runner IDs to start, or 'all'", variadic: true }, @@ -21,7 +21,10 @@ export class StartCommand extends BaseCommand { args = startArgs; async execute(ctx: StartCtx) { - const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profileName = ctx.interactive + ? await selectProfile(ctx.config) + : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + const profile = ctx.config.get("profiles")[profileName]; if (!profile) { throw new Exit(`Profile "${profileName}" not found`); @@ -29,12 +32,24 @@ export class StartCommand extends BaseCommand { const entries = ctx.config.get("runners") ?? []; const profileEntries = entries.filter((e) => e.profile === profileName); - const ids = resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + + if (profileEntries.length === 0) { + throw new Exit(`No runners found for profile "${profileName}"`, "Run hydra create to provision runners"); + } const provider = createProvider(profile); const s = spinner(); + + s.start("Fetching runner status..."); const statuses = await provider.list(); + s.stop("Runner status fetched"); + const runningIds = new Set(statuses.filter((r) => r.status === "running").map((r) => r.id)); + const stoppedIds = profileEntries.map((e) => e.id).filter((id) => !runningIds.has(id)); + + const ids = ctx.interactive + ? await this.promptRunnerSelection(stoppedIds) + : resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); let started = 0; for (const id of ids) { @@ -52,4 +67,16 @@ export class StartCommand extends BaseCommand { log.info(`${color.green("Started")} ${started} runner(s).`); } + + private async promptRunnerSelection(ids: string[]): Promise { + if (ids.length === 0) { + throw new Exit("All runners are already running"); + } + + return multiselect({ + message: "Select runners to start", + options: ids.map((id) => ({ label: id, value: id })), + required: true, + }); + } } diff --git a/packages/hydra/src/commands/stop.ts b/packages/hydra/src/commands/stop.ts index 2cbd10f..89daf6c 100644 --- a/packages/hydra/src/commands/stop.ts +++ b/packages/hydra/src/commands/stop.ts @@ -1,8 +1,8 @@ -import { Exit, args, color, log, positionals, spinner } from "@r5n/cli-core"; +import { Exit, args, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; -import { resolveRunnerIds } from "../utils"; +import { resolveRunnerIds, selectProfile } from "../utils"; const stopPositionals = positionals({ ids: { description: "Runner IDs to stop, or 'all'", variadic: true }, @@ -21,7 +21,10 @@ export class StopCommand extends BaseCommand { args = stopArgs; async execute(ctx: StopCtx) { - const profileName = ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profileName = ctx.interactive + ? await selectProfile(ctx.config) + : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + const profile = ctx.config.get("profiles")[profileName]; if (!profile) { throw new Exit(`Profile "${profileName}" not found`); @@ -29,12 +32,24 @@ export class StopCommand extends BaseCommand { const entries = ctx.config.get("runners") ?? []; const profileEntries = entries.filter((e) => e.profile === profileName); - const ids = resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + + if (profileEntries.length === 0) { + throw new Exit(`No runners found for profile "${profileName}"`, "Run hydra create to provision runners"); + } const provider = createProvider(profile); const s = spinner(); + + s.start("Fetching runner status..."); const statuses = await provider.list(); + s.stop("Runner status fetched"); + const runningIds = new Set(statuses.filter((r) => r.status === "running").map((r) => r.id)); + const activeIds = profileEntries.map((e) => e.id).filter((id) => runningIds.has(id)); + + const ids = ctx.interactive + ? await this.promptRunnerSelection(activeIds) + : resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); let stopped = 0; for (const id of ids) { @@ -52,4 +67,16 @@ export class StopCommand extends BaseCommand { log.info(`${color.green("Stopped")} ${stopped} runner(s).`); } + + private async promptRunnerSelection(ids: string[]): Promise { + if (ids.length === 0) { + throw new Exit("No runners are currently running"); + } + + return multiselect({ + message: "Select runners to stop", + options: ids.map((id) => ({ label: id, value: id })), + required: true, + }); + } } diff --git a/packages/hydra/src/utils.ts b/packages/hydra/src/utils.ts index 26e995b..b7d1b9d 100644 --- a/packages/hydra/src/utils.ts +++ b/packages/hydra/src/utils.ts @@ -1,4 +1,6 @@ -import { Exit } from "@r5n/cli-core"; +import { Exit, select } from "@r5n/cli-core"; +import type { ConfigManager } from "@r5n/cli-core"; +import type { HydraConfig } from "./types"; export function resolveRunnerIds(input: string[], available: string[]): string[] { if (input.length === 0) { @@ -17,3 +19,23 @@ export function resolveRunnerIds(input: string[], available: string[]): string[] return input; } + +export async function selectProfile(config: ConfigManager): Promise { + const profiles = config.get("profiles"); + const names = Object.keys(profiles); + + if (names.length === 0) { + throw new Exit("No profiles configured", "Run hydra init to set up a profile"); + } + + if (names.length === 1) return names[0] as string; + + return select({ + message: "Select profile", + options: names.map((name) => ({ + label: name, + value: name, + hint: profiles[name]?.url, + })), + }); +} From 9ef908148b4b4c06c2a760ffdfdb93062412e34c Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 00:45:39 +0200 Subject: [PATCH 14/22] refactor(hydra): use positional profile argument instead of --profile flag --- packages/hydra/src/commands/create.ts | 12 ++++-------- packages/hydra/src/commands/remove.ts | 16 ++++++---------- packages/hydra/src/commands/start.ts | 16 ++++++---------- packages/hydra/src/commands/stop.ts | 16 ++++++---------- packages/hydra/src/utils.ts | 8 +------- 5 files changed, 23 insertions(+), 45 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index 04f57a0..0134e42 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { Exit, args, color, log, positionals, spinner, text } from "@r5n/cli-core"; +import { Exit, color, log, positionals, spinner, text } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; @@ -7,25 +7,21 @@ import type { RunnerEntry } from "../types"; import { selectProfile } from "../utils"; const createPositionals = positionals({ + profile: { description: "Profile name" }, count: { description: "Number of runners to create (overrides profile)" }, }); -const createArgs = args({ - profile: { alias: "p", description: "Profile name to use", type: "string" }, -}); - -type CreateCtx = Ctx; +type CreateCtx = Ctx, typeof createPositionals>; export class CreateCommand extends BaseCommand { name = "create"; description = "Create and register runners"; positionals = createPositionals; - args = createArgs; async execute(ctx: CreateCtx) { const profileName = ctx.interactive ? await selectProfile(ctx.config) - : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); const profiles = ctx.config.get("profiles"); const profile = profiles[profileName]; diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts index 2f68bc7..73b95fb 100644 --- a/packages/hydra/src/commands/remove.ts +++ b/packages/hydra/src/commands/remove.ts @@ -1,31 +1,27 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; -import { Exit, args, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; +import { Exit, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; import { resolveRunnerIds, selectProfile } from "../utils"; const removePositionals = positionals({ - ids: { description: "Runner IDs to remove, or 'all'", variadic: true }, + profile: { description: "Profile name" }, + ids: { description: "Runner IDs (omit to target all)", variadic: true }, }); -const removeArgs = args({ - profile: { alias: "p", description: "Profile name to use", type: "string" }, -}); - -type RemoveCtx = Ctx; +type RemoveCtx = Ctx, typeof removePositionals>; export class RemoveCommand extends BaseCommand { name = "remove"; description = "Deregister and remove runners"; positionals = removePositionals; - args = removeArgs; async execute(ctx: RemoveCtx) { const profileName = ctx.interactive ? await selectProfile(ctx.config) - : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); const profile = ctx.config.get("profiles")[profileName]; if (!profile) { @@ -40,7 +36,7 @@ export class RemoveCommand extends BaseCommand { const ids = ctx.interactive ? await this.promptRunnerSelection(profileEntries.map((e) => e.id)) - : resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + : resolveRunnerIds(ctx.positionals.ids ?? [], profileEntries.map((e) => e.id)); const provider = createProvider(profile); const s = spinner(); diff --git a/packages/hydra/src/commands/start.ts b/packages/hydra/src/commands/start.ts index 5ecf489..b500d4e 100644 --- a/packages/hydra/src/commands/start.ts +++ b/packages/hydra/src/commands/start.ts @@ -1,29 +1,25 @@ -import { Exit, args, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; +import { Exit, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; import { resolveRunnerIds, selectProfile } from "../utils"; const startPositionals = positionals({ - ids: { description: "Runner IDs to start, or 'all'", variadic: true }, + profile: { description: "Profile name" }, + ids: { description: "Runner IDs (omit to target all)", variadic: true }, }); -const startArgs = args({ - profile: { alias: "p", description: "Profile name to use", type: "string" }, -}); - -type StartCtx = Ctx; +type StartCtx = Ctx, typeof startPositionals>; export class StartCommand extends BaseCommand { name = "start"; description = "Start runners"; positionals = startPositionals; - args = startArgs; async execute(ctx: StartCtx) { const profileName = ctx.interactive ? await selectProfile(ctx.config) - : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); const profile = ctx.config.get("profiles")[profileName]; if (!profile) { @@ -49,7 +45,7 @@ export class StartCommand extends BaseCommand { const ids = ctx.interactive ? await this.promptRunnerSelection(stoppedIds) - : resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + : resolveRunnerIds(ctx.positionals.ids ?? [], profileEntries.map((e) => e.id)); let started = 0; for (const id of ids) { diff --git a/packages/hydra/src/commands/stop.ts b/packages/hydra/src/commands/stop.ts index 89daf6c..a4f0cf9 100644 --- a/packages/hydra/src/commands/stop.ts +++ b/packages/hydra/src/commands/stop.ts @@ -1,29 +1,25 @@ -import { Exit, args, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; +import { Exit, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; import { resolveRunnerIds, selectProfile } from "../utils"; const stopPositionals = positionals({ - ids: { description: "Runner IDs to stop, or 'all'", variadic: true }, + profile: { description: "Profile name" }, + ids: { description: "Runner IDs (omit to target all)", variadic: true }, }); -const stopArgs = args({ - profile: { alias: "p", description: "Profile name to use", type: "string" }, -}); - -type StopCtx = Ctx; +type StopCtx = Ctx, typeof stopPositionals>; export class StopCommand extends BaseCommand { name = "stop"; description = "Stop runners"; positionals = stopPositionals; - args = stopArgs; async execute(ctx: StopCtx) { const profileName = ctx.interactive ? await selectProfile(ctx.config) - : (ctx.args.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); + : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); const profile = ctx.config.get("profiles")[profileName]; if (!profile) { @@ -49,7 +45,7 @@ export class StopCommand extends BaseCommand { const ids = ctx.interactive ? await this.promptRunnerSelection(activeIds) - : resolveRunnerIds(ctx.positionals.ids, profileEntries.map((e) => e.id)); + : resolveRunnerIds(ctx.positionals.ids ?? [], profileEntries.map((e) => e.id)); let stopped = 0; for (const id of ids) { diff --git a/packages/hydra/src/utils.ts b/packages/hydra/src/utils.ts index b7d1b9d..346e667 100644 --- a/packages/hydra/src/utils.ts +++ b/packages/hydra/src/utils.ts @@ -3,13 +3,7 @@ import type { ConfigManager } from "@r5n/cli-core"; import type { HydraConfig } from "./types"; export function resolveRunnerIds(input: string[], available: string[]): string[] { - if (input.length === 0) { - throw new Exit("Specify runner IDs or 'all'", "Example: hydra all"); - } - - if (input.length === 1 && input[0] === "all") { - return available; - } + if (input.length === 0) return available; for (const id of input) { if (!available.includes(id)) { From 579c7b06e3b1c995b63f10cce2b05f89d7508da6 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 01:23:10 +0200 Subject: [PATCH 15/22] fix(hydra): correct CLI hints to use positional profile syntax --- packages/hydra/src/commands/create.ts | 3 ++- packages/hydra/src/commands/init.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index 0134e42..de6384d 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -78,7 +78,8 @@ export class CreateCommand extends BaseCommand { ctx.config.set("runners", entries); - log.info(`${color.green("Created")} ${toCreate} runner(s). Total: ${entries.length}. Run ${color.green("hydra start")} to start them.`); + const profileSuffix = profileName !== DEFAULT_PROFILE ? ` ${profileName}` : ""; + log.info(`${color.green("Created")} ${toCreate} runner(s) for "${profileName}". Run ${color.green(`hydra start${profileSuffix}`)} to start them.`); } private async promptCount(defaultCount: number): Promise { diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts index f8b4f2d..7ea3bef 100644 --- a/packages/hydra/src/commands/init.ts +++ b/packages/hydra/src/commands/init.ts @@ -61,7 +61,7 @@ export class InitCommand extends BaseCommand { `${color.dim("Name:")} ${profile.name}`, profile.labels ? `${color.dim("Labels:")} ${profile.labels}` : "", "", - `Run ${color.green(`${CLI_BIN} create`)}${profileName !== DEFAULT_PROFILE ? color.green(` --profile ${profileName}`) : ""} to provision runners.`, + `Run ${color.green(`${CLI_BIN} create${profileName !== DEFAULT_PROFILE ? ` ${profileName}` : ""}`)} to provision runners.`, ] .filter(Boolean) .join("\n"), From aa0e8d84e4473dcf7b97746378843e4b8aec7fdc Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 01:58:53 +0200 Subject: [PATCH 16/22] refactor(hydra): improve list output with per-profile providers, sorted runners, and compact formatting --- packages/hydra/src/commands/list.ts | 41 +++++++++++++++-------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/hydra/src/commands/list.ts b/packages/hydra/src/commands/list.ts index e8ddf6d..5059b2e 100644 --- a/packages/hydra/src/commands/list.ts +++ b/packages/hydra/src/commands/list.ts @@ -1,4 +1,4 @@ -import { Exit, color, log, spinner } from "@r5n/cli-core"; +import { Exit, color, log } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; import { createProvider } from "../providers"; import type { RunnerInfo } from "../providers"; @@ -26,44 +26,45 @@ export class ListCommand extends BaseCommand { } const entries = ctx.config.get("runners") ?? []; - const firstProfile = profiles[profileNames[0] as string] as Profile; - const s = spinner(); - - s.start("Fetching runner status..."); - const provider = createProvider(firstProfile); - const allStatuses = await provider.list(); - s.stop("Runner status fetched"); - - const statusMap = new Map(allStatuses.map((r) => [r.id, r])); let totalRunners = 0; let totalRunning = 0; + const lines: string[] = []; + for (const name of profileNames) { const profile = profiles[name] as Profile; - log.info(`${color.bold(name)} ${color.dim(profile.url)}`); - const profileRunnerIds = entries.filter((e) => e.profile === name).map((e) => e.id); - const runners = profileRunnerIds - .map((id) => statusMap.get(id)) - .filter((r): r is RunnerInfo => r !== undefined); + const provider = createProvider(profile); + const statuses = await provider.list(); + + if (lines.length > 0) lines.push(""); + + lines.push(`${color.bold(name)} ${color.dim(profile.url)}`); + + const profileRunnerIds = new Set(entries.filter((e) => e.profile === name).map((e) => e.id)); + const runners = statuses + .filter((r) => profileRunnerIds.has(r.id)) + .sort((a, b) => a.name.localeCompare(b.name)); - this.printRunners(runners); + this.formatRunners(runners, lines); totalRunners += runners.length; totalRunning += runners.filter((r) => r.status === "running").length; } - log.info(color.dim(` ${totalRunners} runner(s) total, ${totalRunning} running`)); + lines.push(""); + lines.push(color.dim(`${totalRunners} runner(s) total, ${totalRunning} running`)); + log.info(lines.join("\n")); } - private printRunners(runners: RunnerInfo[]) { + private formatRunners(runners: RunnerInfo[], lines: string[]) { if (runners.length === 0) { - log.info(color.dim(" No runners.")); + lines.push(color.dim(" No runners.")); return; } for (const runner of runners) { const statusColor = STATUS_COLORS[runner.status] ?? color.dim; const pid = runner.pid ? color.dim(` (pid: ${runner.pid})`) : ""; - log.info(` ${statusColor("●")} ${runner.name} ${statusColor(runner.status)}${pid}`); + lines.push(` ${statusColor("●")} ${runner.name} ${statusColor(runner.status)}${pid}`); } } } From 8f2a30ff731841bfe5a09d065860ea0c47bbe4e8 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 01:59:56 +0200 Subject: [PATCH 17/22] feat(hydra): add `profile` command with `list` and `remove` subcommands --- packages/hydra/src/cli.ts | 3 +- packages/hydra/src/commands/index.ts | 1 + packages/hydra/src/commands/profile/index.ts | 12 +++ packages/hydra/src/commands/profile/list.ts | 51 ++++++++++++ packages/hydra/src/commands/profile/remove.ts | 77 +++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/hydra/src/commands/profile/index.ts create mode 100644 packages/hydra/src/commands/profile/list.ts create mode 100644 packages/hydra/src/commands/profile/remove.ts diff --git a/packages/hydra/src/cli.ts b/packages/hydra/src/cli.ts index 3f8c89f..c10847f 100644 --- a/packages/hydra/src/cli.ts +++ b/packages/hydra/src/cli.ts @@ -1,6 +1,6 @@ import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; import { version } from "../package.json"; -import { CreateCommand, InitCommand, ListCommand, RemoveCommand, StartCommand, StopCommand } from "./commands"; +import { CreateCommand, InitCommand, ListCommand, ProfileCommand, RemoveCommand, StartCommand, StopCommand } from "./commands"; import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants"; import type { HydraConfig } from "./types"; @@ -25,6 +25,7 @@ class HydraCLI extends AbstractCLI { new StopCommand(), new ListCommand(), new RemoveCommand(), + new ProfileCommand(), ]); } } diff --git a/packages/hydra/src/commands/index.ts b/packages/hydra/src/commands/index.ts index 77aa45d..23bcf50 100644 --- a/packages/hydra/src/commands/index.ts +++ b/packages/hydra/src/commands/index.ts @@ -1,6 +1,7 @@ export { CreateCommand } from "./create"; export { InitCommand } from "./init"; export { ListCommand } from "./list"; +export { ProfileCommand } from "./profile"; export { RemoveCommand } from "./remove"; export { StartCommand } from "./start"; export { StopCommand } from "./stop"; diff --git a/packages/hydra/src/commands/profile/index.ts b/packages/hydra/src/commands/profile/index.ts new file mode 100644 index 0000000..57c056f --- /dev/null +++ b/packages/hydra/src/commands/profile/index.ts @@ -0,0 +1,12 @@ +import { BaseCommand } from "../../base-command"; +import { ProfileListCommand } from "./list"; +import { ProfileRemoveCommand } from "./remove"; + +export class ProfileCommand extends BaseCommand { + name = "profile"; + description = "Manage profiles"; + + init() { + this.registerSubcommands([new ProfileListCommand(), new ProfileRemoveCommand()]); + } +} diff --git a/packages/hydra/src/commands/profile/list.ts b/packages/hydra/src/commands/profile/list.ts new file mode 100644 index 0000000..070d5f2 --- /dev/null +++ b/packages/hydra/src/commands/profile/list.ts @@ -0,0 +1,51 @@ +import { Exit, color, log, note } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; +import { createProvider } from "../../providers"; +import type { Profile } from "../../types"; + +type ListCtx = Ctx; + +export class ProfileListCommand extends BaseCommand { + name = "list"; + description = "List all profiles"; + + async execute(ctx: ListCtx) { + const profiles = ctx.config.get("profiles"); + const profileNames = Object.keys(profiles); + + if (profileNames.length === 0) { + throw new Exit("No profiles configured", "Run hydra init to set up a profile"); + } + + const entries = ctx.config.get("runners") ?? []; + const defaultProfile = ctx.config.get("defaultProfile"); + + for (const name of profileNames) { + const profile = profiles[name] as Profile; + const provider = createProvider(profile); + const statuses = await provider.list(); + + const profileRunners = entries.filter((e) => e.profile === name); + const runningIds = new Set(statuses.filter((r) => r.status === "running").map((r) => r.id)); + const running = profileRunners.filter((e) => runningIds.has(e.id)).length; + const isDefault = name === defaultProfile; + + const lines = [ + `${color.dim("URL:")} ${profile.url}`, + `${color.dim("Provider:")} ${profile.provider}`, + `${color.dim("OS:")} ${profile.os}`, + `${color.dim("Name:")} ${profile.name}`, + `${color.dim("Runners:")} ${profileRunners.length}/${profile.numberOfMachines} created, ${color.green(`${running} running`)}`, + ]; + + if (profile.labels) { + lines.push(`${color.dim("Labels:")} ${profile.labels}`); + } + + const title = isDefault ? `${name} ${color.dim("(default)")}` : name; + note(lines.join("\n"), title); + } + + log.info(color.dim(`${profileNames.length} profile(s)`)); + } +} diff --git a/packages/hydra/src/commands/profile/remove.ts b/packages/hydra/src/commands/profile/remove.ts new file mode 100644 index 0000000..d73f8e4 --- /dev/null +++ b/packages/hydra/src/commands/profile/remove.ts @@ -0,0 +1,77 @@ +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { Exit, color, confirm, log, positionals, spinner } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; +import { createProvider } from "../../providers"; +import { selectProfile } from "../../utils"; + +const removePositionals = positionals({ + name: { description: "Profile name to remove" }, +}); + +type RemoveCtx = Ctx, typeof removePositionals>; + +export class ProfileRemoveCommand extends BaseCommand { + name = "remove"; + description = "Remove a profile"; + positionals = removePositionals; + + async execute(ctx: RemoveCtx) { + const profileName = ctx.interactive + ? await selectProfile(ctx.config) + : ctx.positionals.name; + + if (!profileName) { + throw new Exit("Profile name is required", "Usage: hydra profile remove "); + } + + const profiles = ctx.config.get("profiles"); + if (!profiles[profileName]) { + throw new Exit(`Profile "${profileName}" not found`); + } + + const entries = ctx.config.get("runners") ?? []; + const profileRunners = entries.filter((e) => e.profile === profileName); + + if (profileRunners.length > 0) { + const proceed = await confirm({ + initialValue: true, + message: `This will stop and remove ${profileRunners.length} runner(s). Continue?`, + }); + if (!proceed) return; + + await this.removeRunners(ctx, profileName, profileRunners.map((e) => e.id)); + } + + const { [profileName]: _, ...remaining } = profiles; + ctx.config.set("profiles", remaining); + + const defaultProfile = ctx.config.get("defaultProfile"); + if (defaultProfile === profileName) { + const remainingNames = Object.keys(remaining); + ctx.config.set("defaultProfile", remainingNames[0]); + } + + log.info(`${color.green("Removed")} profile "${profileName}".`); + } + + private async removeRunners(ctx: RemoveCtx, profileName: string, ids: string[]) { + const profile = ctx.config.get("profiles")[profileName]; + if (!profile) return; + + const provider = createProvider(profile); + const s = spinner(); + + for (const id of ids) { + s.start(`Stopping and removing ${id}...`); + await provider.stop([id]); + await provider.remove([id]); + await rm(join(profile.directory, id), { force: true, recursive: true }); + s.stop(`${color.red("-")} ${id}`); + } + + const entries = ctx.config.get("runners") ?? []; + const remaining = entries.filter((e) => e.profile !== profileName); + ctx.config.set("runners", remaining); + } +} From 8ac0488e73797bac288f14c2b50aeb61056515bd Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 02:04:18 +0200 Subject: [PATCH 18/22] refactor(hydra): rename `list` command to `status` --- packages/hydra/src/cli.ts | 4 ++-- packages/hydra/src/commands/index.ts | 2 +- packages/hydra/src/commands/{list.ts => status.ts} | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/hydra/src/commands/{list.ts => status.ts} (94%) diff --git a/packages/hydra/src/cli.ts b/packages/hydra/src/cli.ts index c10847f..ce3f8ed 100644 --- a/packages/hydra/src/cli.ts +++ b/packages/hydra/src/cli.ts @@ -1,6 +1,6 @@ import { AbstractCLI, ConfigManager } from "@r5n/cli-core"; import { version } from "../package.json"; -import { CreateCommand, InitCommand, ListCommand, ProfileCommand, RemoveCommand, StartCommand, StopCommand } from "./commands"; +import { CreateCommand, InitCommand, ProfileCommand, RemoveCommand, StartCommand, StatusCommand, StopCommand } from "./commands"; import { CLI_BIN, DEFAULT_CONFIG, HYDRA_CONFIG_FILE } from "./constants"; import type { HydraConfig } from "./types"; @@ -23,7 +23,7 @@ class HydraCLI extends AbstractCLI { new CreateCommand(), new StartCommand(), new StopCommand(), - new ListCommand(), + new StatusCommand(), new RemoveCommand(), new ProfileCommand(), ]); diff --git a/packages/hydra/src/commands/index.ts b/packages/hydra/src/commands/index.ts index 23bcf50..9720220 100644 --- a/packages/hydra/src/commands/index.ts +++ b/packages/hydra/src/commands/index.ts @@ -1,6 +1,6 @@ export { CreateCommand } from "./create"; export { InitCommand } from "./init"; -export { ListCommand } from "./list"; +export { StatusCommand } from "./status"; export { ProfileCommand } from "./profile"; export { RemoveCommand } from "./remove"; export { StartCommand } from "./start"; diff --git a/packages/hydra/src/commands/list.ts b/packages/hydra/src/commands/status.ts similarity index 94% rename from packages/hydra/src/commands/list.ts rename to packages/hydra/src/commands/status.ts index 5059b2e..b428348 100644 --- a/packages/hydra/src/commands/list.ts +++ b/packages/hydra/src/commands/status.ts @@ -13,9 +13,9 @@ const STATUS_COLORS: Record string> = { unknown: color.dim, }; -export class ListCommand extends BaseCommand { - name = "list"; - description = "List runners and their status"; +export class StatusCommand extends BaseCommand { + name = "status"; + description = "Show runner status across all profiles"; async execute(ctx: ListCtx) { const profiles = ctx.config.get("profiles"); From 5c199fb1a5c2d00eba7c37e9054dc38026017eb2 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 02:08:03 +0200 Subject: [PATCH 19/22] fix(hydra): use natural sort order for runner names in status --- packages/hydra/src/commands/status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hydra/src/commands/status.ts b/packages/hydra/src/commands/status.ts index b428348..39f2b5e 100644 --- a/packages/hydra/src/commands/status.ts +++ b/packages/hydra/src/commands/status.ts @@ -43,7 +43,7 @@ export class StatusCommand extends BaseCommand { const profileRunnerIds = new Set(entries.filter((e) => e.profile === name).map((e) => e.id)); const runners = statuses .filter((r) => profileRunnerIds.has(r.id)) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); this.formatRunners(runners, lines); totalRunners += runners.length; From ed7c7d2f33c0f2fe01ae453ac4cf17495cacaf85 Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 02:14:57 +0200 Subject: [PATCH 20/22] fix(hydra): stop runner processes before removing them --- packages/hydra/src/commands/remove.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts index 73b95fb..3f6b4bc 100644 --- a/packages/hydra/src/commands/remove.ts +++ b/packages/hydra/src/commands/remove.ts @@ -42,7 +42,8 @@ export class RemoveCommand extends BaseCommand { const s = spinner(); for (const id of ids) { - s.start(`Removing ${id}...`); + s.start(`Stopping and removing ${id}...`); + await provider.stop([id]); await provider.remove([id]); await rm(join(profile.directory, id), { force: true, recursive: true }); s.stop(`${color.red("-")} ${id}`); From a397b2994b003acc688dc7cf509d8d8913b2b96b Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 02:40:10 +0200 Subject: [PATCH 21/22] refactor(hydra): unify profile resolution and use config-aware default profile --- packages/hydra/src/commands/create.ts | 18 ++++++----------- packages/hydra/src/commands/init.ts | 4 ++-- packages/hydra/src/commands/profile/remove.ts | 3 +++ packages/hydra/src/commands/remove.ts | 14 ++++--------- packages/hydra/src/commands/start.ts | 14 ++++--------- packages/hydra/src/commands/stop.ts | 14 ++++--------- packages/hydra/src/utils.ts | 20 ++++++++++++++++++- 7 files changed, 42 insertions(+), 45 deletions(-) diff --git a/packages/hydra/src/commands/create.ts b/packages/hydra/src/commands/create.ts index de6384d..f069bdd 100644 --- a/packages/hydra/src/commands/create.ts +++ b/packages/hydra/src/commands/create.ts @@ -1,10 +1,9 @@ import { join } from "node:path"; import { Exit, color, log, positionals, spinner, text } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; -import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; import type { RunnerEntry } from "../types"; -import { selectProfile } from "../utils"; +import { resolveProfile, selectProfile } from "../utils"; const createPositionals = positionals({ profile: { description: "Profile name" }, @@ -19,15 +18,9 @@ export class CreateCommand extends BaseCommand { positionals = createPositionals; async execute(ctx: CreateCtx) { - const profileName = ctx.interactive - ? await selectProfile(ctx.config) - : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); - - const profiles = ctx.config.get("profiles"); - const profile = profiles[profileName]; - if (!profile) { - throw new Exit(`Profile "${profileName}" not found`); - } + const { name: profileName, profile } = ctx.interactive + ? resolveProfile(ctx.config, await selectProfile(ctx.config)) + : resolveProfile(ctx.config, ctx.positionals.profile); const count = ctx.interactive ? await this.promptCount(profile.numberOfMachines) @@ -78,7 +71,8 @@ export class CreateCommand extends BaseCommand { ctx.config.set("runners", entries); - const profileSuffix = profileName !== DEFAULT_PROFILE ? ` ${profileName}` : ""; + const isDefault = profileName === ctx.config.get("defaultProfile"); + const profileSuffix = isDefault ? "" : ` ${profileName}`; log.info(`${color.green("Created")} ${toCreate} runner(s) for "${profileName}". Run ${color.green(`hydra start${profileSuffix}`)} to start them.`); } diff --git a/packages/hydra/src/commands/init.ts b/packages/hydra/src/commands/init.ts index 7ea3bef..8a31c3c 100644 --- a/packages/hydra/src/commands/init.ts +++ b/packages/hydra/src/commands/init.ts @@ -49,7 +49,7 @@ export class InitCommand extends BaseCommand { profiles[profileName] = profile; ctx.config.set("profiles", profiles); - if (isNewInit) { + if (!ctx.config.get("defaultProfile")) { ctx.config.set("defaultProfile", profileName); } @@ -61,7 +61,7 @@ export class InitCommand extends BaseCommand { `${color.dim("Name:")} ${profile.name}`, profile.labels ? `${color.dim("Labels:")} ${profile.labels}` : "", "", - `Run ${color.green(`${CLI_BIN} create${profileName !== DEFAULT_PROFILE ? ` ${profileName}` : ""}`)} to provision runners.`, + `Run ${color.green(`${CLI_BIN} create${profileName !== ctx.config.get("defaultProfile") ? ` ${profileName}` : ""}`)} to provision runners.`, ] .filter(Boolean) .join("\n"), diff --git a/packages/hydra/src/commands/profile/remove.ts b/packages/hydra/src/commands/profile/remove.ts index d73f8e4..ac1cdcc 100644 --- a/packages/hydra/src/commands/profile/remove.ts +++ b/packages/hydra/src/commands/profile/remove.ts @@ -26,6 +26,9 @@ export class ProfileRemoveCommand extends BaseCommand { } const profiles = ctx.config.get("profiles"); + if (Object.keys(profiles).length === 0) { + throw new Exit("No profiles configured", "Run hydra init to set up a profile"); + } if (!profiles[profileName]) { throw new Exit(`Profile "${profileName}" not found`); } diff --git a/packages/hydra/src/commands/remove.ts b/packages/hydra/src/commands/remove.ts index 3f6b4bc..6191e9e 100644 --- a/packages/hydra/src/commands/remove.ts +++ b/packages/hydra/src/commands/remove.ts @@ -2,9 +2,8 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; import { Exit, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; -import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; -import { resolveRunnerIds, selectProfile } from "../utils"; +import { resolveProfile, resolveRunnerIds, selectProfile } from "../utils"; const removePositionals = positionals({ profile: { description: "Profile name" }, @@ -19,14 +18,9 @@ export class RemoveCommand extends BaseCommand { positionals = removePositionals; async execute(ctx: RemoveCtx) { - const profileName = ctx.interactive - ? await selectProfile(ctx.config) - : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); - - const profile = ctx.config.get("profiles")[profileName]; - if (!profile) { - throw new Exit(`Profile "${profileName}" not found`); - } + const { name: profileName, profile } = ctx.interactive + ? resolveProfile(ctx.config, await selectProfile(ctx.config)) + : resolveProfile(ctx.config, ctx.positionals.profile); const entries = ctx.config.get("runners") ?? []; const profileEntries = entries.filter((e) => e.profile === profileName); diff --git a/packages/hydra/src/commands/start.ts b/packages/hydra/src/commands/start.ts index b500d4e..5453754 100644 --- a/packages/hydra/src/commands/start.ts +++ b/packages/hydra/src/commands/start.ts @@ -1,8 +1,7 @@ import { Exit, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; -import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; -import { resolveRunnerIds, selectProfile } from "../utils"; +import { resolveProfile, resolveRunnerIds, selectProfile } from "../utils"; const startPositionals = positionals({ profile: { description: "Profile name" }, @@ -17,14 +16,9 @@ export class StartCommand extends BaseCommand { positionals = startPositionals; async execute(ctx: StartCtx) { - const profileName = ctx.interactive - ? await selectProfile(ctx.config) - : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); - - const profile = ctx.config.get("profiles")[profileName]; - if (!profile) { - throw new Exit(`Profile "${profileName}" not found`); - } + const { name: profileName, profile } = ctx.interactive + ? resolveProfile(ctx.config, await selectProfile(ctx.config)) + : resolveProfile(ctx.config, ctx.positionals.profile); const entries = ctx.config.get("runners") ?? []; const profileEntries = entries.filter((e) => e.profile === profileName); diff --git a/packages/hydra/src/commands/stop.ts b/packages/hydra/src/commands/stop.ts index a4f0cf9..472ea5a 100644 --- a/packages/hydra/src/commands/stop.ts +++ b/packages/hydra/src/commands/stop.ts @@ -1,8 +1,7 @@ import { Exit, color, log, multiselect, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../base-command"; -import { DEFAULT_PROFILE } from "../constants"; import { createProvider } from "../providers"; -import { resolveRunnerIds, selectProfile } from "../utils"; +import { resolveProfile, resolveRunnerIds, selectProfile } from "../utils"; const stopPositionals = positionals({ profile: { description: "Profile name" }, @@ -17,14 +16,9 @@ export class StopCommand extends BaseCommand { positionals = stopPositionals; async execute(ctx: StopCtx) { - const profileName = ctx.interactive - ? await selectProfile(ctx.config) - : (ctx.positionals.profile ?? ctx.config.get("defaultProfile") ?? DEFAULT_PROFILE); - - const profile = ctx.config.get("profiles")[profileName]; - if (!profile) { - throw new Exit(`Profile "${profileName}" not found`); - } + const { name: profileName, profile } = ctx.interactive + ? resolveProfile(ctx.config, await selectProfile(ctx.config)) + : resolveProfile(ctx.config, ctx.positionals.profile); const entries = ctx.config.get("runners") ?? []; const profileEntries = entries.filter((e) => e.profile === profileName); diff --git a/packages/hydra/src/utils.ts b/packages/hydra/src/utils.ts index 346e667..800b897 100644 --- a/packages/hydra/src/utils.ts +++ b/packages/hydra/src/utils.ts @@ -1,6 +1,7 @@ import { Exit, select } from "@r5n/cli-core"; import type { ConfigManager } from "@r5n/cli-core"; -import type { HydraConfig } from "./types"; +import { DEFAULT_PROFILE } from "./constants"; +import type { HydraConfig, Profile } from "./types"; export function resolveRunnerIds(input: string[], available: string[]): string[] { if (input.length === 0) return available; @@ -14,6 +15,23 @@ export function resolveRunnerIds(input: string[], available: string[]): string[] return input; } +export function resolveProfile(config: ConfigManager, positionalProfile?: string): { name: string; profile: Profile } { + const profiles = config.get("profiles"); + + if (Object.keys(profiles).length === 0) { + throw new Exit("No profiles configured", "Run hydra init to set up a profile"); + } + + const name = positionalProfile ?? config.get("defaultProfile") ?? DEFAULT_PROFILE; + const profile = profiles[name]; + + if (!profile) { + throw new Exit(`Profile "${name}" not found`); + } + + return { name, profile }; +} + export async function selectProfile(config: ConfigManager): Promise { const profiles = config.get("profiles"); const names = Object.keys(profiles); From 7787df4c4672d5b5f9303f46e4f4f1020fef5f4f Mon Sep 17 00:00:00 2001 From: Dawid Harat Date: Thu, 2 Apr 2026 02:58:24 +0200 Subject: [PATCH 22/22] feat(hydra): add `profile default` subcommand to set the default profile --- .../hydra/src/commands/profile/default.ts | 36 +++++++++++++++++++ packages/hydra/src/commands/profile/index.ts | 3 +- packages/hydra/src/commands/profile/remove.ts | 26 +++++--------- 3 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 packages/hydra/src/commands/profile/default.ts diff --git a/packages/hydra/src/commands/profile/default.ts b/packages/hydra/src/commands/profile/default.ts new file mode 100644 index 0000000..d8e403e --- /dev/null +++ b/packages/hydra/src/commands/profile/default.ts @@ -0,0 +1,36 @@ +import { Exit, color, log, positionals } from "@r5n/cli-core"; +import { BaseCommand, type Ctx } from "../../base-command"; +import { resolveProfile, selectProfile } from "../../utils"; + +const defaultPositionals = positionals({ + name: { description: "Profile name to set as default" }, +}); + +type DefaultCtx = Ctx, typeof defaultPositionals>; + +export class ProfileDefaultCommand extends BaseCommand { + name = "default"; + description = "Set the default profile"; + positionals = defaultPositionals; + + async execute(ctx: DefaultCtx) { + const profileName = ctx.interactive + ? await selectProfile(ctx.config) + : ctx.positionals.name; + + if (!profileName) { + throw new Exit("Profile name is required", "Usage: hydra profile default "); + } + + const { name } = resolveProfile(ctx.config, profileName); + + const current = ctx.config.get("defaultProfile"); + if (current === name) { + log.info(`"${name}" is already the default profile.`); + return; + } + + ctx.config.set("defaultProfile", name); + log.info(`${color.green("Default profile set to")} "${name}".`); + } +} diff --git a/packages/hydra/src/commands/profile/index.ts b/packages/hydra/src/commands/profile/index.ts index 57c056f..3608ab9 100644 --- a/packages/hydra/src/commands/profile/index.ts +++ b/packages/hydra/src/commands/profile/index.ts @@ -1,4 +1,5 @@ import { BaseCommand } from "../../base-command"; +import { ProfileDefaultCommand } from "./default"; import { ProfileListCommand } from "./list"; import { ProfileRemoveCommand } from "./remove"; @@ -7,6 +8,6 @@ export class ProfileCommand extends BaseCommand { description = "Manage profiles"; init() { - this.registerSubcommands([new ProfileListCommand(), new ProfileRemoveCommand()]); + this.registerSubcommands([new ProfileListCommand(), new ProfileRemoveCommand(), new ProfileDefaultCommand()]); } } diff --git a/packages/hydra/src/commands/profile/remove.ts b/packages/hydra/src/commands/profile/remove.ts index ac1cdcc..8126f38 100644 --- a/packages/hydra/src/commands/profile/remove.ts +++ b/packages/hydra/src/commands/profile/remove.ts @@ -3,7 +3,8 @@ import { join } from "node:path"; import { Exit, color, confirm, log, positionals, spinner } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../../base-command"; import { createProvider } from "../../providers"; -import { selectProfile } from "../../utils"; +import type { Profile } from "../../types"; +import { resolveProfile, selectProfile } from "../../utils"; const removePositionals = positionals({ name: { description: "Profile name to remove" }, @@ -25,13 +26,7 @@ export class ProfileRemoveCommand extends BaseCommand { throw new Exit("Profile name is required", "Usage: hydra profile remove "); } - const profiles = ctx.config.get("profiles"); - if (Object.keys(profiles).length === 0) { - throw new Exit("No profiles configured", "Run hydra init to set up a profile"); - } - if (!profiles[profileName]) { - throw new Exit(`Profile "${profileName}" not found`); - } + const { profile } = resolveProfile(ctx.config, profileName); const entries = ctx.config.get("runners") ?? []; const profileRunners = entries.filter((e) => e.profile === profileName); @@ -43,10 +38,12 @@ export class ProfileRemoveCommand extends BaseCommand { }); if (!proceed) return; - await this.removeRunners(ctx, profileName, profileRunners.map((e) => e.id)); + await this.removeRunners(profile, profileRunners.map((e) => e.id)); + const remainingEntries = entries.filter((e) => e.profile !== profileName); + ctx.config.set("runners", remainingEntries); } - const { [profileName]: _, ...remaining } = profiles; + const { [profileName]: _, ...remaining } = ctx.config.get("profiles"); ctx.config.set("profiles", remaining); const defaultProfile = ctx.config.get("defaultProfile"); @@ -58,10 +55,7 @@ export class ProfileRemoveCommand extends BaseCommand { log.info(`${color.green("Removed")} profile "${profileName}".`); } - private async removeRunners(ctx: RemoveCtx, profileName: string, ids: string[]) { - const profile = ctx.config.get("profiles")[profileName]; - if (!profile) return; - + private async removeRunners(profile: Profile, ids: string[]) { const provider = createProvider(profile); const s = spinner(); @@ -72,9 +66,5 @@ export class ProfileRemoveCommand extends BaseCommand { await rm(join(profile.directory, id), { force: true, recursive: true }); s.stop(`${color.red("-")} ${id}`); } - - const entries = ctx.config.get("runners") ?? []; - const remaining = entries.filter((e) => e.profile !== profileName); - ctx.config.set("runners", remaining); } }