@r5n/hydra 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -116
  2. package/dist/cli.js +1 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,138 +1,55 @@
1
- # Hydra
2
-
3
- Spawn and manage local self-hosted GitHub Actions runners.
4
-
5
- [![npm version](https://img.shields.io/npm/v/@r5n/hydra.svg)](https://www.npmjs.com/package/@r5n/hydra) [![License](https://img.shields.io/npm/l/@r5n/hydra.svg)](./LICENSE)
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/r5n-labs/clis/develop/packages/hydra/assets/logo.svg" width="128" alt="Hydra logo — three serpent heads">
3
+ </p>
6
4
 
7
- Hydra registers runners against a GitHub repository or organization and runs them as background processes on the machine it runs on. All state lives in a `.hydra/` directory relative to your current working directory, so each directory you run it from is its own isolated runner fleet.
5
+ # Hydra
8
6
 
9
- ## Requirements
7
+ **One machine. Many runners.**
10
8
 
11
- - Bun (the published CLI runs on Bun)
12
- - `gh` CLI, installed and authenticated (`gh auth login`) — Hydra mints runner registration and removal tokens via `gh api`; it never asks for or stores a token itself. Repository runners need admin access to the repository; organization runners need the `admin:org` scope.
13
- - `bash`, `curl`, `tar`
14
- - macOS (Apple Silicon) or Linux (x64). A `win-x64` runner download mapping exists in the code, but registration and startup shell out to `bash config.sh` / `bash run.sh`, so Windows is not actually supported.
9
+ Create and manage local self-hosted GitHub Actions runners for a repository or organisation. Hydra downloads each runner version once, shares its binaries across the fleet and registers runners through your authenticated `gh` CLI.
15
10
 
16
- ## Install
11
+ [![npm version](https://img.shields.io/npm/v/@r5n/hydra.svg)](https://www.npmjs.com/package/@r5n/hydra) [![License](https://img.shields.io/npm/l/@r5n/hydra.svg)](./LICENSE)
17
12
 
18
- ```bash
19
- bun add -g @r5n/hydra
20
- ```
13
+ Each working directory has its own fleet, with profiles and runner state in `.hydra/`.
21
14
 
22
15
  ## Quick start
23
16
 
24
- ```bash
25
- mkdir ~/runners && cd ~/runners
17
+ ```sh
18
+ bun add -g @r5n/hydra
19
+ mkdir ~/runners
20
+ cd ~/runners
26
21
  hydra init https://github.com/owner/repo -c 2
27
22
  hydra create
28
23
  hydra start
29
24
  hydra status
30
25
  ```
31
26
 
32
- Bare `hydra` prints help. `hydra -i` opens an interactive menu that walks through the same commands with prompts. `hydra help <command>` or `hydra <command> --help` shows per-command usage.
33
-
34
- ## Commands
35
-
36
- ### init
37
-
38
- `hydra init [url]` writes a profile to `.hydra/config.json`. The first profile becomes the default. Run with no arguments it opens an interactive form; when any positional or piped input is involved the URL is required.
39
-
40
- - `-p, --profile` profile name (default: `default`)
41
- - `-n, --name` base name for runners (default: `runner`)
42
- - `-c, --runners` number of runners (default: 1)
43
- - `-l, --labels` comma-separated extra labels
44
- - `-f, --force` overwrite an existing profile
45
-
46
- ### create
47
-
48
- `hydra create [profile] [count]` downloads the latest `actions/runner` release (once per version, shared across runners), then registers runners named `<name>-1`, `<name>-2`, … It tops up to the target count: if the profile already has enough runners recorded, it does nothing. `count` overrides the profile's `numberOfMachines`.
49
-
50
- ### start / stop
51
-
52
- `hydra start [profile] [ids...]` launches each runner's `run.sh` as a detached background process and records its pid. `hydra stop [profile] [ids...]` kills them. Omit the ids to target every runner in the profile; omit the profile to use the default. Runners do not survive a reboot — run `hydra start` again.
53
-
54
- ### status
55
-
56
- `hydra status` lists every profile's runners with their state (`running` with pid, `registered`, or `unknown`). State is determined locally from pid liveness and the runner's `.runner` file; no API calls.
57
-
58
- ### logs
27
+ `hydra -i` opens the interactive menu. Bare `hydra` prints help; `hydra <command> --help` shows that command's options.
59
28
 
60
- `hydra logs [id]` tails the newest job log (`Worker_*.log`) from the runner's `_diag` directory. If no job has run yet it falls back to the runner daemon log with a warning. The id can be omitted when there is exactly one runner.
29
+ ## Everyday commands
61
30
 
62
- - `-n, --lines` lines to tail (default: 100)
63
- - `-r, --runner` show the runner daemon log (`Runner_*.log`) instead of the job log
64
- - `-l, --list` list available log files with timestamps and sizes
65
- - `-o, --open` open the log in `$EDITOR` (falls back to `open` on macOS)
31
+ | Task | Command |
32
+ | --- | --- |
33
+ | Configure a repository or organisation | `hydra init [url]` |
34
+ | Create runners up to the target count | `hydra create [profile] [count]` |
35
+ | Start or stop runners | `hydra start [profile] [ids...]` / `hydra stop [profile] [ids...]` |
36
+ | Inspect local runner state | `hydra status` |
37
+ | Tail the latest job log | `hydra logs [id]` |
38
+ | Preview disk cleanup | `hydra cleanup --dry-run` |
39
+ | Update shared runner binaries | `hydra update` |
40
+ | Deregister and remove runners | `hydra remove [profile] [ids...]` |
41
+ | Manage fleet profiles | `hydra profile` |
66
42
 
67
- ### cleanup
43
+ Runners are background processes; start them again after a reboot. Cleanup preserves job workspaces by default, and automatic runner updates are disabled in favour of `hydra update`.
68
44
 
69
- `hydra cleanup` frees disk space. By default it prunes `_diag` log files older than 7 days (always keeping the newest runner and worker log per runner) and removes shared runner versions no runner's `externals` link points to (the newest installed version is always kept). `_work` holds job checkouts and caches, so it is never cleaned by default — pass `-w` explicitly. Runners that are currently running are skipped for `_work` cleanup.
45
+ See the [command and configuration reference](https://github.com/r5n-labs/clis/blob/develop/packages/hydra/docs/reference.md) for flags, profiles, cleanup policy and storage details.
70
46
 
71
- - `-l, --logs` prune old `_diag` log files
72
- - `-w, --work` delete `_work` contents of stopped runners
73
- - `-s, --shared` remove unused shared runner versions
74
- - `-d, --days` age threshold in days for log pruning (default: 7, or `cleanup.olderThanDays`)
75
- - `-n, --dry-run` list what would be deleted with sizes, delete nothing
76
- - `-y, --yes` skip confirmation prompts
77
-
78
- Passing any of `-l`/`-w`/`-s` cleans only those targets; with none, the targets come from the `cleanup` config section (default: logs and shared).
79
-
80
- ### update
81
-
82
- `hydra update` checks the latest `actions/runner` release, and if it differs from the installed version, swaps the binaries for every runner. Runners that were running are stopped, updated, and restarted.
83
-
84
- ### remove
85
-
86
- `hydra remove [profile] [ids...]` stops the runners, deregisters them from GitHub, and deletes their directories.
87
-
88
- ### profile
89
-
90
- - `hydra profile list` — profiles with URL, OS, and created/running counts
91
- - `hydra profile default <name>` — set the default profile
92
- - `hydra profile remove <name> [--yes]` — remove a profile; if it has runners, stops and deregisters them first. Pass `--yes` to skip confirmation in scripts.
93
-
94
- ## Configuration
95
-
96
- `.hydra/config.json`, resolved relative to the working directory. Hydra writes it; the `profiles` section is safe to edit by hand:
97
-
98
- ```json
99
- {
100
- "defaultProfile": "default",
101
- "profiles": {
102
- "default": {
103
- "url": "https://github.com/owner/repo",
104
- "name": "runner",
105
- "numberOfMachines": 2,
106
- "labels": "macOS,ARM64",
107
- "directory": ".hydra/runners",
108
- "os": "osx",
109
- "provider": "github",
110
- "overwrite": false,
111
- "run": false
112
- }
113
- },
114
- "runners": []
115
- }
116
- ```
117
-
118
- `os` is one of `osx` | `linux` | `windows` (auto-detected by `init`). An optional `runnerGroup` string is passed through to `config.sh --runnergroup`; runner groups only apply to organization runners. The `runners` array is Hydra's record of what it created — leave it alone.
119
-
120
- An optional `cleanup` section controls `hydra cleanup` and automatic cleanup:
121
-
122
- ```json
123
- "cleanup": { "auto": true, "intervalHours": 24, "olderThanDays": 7, "targets": ["logs", "shared"] }
124
- ```
125
-
126
- All fields are optional; the values above (with `auto: false`) are the defaults. With `auto` enabled, `hydra start` and `hydra update` run a cleanup afterwards whenever the last run is more than `intervalHours` ago. Automatic runs never prompt and only touch the configured `targets` — `work` only if you list it explicitly. Hydra records `lastRun` itself; leave it alone.
127
-
128
- The `url` is either a repository URL (`https://github.com/owner/repo`) or an organization URL (`https://github.com/org`). Repository URLs mint tokens from the repository endpoint (requires repo admin); organization URLs use the organization endpoint (requires the `admin:org` scope).
129
-
130
- ## How it works
131
-
132
- Runner binaries are downloaded once per version into `.hydra/shared/github/<version>`. Each runner directory under `.hydra/runners/<id>` gets hardlinks for `bin`, a symlink for `externals`, and its own copies of the shell scripts, so ten runners cost roughly one copy of the runner distribution on disk. Start/stop is plain process management: a detached `bash run.sh` plus a pid file per runner.
47
+ ## Requirements
133
48
 
134
- Hydra disables the runner's automatic updater when registering it; use `hydra update` to update the shared binaries. Failed downloads are discarded, and completed registrations and removals are saved as each runner finishes, so a later failure in the batch can be retried. Failed deregistrations preserve the runner's files and record.
49
+ - Bun, `bash`, `curl` and `tar`.
50
+ - Authenticated `gh` (`gh auth login`). Repository runners require repository admin access; organisation runners require the `admin:org` scope. Hydra obtains registration and removal tokens through `gh api` without storing them.
51
+ - macOS on Apple Silicon or Linux on x64. Windows is not supported: registration and startup use the runner's Bash scripts.
135
52
 
136
- ## License
53
+ ## Licence
137
54
 
138
- Apache-2.0 — see [LICENSE](./LICENSE)
55
+ [Apache 2.0](LICENSE).
package/dist/cli.js CHANGED
@@ -91,7 +91,7 @@ Run "${this.commandPath} --help" for usage.`),process.exit(1);let o=this.command
91
91
  `))});return`${p?"[":"{"}${_}${O.join(D)}${x}${p?"]":"}"}`}function l(u,m,f){if(!f)return Je(u,o);let p=t.lastIndexOf(`
92
92
  `,m-1)+1,g=t.slice(p,m).match(/^[\t ]*/)?.[0]??"";return JSON.stringify(u,null,i).replaceAll(`
93
93
  `,s+g)}return t.slice(0,n.start)+a(n,r)+t.slice(n.end)}function Je(t,e){if(Array.isArray(t))return`[${t.map((i)=>Je(i,e)).join(`,${e}`)}]`;if(t===null||typeof t!=="object")return JSON.stringify(t);let r=Object.entries(t);if(r.length===0)return"{}";let n=r.map(([i,s])=>`${JSON.stringify(i)}:${e}${Je(s,e)}`);return`{${e}${n.join(`,${e}`)}${e}}`}function Yi(t){let e=t.matchAll(/"(?:[^"\\]|\\.)*"|[^\s{}[\],:]+|[{}[\],:]/g),r=e.next().value;function n(){if(!r)throw Error("Unexpected end of JSON");let s=r;return r=e.next().value,s}function i(){let s=n(),o=[],a=s[0]==="{";if(!a&&s[0]!=="[")return{end:s.index+s[0].length,entries:o,start:s.index,value:JSON.parse(s[0])};let l=a?"}":"]";while(r&&r[0]!==l){let m=r.index,f=a?JSON.parse(n()[0]):String(o.length);if(a)n();if(o.push({key:f,node:i(),start:m}),r?.[0]===",")n()}let u=n().index+1;return{end:u,entries:o,start:s.index,value:JSON.parse(t.slice(s.index,u))}}return i()}var Xi=2,Zi=en(Object);class ze{config;configPath;defaultConfig;loadError;constructor(t,e){this.configPath=rn.resolve(t),this.defaultConfig=e,this.config=this.loadConfig()}get path(){return this.configPath}get(t){return this.config[t]}set(t,e){this.config={...this.config,[t]:e},this.save()}delete(t){delete this.config[t],this.save()}exists(){return z.existsSync(this.configPath)}getAll(){return structuredClone(this.config)}save(t){if(this.loadError)throw Error(`Cannot overwrite unreadable config at ${this.configPath}; repair the file and try again`,{cause:this.loadError});if(t)this.config={...t};this.ensureDirectoryExists();let e=this.exists()?z.realpathSync(this.configPath):this.configPath,r=`${e}.${crypto.randomUUID()}.tmp`;try{let n=this.exists()?z.statSync(this.configPath).mode:void 0,i=this.exists()?qe(z.readFileSync(this.configPath,"utf-8"),this.config):`${JSON.stringify(this.config,null,Xi)}
