Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5570048
feat(hydra): scaffold CLI with init command and config types
deralaxo Mar 31, 2026
66815d9
feat(hydra): add GitHub runner provider with hard-link and symlink disk
deralaxo Mar 31, 2026
3c14254
feat(hydra): add `create` command
deralaxo Mar 31, 2026
73e9749
fix(hydra): ensure create targets total count and skip existing runners
deralaxo Mar 31, 2026
abda1dd
refactor(hydra): unify provider interface to use id arrays consistently
deralaxo Mar 31, 2026
c8cf6d2
feat(hydra): add `remove` command with profile-scoped runner
deralaxo Mar 31, 2026
b8baa42
fix(hydra): fix process lifecycle — unref spawned runners and pkill
deralaxo Mar 31, 2026
dd461da
feat(hydra): add `list`, `start`, `stop` commands with status-aware
deralaxo Mar 31, 2026
ec5237e
feat(hydra): support named profiles via --profile flag in init
deralaxo Mar 31, 2026
f119356
chore(hydra): remove dead code
deralaxo Mar 31, 2026
a713afc
fix(hydra): resolve type errors and ensure build passes
deralaxo Mar 31, 2026
fb0e4a2
fix(core): catch Exit errors in interactive mode and return to menu
deralaxo Apr 1, 2026
4a3f42d
feat(hydra): add interactive prompts for start, stop, remove and always
deralaxo Apr 1, 2026
9ef9081
refactor(hydra): use positional profile argument instead of --profile
deralaxo Apr 1, 2026
579c7b0
fix(hydra): correct CLI hints to use positional profile syntax
deralaxo Apr 1, 2026
aa0e8d8
refactor(hydra): improve list output with per-profile providers, sorted
deralaxo Apr 1, 2026
8f2a30f
feat(hydra): add `profile` command with `list` and `remove` subcommands
deralaxo Apr 1, 2026
8ac0488
refactor(hydra): rename `list` command to `status`
deralaxo Apr 2, 2026
5c199fb
fix(hydra): use natural sort order for runner names in status
deralaxo Apr 2, 2026
ed7c7d2
fix(hydra): stop runner processes before removing them
deralaxo Apr 2, 2026
a397b29
refactor(hydra): unify profile resolution and use config-aware default
deralaxo Apr 2, 2026
7787df4
feat(hydra): add `profile default` subcommand to set the default profile
deralaxo Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/command-router.ts
Original file line number Diff line number Diff line change
@@ -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__";

Expand Down Expand Up @@ -144,6 +145,11 @@ export class CommandRouter<TConfig extends object = object> {
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;
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/hydra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
9 changes: 9 additions & 0 deletions packages/hydra/src/base-command.ts
Original file line number Diff line number Diff line change
@@ -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<HydraConfig> {}

export type Ctx<
TArgDefs extends Record<string, ArgDefinition> = Record<string, never>,
TPositionalDefs extends Record<string, PositionalDefinition> = Record<string, never>,
> = BaseCtx<HydraConfig, TArgDefs, TPositionalDefs>;
36 changes: 35 additions & 1 deletion packages/hydra/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1 +1,35 @@
console.log("hello");
import { AbstractCLI, ConfigManager } from "@r5n/cli-core";
import { version } from "../package.json";
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";

class HydraCLI extends AbstractCLI {
constructor() {
super(new ConfigManager<HydraConfig>(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(),
new CreateCommand(),
new StartCommand(),
new StopCommand(),
new StatusCommand(),
new RemoveCommand(),
new ProfileCommand(),
]);
}
}

const cli = new HydraCLI();

void cli.run();
91 changes: 91 additions & 0 deletions packages/hydra/src/commands/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { join } from "node:path";
import { Exit, color, log, positionals, spinner, text } from "@r5n/cli-core";
import { BaseCommand, type Ctx } from "../base-command";
import { createProvider } from "../providers";
import type { RunnerEntry } from "../types";
import { resolveProfile, selectProfile } from "../utils";

const createPositionals = positionals({
profile: { description: "Profile name" },
count: { description: "Number of runners to create (overrides profile)" },
});

type CreateCtx = Ctx<Record<string, never>, typeof createPositionals>;

export class CreateCommand extends BaseCommand {
name = "create";
description = "Create and register runners";
positionals = createPositionals;

async execute(ctx: CreateCtx) {
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)
: (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") ?? [];
const existing = entries.filter((e) => e.profile === profileName);
const toCreate = count - existing.length;

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 id = `${profile.name}-${nextIndex++}`;
if (existingIds.has(id)) continue;
created++;

s.start(`Registering ${id}...`);
await provider.create([id]);
s.stop(`${color.green("+")} ${id}`);

entries.push({
createdAt: new Date().toISOString(),
directory: join(profile.directory, id),
id,
name: id,
profile: profileName,
provider: profile.provider,
url: profile.url,
});
}

ctx.config.set("runners", entries);

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.`);
}

private async promptCount(defaultCount: number): Promise<number> {
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);
}
}
7 changes: 7 additions & 0 deletions packages/hydra/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { CreateCommand } from "./create";
export { InitCommand } from "./init";
export { StatusCommand } from "./status";
export { ProfileCommand } from "./profile";
export { RemoveCommand } from "./remove";
export { StartCommand } from "./start";
export { StopCommand } from "./stop";
186 changes: 186 additions & 0 deletions packages/hydra/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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, DEFAULT_PROFILE, 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" },
profile: { alias: "p", description: "Profile name", type: "string" },
runners: { alias: "c", description: "Number of runners to create", type: "number" },
});

type InitCtx = Ctx<typeof initArgs, typeof initPositionals>;

export class InitCommand extends BaseCommand {
name = "init";
description = "Initialize Hydra runner manager";
positionals = initPositionals;
args = initArgs;
prompts = true;

async execute(ctx: InitCtx) {
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];
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", profiles);
if (!ctx.config.get("defaultProfile")) {
ctx.config.set("defaultProfile", profileName);
}

note(
[
`${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} create${profileName !== ctx.config.get("defaultProfile") ? ` ${profileName}` : ""}`)} to provision runners.`,
]
.filter(Boolean)
.join("\n"),
color.green(isNewInit ? "Hydra initialized" : `Profile "${profileName}" added`),
);
}

private async promptProfileName(ctx: InitCtx): Promise<string> {
return text({
initialValue: ctx.args.profile ?? DEFAULT_PROFILE,
message: "Profile name",
});
}

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<Profile> {
const values = await group(
{
url: () =>
text({
message: "GitHub repository or organization URL",
placeholder: "https://github.com/owner/repo",
validate: (v): string | undefined => {
if (!v) return "URL is required";
try {
new URL(v);
} catch {
return "Must be a valid URL";
}
return undefined;
},
}),
name: () =>
text({
initialValue: "runner",
message: "Base name for runners",
placeholder: "runner",
}),
runners: () =>
text({
initialValue: "1",
message: "Number of runners",
placeholder: "1",
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: () =>
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 <url>");
}

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;
}
}
Loading
Loading