94
- `;if(z.writeFileSync(r,i,{flag:"wx",mode:n}),n!==void 0)z.chmodSync(r,n);z.renameSync(r,e)}catch(n){throw Error(`Failed to save config to ${this.configPath}`,{cause:n})}finally{z.rmSync(r,{force:!0})}}ensureDirectoryExists(){let t=rn.dirname(this.configPath);if(!z.existsSync(t))try{z.mkdirSync(t,{recursive:!0})}catch(e){throw Error(`Failed to create config directory at ${t}`,{cause:e})}}loadConfig(){if(!z.existsSync(this.configPath))return structuredClone(this.defaultConfig);try{let t=z.readFileSync(this.configPath,"utf-8"),e=JSON.parse(t);if(Array.isArray(e))throw Error("Config must be an object");let r=Zi(e);return te(structuredClone(this.defaultConfig),r)}catch(t){return this.loadError=t,console.warn(`Config file corrupted at ${this.configPath}. Using defaults.`),structuredClone(this.defaultConfig)}}}var nn="0.10.0";class T extends Tt{}var ne="hydra";var sn=".hydra/config.json",tt=".hydra/shared",Dt=".hydra/runners",Ot="default",on={profiles:{}},an=!1,ln=24,ie=7,un=["logs","shared"];import{spawn as as}from"child_process";import{once as ls}from"events";import{existsSync as Ct}from"fs";import{cp as us,link as cs,mkdir as ae,mkdtemp as ps,readdir as fn,readFile as Ze,readlink as ds,rename as ms,rm as ft,stat as hs,symlink as fs,unlink as gn,writeFile as gs}from"fs/promises";import{join as P,resolve as Qe}from"path";var ts=new Set(["github.com","www.github.com"]);var cn=/\.git$/;function mt(t){let e;try{e=new URL(t)}catch{throw Error(`Invalid GitHub URL: ${t}`)}if(e.protocol!=="https:"&&e.protocol!=="http:")throw Error(`Invalid GitHub URL (expected http(s)): ${t}`);if(!ts.has(e.hostname))throw Error(`Invalid GitHub URL (host must be github.com): ${t}`);let r=e.pathname.split("/").filter(Boolean);if(r.length===1){let n=r[0]?.replace(cn,"");if(!n)throw Error(`Invalid GitHub URL: ${t}`);return{kind:"org",org:n}}if(r.length===2){let n=r[0],i=r[1]?.replace(cn,"");if(!n||!i)throw Error(`Invalid GitHub URL: ${t}`);return{kind:"repo",owner:n,repo:i}}throw Error(`Invalid GitHub URL (expected github.com/<owner>/<repo> or github.com/<org>): ${t}`)}function se(t){return t.kind==="repo"?`${t.owner}/${t.repo}`:t.org}import{existsSync as es}from"fs";import{readdir as rs,stat as ns}from"fs/promises";import{join as is}from"path";var kt="_diag",pn={runner:/^Runner_.+\.log$/,worker:/^Worker_.+\.log$/},dn=1024,mn=["B","KB","MB","GB"],ss=1;function hn(t){if(pn.worker.test(t))return"worker";if(pn.runner.test(t))return"runner";return null}function oe(t){return[...t].sort((e,r)=>r.mtime.getTime()-e.mtime.getTime())}async function Lt(t){if(!es(t))return[];let e=await rs(t,{withFileTypes:!0}),r=[];for(let n of e){if(!n.isFile())continue;let i=hn(n.name);if(!i)continue;let s=is(t,n.name),o=await ns(s);r.push({mtime:o.mtime,name:n.name,path:s,size:o.size,type:i})}return oe(r)}function Ye(t,e){let r=t.find((i)=>i.type===e);if(r)return{fallback:!1,file:r};let n=t[0];return n?{fallback:!0,file:n}:null}function Xe(t,e){if(e<=0)return"";let r=t.endsWith(`
94
+ `;if(z.writeFileSync(r,i,{flag:"wx",mode:n}),n!==void 0)z.chmodSync(r,n);z.renameSync(r,e)}catch(n){throw Error(`Failed to save config to ${this.configPath}`,{cause:n})}finally{z.rmSync(r,{force:!0})}}ensureDirectoryExists(){let t=rn.dirname(this.configPath);if(!z.existsSync(t))try{z.mkdirSync(t,{recursive:!0})}catch(e){throw Error(`Failed to create config directory at ${t}`,{cause:e})}}loadConfig(){if(!z.existsSync(this.configPath))return structuredClone(this.defaultConfig);try{let t=z.readFileSync(this.configPath,"utf-8"),e=JSON.parse(t);if(Array.isArray(e))throw Error("Config must be an object");let r=Zi(e);return te(structuredClone(this.defaultConfig),r)}catch(t){return this.loadError=t,console.warn(`Config file corrupted at ${this.configPath}. Using defaults.`),structuredClone(this.defaultConfig)}}}var nn="0.11.0";class T extends Tt{}var ne="hydra";var sn=".hydra/config.json",tt=".hydra/shared",Dt=".hydra/runners",Ot="default",on={profiles:{}},an=!1,ln=24,ie=7,un=["logs","shared"];import{spawn as as}from"child_process";import{once as ls}from"events";import{existsSync as Ct}from"fs";import{cp as us,link as cs,mkdir as ae,mkdtemp as ps,readdir as fn,readFile as Ze,readlink as ds,rename as ms,rm as ft,stat as hs,symlink as fs,unlink as gn,writeFile as gs}from"fs/promises";import{join as P,resolve as Qe}from"path";var ts=new Set(["github.com","www.github.com"]);var cn=/\.git$/;function mt(t){let e;try{e=new URL(t)}catch{throw Error(`Invalid GitHub URL: ${t}`)}if(e.protocol!=="https:"&&e.protocol!=="http:")throw Error(`Invalid GitHub URL (expected http(s)): ${t}`);if(!ts.has(e.hostname))throw Error(`Invalid GitHub URL (host must be github.com): ${t}`);let r=e.pathname.split("/").filter(Boolean);if(r.length===1){let n=r[0]?.replace(cn,"");if(!n)throw Error(`Invalid GitHub URL: ${t}`);return{kind:"org",org:n}}if(r.length===2){let n=r[0],i=r[1]?.replace(cn,"");if(!n||!i)throw Error(`Invalid GitHub URL: ${t}`);return{kind:"repo",owner:n,repo:i}}throw Error(`Invalid GitHub URL (expected github.com/<owner>/<repo> or github.com/<org>): ${t}`)}function se(t){return t.kind==="repo"?`${t.owner}/${t.repo}`:t.org}import{existsSync as es}from"fs";import{readdir as rs,stat as ns}from"fs/promises";import{join as is}from"path";var kt="_diag",pn={runner:/^Runner_.+\.log$/,worker:/^Worker_.+\.log$/},dn=1024,mn=["B","KB","MB","GB"],ss=1;function hn(t){if(pn.worker.test(t))return"worker";if(pn.runner.test(t))return"runner";return null}function oe(t){return[...t].sort((e,r)=>r.mtime.getTime()-e.mtime.getTime())}async function Lt(t){if(!es(t))return[];let e=await rs(t,{withFileTypes:!0}),r=[];for(let n of e){if(!n.isFile())continue;let i=hn(n.name);if(!i)continue;let s=is(t,n.name),o=await ns(s);r.push({mtime:o.mtime,name:n.name,path:s,size:o.size,type:i})}return oe(r)}function Ye(t,e){let r=t.find((i)=>i.type===e);if(r)return{fallback:!1,file:r};let n=t[0];return n?{fallback:!0,file:n}:null}function Xe(t,e){if(e<=0)return"";let r=t.endsWith(`
95
95
  `)?t.slice(0,-1):t;if(r==="")return"";return r.split(`
96
96
  `).slice(-e).join(`
97
97
  `)}function ht(t){let e=t,r=0;while(e>=dn&&r<mn.length-1)e/=dn,r++;return`${r===0?String(e):e.toFixed(ss)} ${mn[r]??"B"}`}var os=/(?:^|\/)github\/([^/]+)\/externals$/;function Ft(t){return t.match(os)?.[1]??null}var yn="actions/runner",ys={linux:"linux-x64",osx:"osx-arm64",windows:"win-x64"},tr=["bin"],er=["externals"],$s=1,vs=5000,ws=50,bs=5000,Cs=50;class le{profile;sharedPath=null;constructor(t){this.profile=t}async download(){let t=ys[this.profile.os],e=await this.getLatestVersion(),r=P(tt,"github",e);if(await this.isCompleteDownload(r))return this.sharedPath=r,{path:r,version:e};let n=P(tt,"github");await ae(n,{recursive:!0});let i=await ps(P(tt,`.github-${e}-`)),s=`actions-runner-${t}-${e}.tar.gz`,o=P(i,s),a=`https://github.com/${yn}/releases/download/v${e}/${s}`;try{if(await Bun.$`curl --fail --silent --show-error --location -o ${o} ${a}`,await Bun.$`tar xzf ${o} -C ${i}`,await gn(o),!await this.isCompleteDownload(i))throw new v(`Runner v${e} archive is incomplete`);await this.publishDownload(i,r,e)}finally{await ft(i,{force:!0,recursive:!0})}return this.sharedPath=r,{path:r,version:e}}async create(t){let e=await this.ensureDownloaded(),r=[];for(let n of t){let i=P(this.profile.directory,n);if(Ct(i))throw new v(`Runner directory already exists: ${i}`,"Remove or recover this runner before creating it again");try{await this.setupRunnerDir(e,i),await this.registerRunner(i,n)}catch(s){if(!Ct(P(i,".runner")))await ft(i,{force:!0,recursive:!0});throw s}r.push({directory:i,id:n,name:n,status:"registered"})}return r}async remove(t){for(let e of t){let r=P(this.profile.directory,e);if(!Ct(P(r,".runner")))continue;let n=await this.fetchRemovalToken(r),i=await Bun.$`bash ./config.sh remove --token ${n}`.cwd(r).quiet().nothrow();if(i.exitCode!==0)throw new v(`Failed to deregister runner ${e}`,i.stderr.toString().trim()||"Runner files have been preserved; retry removal")}}async start(t){for(let e of t){let r=P(this.profile.directory,e),n=await this.readPidFile(r);if(n&&this.isProcessRunning(n))continue;let i=as("bash",["./run.sh"],{cwd:Qe(r),detached:!0,stdio:"ignore"});if(await ls(i,"spawn"),i.pid===void 0)throw new v(`Failed to start runner ${e}`);i.unref(),await this.writePidFile(r,i.pid)}}async stop(t){for(let e of t){let r=P(this.profile.directory,e),n=await this.readPidFile(r);if(n&&this.isProcessRunning(n)){try{process.kill(-n,"SIGTERM")}catch(s){if(s.code!=="ESRCH")throw s;try{process.kill(n,"SIGTERM")}catch(o){if(o.code!=="ESRCH")throw o}}let i=Date.now()+vs;while(this.isProcessRunning(n)){if(Date.now()>=i)throw new v(`Runner ${e} did not stop`,"Wait for its current job to finish and retry");await Bun.sleep(ws)}}await this.removePidFile(r)}}async list(){let t=this.profile.directory;if(!Ct(t))return[];let e=await fn(t,{withFileTypes:!0}),r=[];for(let n of e){if(!n.isDirectory())continue;let i=P(t,n.name);r.push(await this.getRunnerStatus(i,n.name))}return r}async currentVersion(t){let e=P(this.profile.directory,t);return this.detectVersion(e)}async logFiles(t){return Lt(P(this.profile.directory,t,kt))}async update(t){let e=await this.ensureDownloaded();for(let r of t){let n=P(this.profile.directory,r);await this.removeRunnerBinaries(n),await this.setupRunnerDir(e,n)}}async getRunnerStatus(t,e){let r=await this.readRunnerName(t),n=await this.readPidFile(t),i=n!==null&&this.isProcessRunning(n),s="unknown";if(i)s="running";else if(Ct(P(t,".runner")))s="registered";return{directory:t,id:e,name:r,pid:i?n:void 0,status:s}}async ensureDownloaded(){if(this.sharedPath)return this.sharedPath;let{path:t}=await this.download();return t}async isCompleteDownload(t){try{return(await Promise.all(["bin","externals","config.sh","run.sh"].map((r)=>hs(P(t,r))))).every((r,n)=>n<tr.length+er.length?r.isDirectory():r.isFile())}catch(e){if(e.code==="ENOENT")return!1;throw e}}async publishDownload(t,e,r){let n=P(tt,`.github-${r}.lock`),i=Date.now()+bs;while(!0)try{await ae(n);break}catch(s){if(s.code!=="EEXIST")throw s;if(Date.now()>=i)throw new v(`Runner download is locked: ${n}`,"Retry after other Hydra downloads finish; remove a stale lock only when no download is running");await Bun.sleep(Cs)}try{if(await this.isCompleteDownload(e))return;await ft(e,{force:!0,recursive:!0}),await ms(t,e)}finally{await ft(n,{force:!0,recursive:!0})}}async setupRunnerDir(t,e){await ae(e,{recursive:!0});for(let r of tr)await this.hardLinkDir(P(t,r),P(e,r));for(let r of er){let n=Qe(t,r),i=P(e,r);if(!Ct(i))await fs(n,i)}for(let r of["*.sh","*.sh.template"]){let n=await Array.fromAsync(new Bun.Glob(r).scan(t));for(let i of n)await us(P(t,i),P(e,i))}}async registerRunner(t,e){let r=await this.fetchRegistrationToken(),n=["--url",this.profile.url,"--token",r,"--name",e,"--unattended","--disableupdate"];if(this.profile.labels)n.push("--labels",this.profile.labels);if(this.profile.runnerGroup)n.push("--runnergroup",this.profile.runnerGroup);await Bun.$`bash ./config.sh ${n}`.cwd(t).quiet()}async fetchRegistrationToken(){return this.fetchRunnerToken(mt(this.profile.url),"registration-token")}async fetchRemovalToken(t){let e=await Ze(P(t,".runner"),"utf-8"),n=JSON.parse(this.stripBom(e)).gitHubUrl;if(!n)throw Error("Cannot determine GitHub URL from runner config");return this.fetchRunnerToken(mt(n),"remove-token")}async fetchRunnerToken(t,e){let r=t.kind==="repo"?`repos/${t.owner}/${t.repo}/actions/runners/${e}`:`orgs/${t.org}/actions/runners/${e}`,n=t.kind==="repo"?"gh needs admin access to the repository":"gh needs the admin:org scope",i=await Bun.$`gh api ${r} --method POST --jq .token`.quiet().nothrow();if(i.exitCode!==0){let o=i.stderr.toString().trim();throw Error(`gh api ${r} failed for ${se(t)} (${n})${o?`: ${o}`:""}`)}let s=i.stdout.toString().trim();if(!s)throw Error(`Failed to fetch ${e} for ${se(t)}`);return s}async getLatestVersion(){return(await Bun.$`gh api repos/${yn}/releases/latest --jq .tag_name`.quiet()).stdout.toString().trim().replace(/^v/,"")}pidFilePath(t){return Qe(t,".pid")}async writePidFile(t,e){await gs(this.pidFilePath(t),String(e))}async readPidFile(t){try{let e=await Ze(this.pidFilePath(t),"utf-8"),r=Number(e.trim());return Number.isSafeInteger(r)&&r>$s?r:null}catch{return null}}async removePidFile(t){try{await gn(this.pidFilePath(t))}catch{}}async readRunnerName(t){try{let e=await Ze(P(t,".runner"),"utf-8");return JSON.parse(this.stripBom(e)).agentName??"unknown"}catch{return"unknown"}}async hardLinkDir(t,e){await ae(e,{recursive:!0});let r=await fn(t,{withFileTypes:!0});for(let n of r){let i=P(t,n.name),s=P(e,n.name);if(n.isDirectory())await this.hardLinkDir(i,s);else await cs(i,s)}}async detectVersion(t){try{let e=await ds(P(t,"externals"));return Ft(e)}catch{return null}}async removeRunnerBinaries(t){for(let e of tr)await ft(P(t,e),{force:!0,recursive:!0});for(let e of er)await ft(P(t,e),{force:!0});for(let e of["*.sh","*.sh.template"]){let r=await Array.fromAsync(new Bun.Glob(e).scan(t));for(let n of r)await ft(P(t,n),{force:!0})}}stripBom(t){return t.replace(/^\uFEFF/,"")}isProcessRunning(t){try{return process.kill(t,0),!0}catch(e){return e.code==="EPERM"}}}import{existsSync as rr}from"fs";import{readdir as nr,readFile as xs,readlink as Ss,rm as ir,stat as Rs}from"fs/promises";import{join as lt}from"path";var vn="_work",Es="externals",Is=".pid",Ps="github",_s=["runner","worker"],As=24,wn=3600000,$n=10,Ts=1,Ds=re().map((t)=>Number.isSafeInteger(t)&&t>Ts?t:bt());function ue(t){return{auto:t?.auto??an,intervalHours:t?.intervalHours??ln,lastRun:t?.lastRun,olderThanDays:t?.olderThanDays??ie,targets:t?.targets??un}}function bn(t,e){return t-e*As*wn}function sr(t,e){if(!t.lastRun)return!0;let r=Date.parse(t.lastRun);if(Number.isNaN(r))return!0;return e-r>=t.intervalHours*wn}function Cn(t,e){let r=oe(t),n=new Set;for(let i of _s){let s=r.find((o)=>o.type===i);if(s)n.add(s.path)}return r.filter((i)=>!n.has(i.path)&&i.mtime.getTime()<e)}function xn(t){return[...t].sort(Ns)[0]??null}function Sn(t,e){let r=xn(t),n=new Set(e);return t.filter((i)=>i!==r&&!n.has(i))}function ce(t){return Object.values(t).reduce((e,r)=>e+r.freedBytes,0)}async function Rn(t){let e;try{e=await xs(lt(t,Is),"utf-8")}catch(n){if(n.code==="ENOENT")return!1;throw n}let r;try{r=Ds(Number(e.trim()))}catch{return!1}try{return process.kill(r,0),!0}catch(n){let i=n.code;if(i==="EPERM")return!0;if(i==="ESRCH")return!1;throw n}}async function pe(t){if(!rr(t))return 0;let e=await nr(t,{withFileTypes:!0}),r=0;for(let n of e){if(n.isSymbolicLink())continue;let i=lt(t,n.name);if(n.isDirectory()){r+=await pe(i);continue}if(n.isFile()){let s=await Rs(i);r+=s.size}}return r}async function de(t){let e=bn(t.now,t.olderThanDays),r={};for(let n of t.targets)switch(n){case"logs":r.logs=await Os(t.entries,e,t.dryRun);break;case"work":r.work=await ks(t.entries,t.dryRun);break;case"shared":r.shared=await Ls(t.entries,t.dryRun,t.sharedDir);break}return r}async function Os(t,e,r){let n={freedBytes:0,removed:0,skipped:[]};for(let i of t){let s=await Lt(lt(i.directory,kt));for(let o of Cn(s,e)){if(!r)await ir(o.path,{force:!0});n.removed++,n.freedBytes+=o.size}}return n}async function ks(t,e){let r={freedBytes:0,removed:0,skipped:[]};for(let n of t){if(await Rn(n.directory)){r.skipped.push(n.id);continue}let i=lt(n.directory,vn);if(!rr(i))continue;let s=await nr(i);if(s.length===0)continue;if(r.freedBytes+=await pe(i),r.removed+=s.length,e)continue;for(let o of s)await ir(lt(i,o),{force:!0,recursive:!0})}return r}async function Ls(t,e,r){let n={freedBytes:0,removed:0,skipped:[]},i=r??lt(tt,Ps);if(!rr(i))return n;let o=(await nr(i,{withFileTypes:!0})).filter((l)=>l.isDirectory()).map((l)=>l.name),a=await Fs(t);for(let l of Sn(o,a)){let u=lt(i,l);if(n.freedBytes+=await pe(u),n.removed++,!e)await ir(u,{force:!0,recursive:!0})}return n}async function Fs(t){let e=[];for(let r of t)try{let n=await Ss(lt(r.directory,Es)),i=Ft(n);if(i)e.push(i)}catch{}return e}function Ns(t,e){let r=t.split(".").map((s)=>Number.parseInt(s,$n)),n=e.split(".").map((s)=>Number.parseInt(s,$n)),i=Math.max(r.length,n.length);for(let s=0;s<i;s++){let o=r[s]??0,a=n[s]??0,l=Number.isNaN(o)?0:o,u=Number.isNaN(a)?0:a;if(l!==u)return u-l}return 0}function U(t){switch(t.provider){case"github":return new le(t);default:throw Error(`Runner provider "${t.provider}" is not yet implemented`)}}var In=0,Ms={logs:"file(s)",shared:"version(s)",work:"item(s)"},En=Q({days:{alias:"d",default:In,description:"Override log retention",displayDefault:`config or ${ie} days`,type:"number"},dryRun:{alias:"n",default:!1,description:"Preview cleanup without deleting",type:"boolean"},logs:{alias:"l",default:!1,description:"Remove old diagnostic logs",type:"boolean"},shared:{alias:"s",default:!1,description:"Remove unused runner versions",type:"boolean"},work:{alias:"w",default:!1,description:"Clear stopped runners' workspaces",type:"boolean"},yes:{alias:"y",default:!1,description:"Skip workspace confirmation",type:"boolean"}});class me extends T{name="cleanup";description="Remove logs, workspaces, and old runner versions";args=En;async execute(t){N(t.args,En,"Run 'hydra cleanup --help' for supported options");let e=t.config.get("profiles");if(Object.keys(e).length===0)throw new v("No profiles configured","Run hydra init to set up a profile");let r=ue(t.config.get("cleanup")),n=this.explicitTargets(t),i=n.length>0?n:r.targets,s=t.args.days>In?t.args.days:r.olderThanDays;if(i.includes("work")&&!t.args.dryRun&&!t.args.yes){if(!await ct({message:"Delete _work contents (checkouts and caches) of stopped runners?"}))i=i.filter((l)=>l!=="work")}let o=await de({dryRun:t.args.dryRun,entries:t.config.get("runners")??[],now:Date.now(),olderThanDays:s,targets:i});if(this.printReport(o,t.args.dryRun),t.args.dryRun)return;t.config.set("cleanup",{...t.config.get("cleanup"),lastRun:new Date().toISOString()})}explicitTargets(t){let e=[];if(t.args.logs)e.push("logs");if(t.args.work)e.push("work");if(t.args.shared)e.push("shared");return e}printReport(t,e){let r=[];for(let[s,o]of Object.entries(t)){if(!o)continue;let a=Ms[s],l=ht(o.freedBytes),u=e?`would remove ${o.removed} ${a}, ${l}`:`removed ${o.removed} ${a}, freed ${l}`;if(r.push(`${d.bold(s)}: ${o.removed>0?u:d.dim("nothing to do")}`),o.skipped.length>0)r.push(d.dim(` skipped running runner(s): ${o.skipped.join(", ")}`))}if(r.length===0){w.info(d.dim("Nothing to clean."));return}let n=ce(t),i=e?"Would free":"Freed";r.push(d.dim(`${i} ${ht(n)} total`)),w.info(r.join(`
package/package.json CHANGED
@@ -36,5 +36,5 @@
36
36
  },
37
37
  "type": "module",
38
38
  "types": "src/cli.ts",
39
- "version": "0.10.0"
39
+ "version": "0.11.0"
40
40
  }