gibil 0.1.13 → 0.2.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.
- package/README.md +88 -16
- package/dist/index.js +27 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,34 +7,36 @@
|
|
|
7
7
|
<p align="center"><strong>Full servers. Forged in seconds. Gone when done.</strong></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<img src="https://img.shields.io/badge/version-0.
|
|
11
|
-
<img src="https://img.shields.io/badge/tests-
|
|
10
|
+
<img src="https://img.shields.io/badge/version-0.2.0-blue" alt="Version 0.2.0" />
|
|
11
|
+
<img src="https://img.shields.io/badge/tests-209%20passing-brightgreen" alt="Tests: 209 passing" />
|
|
12
12
|
<img src="https://img.shields.io/badge/typescript-%3E5.0-3178C6" alt="TypeScript" />
|
|
13
13
|
<img src="https://img.shields.io/badge/node-%3E%3D20-339933" alt="Node >= 20" />
|
|
14
14
|
<img src="https://img.shields.io/badge/license-proprietary-red" alt="License: Proprietary" />
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
Don't switch branches. Spin up a server.<br/>
|
|
19
|
+
Run any branch on a clean Linux machine. SSH in if it breaks. Destroy when done.
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
24
|
## The Problem
|
|
25
25
|
|
|
26
|
-
You're
|
|
26
|
+
You're deep in a feature on `main`. Slack: "can you check why tests fail on `feat/payments`?" You stash, checkout, install deps, run tests, debug, switch back. 15 minutes gone. Flow destroyed.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Or: three people need to test three different branches on one laptop. One Docker daemon. Port conflicts. Everyone waits for CI instead.
|
|
29
|
+
|
|
30
|
+
**gibil gives every branch its own machine.** Your local stays on `main`. The branch runs on a real Linux server for $0.007/hr.
|
|
29
31
|
|
|
30
32
|
## 30-Second Demo
|
|
31
33
|
|
|
32
34
|
```bash
|
|
33
35
|
npm install -g gibil
|
|
34
|
-
gibil init
|
|
35
|
-
gibil
|
|
36
|
-
gibil
|
|
37
|
-
gibil destroy
|
|
36
|
+
gibil init # enter your Hetzner API token
|
|
37
|
+
gibil branch feat/payments --run "pnpm test" # spins up a server, checks out the branch, runs tests
|
|
38
|
+
gibil ssh feat-payments # SSH in to debug
|
|
39
|
+
gibil destroy feat-payments # gone, no trace
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
## Install
|
|
@@ -61,11 +63,14 @@ gibil destroy my-app
|
|
|
61
63
|
|
|
62
64
|
## Why Gibil?
|
|
63
65
|
|
|
64
|
-
- **
|
|
65
|
-
- **
|
|
66
|
+
- **Don't switch branches** — `gibil branch feat/X` gives the branch its own Linux machine. Your local stays on `main`.
|
|
67
|
+
- **Run your agent remotely** — `--agent claude` installs Claude Code on the server. Also supports `aider` and `codex`. Direct filesystem access, no MCP latency.
|
|
68
|
+
- **SSH when it breaks** — `gibil ssh <name>` drops you into a real terminal. Debug live, not from CI logs.
|
|
69
|
+
- **Preview a branch** — `--port 3000` tunnels the app to localhost. Open your browser, see the branch running live.
|
|
70
|
+
- **Parallel branches** — `gibil branch feat/A feat/B feat/C` boots three servers in parallel. Zero interference.
|
|
71
|
+
- **MCP built in** — `gibil mcp` gives Claude Code direct access to a remote server via MCP tools.
|
|
66
72
|
- **Ephemeral by design** — set a TTL, servers auto-destroy. No forgotten VMs, no surprise bills.
|
|
67
|
-
- **
|
|
68
|
-
- **Parallel agents** — forge 5 servers, run 5 agents, destroy them all. Scale your AI workflow, not your hardware.
|
|
73
|
+
- **$0.007/hr** — real Linux VMs on Hetzner. 24x cheaper than E2B. BYOC — your code stays on your account.
|
|
69
74
|
|
|
70
75
|
## Commands
|
|
71
76
|
|
|
@@ -86,9 +91,76 @@ gibil destroy my-app
|
|
|
86
91
|
| `gibil auth` | Manage authentication |
|
|
87
92
|
| `gibil usage` | View usage and plan limits |
|
|
88
93
|
|
|
89
|
-
##
|
|
94
|
+
## Two Ways to Use Agents
|
|
95
|
+
|
|
96
|
+
### Option A: Agent on your laptop (MCP)
|
|
97
|
+
|
|
98
|
+
Your agent runs locally and reaches into the server via MCP tools. Good for quick tasks where you want to watch the agent work.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
gibil create --name my-app --repo github.com/you/project
|
|
102
|
+
gibil mcp # Claude Code gets vm_bash, vm_read, vm_write tools
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Pros | Cons |
|
|
106
|
+
|------|------|
|
|
107
|
+
| See everything the agent does | Every file/command is a network round-trip |
|
|
108
|
+
| Agent uses your local config | Your laptop stays busy (fans, battery) |
|
|
109
|
+
| Works with any MCP-compatible agent | Long commands block with no streaming |
|
|
110
|
+
|
|
111
|
+
### Option B: Agent on the server (--agent)
|
|
112
|
+
|
|
113
|
+
The agent runs directly on the server. Your laptop is just a terminal. Good for heavy work, long sessions, or when you want your laptop free.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
gibil branch feat/payments --agent claude
|
|
117
|
+
gibil ssh feat-payments
|
|
118
|
+
|
|
119
|
+
# Set your API key in the session (stays in memory, never written to disk)
|
|
120
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
121
|
+
claude # direct filesystem access, full Docker, zero latency
|
|
122
|
+
|
|
123
|
+
# Also supports aider and codex:
|
|
124
|
+
gibil branch feat/payments --agent aider # then: export ANTHROPIC_API_KEY=...
|
|
125
|
+
gibil branch feat/payments --agent codex # then: export OPENAI_API_KEY=...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
| Pros | Cons |
|
|
129
|
+
|------|------|
|
|
130
|
+
| Direct filesystem — no MCP latency | Need to SSH in to interact |
|
|
131
|
+
| Full Docker, real Linux tools | Set API key manually after SSH |
|
|
132
|
+
| Your laptop is free | Need tmux to persist sessions |
|
|
133
|
+
| Works with claude, aider, codex | |
|
|
134
|
+
|
|
135
|
+
> **Security note:** Don't pass API keys via `--env` — they end up on disk in `/etc/environment`. Instead, SSH in and `export` the key. It stays in memory and vanishes when the server is destroyed.
|
|
136
|
+
|
|
137
|
+
**Rule of thumb:** Quick checks → MCP (Option A). Heavy work or "let it run" → remote agent (Option B).
|
|
138
|
+
|
|
139
|
+
## Preview a Branch
|
|
140
|
+
|
|
141
|
+
Run a dev server on a gibil server and open it in your browser:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
gibil branch feat/payments --run "pnpm dev" --port 3000
|
|
145
|
+
# → Server created, branch checked out, dev server started
|
|
146
|
+
# → Local: http://localhost:3000 ← open in your browser
|
|
147
|
+
# → Tunnel running in background
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Or with an interactive SSH session and port forwarding:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
gibil ssh feat-payments --port 3000 --port 5432
|
|
154
|
+
# → Forwarding localhost:3000 → feat-payments:3000
|
|
155
|
+
# → Forwarding localhost:5432 → feat-payments:5432
|
|
156
|
+
# → Tunnel active while SSH session is open
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Both approaches tunnel traffic through SSH — encrypted, no public ports exposed, works behind any firewall.
|
|
160
|
+
|
|
161
|
+
## Agent Workflow (JSON)
|
|
90
162
|
|
|
91
|
-
Every command supports `--json
|
|
163
|
+
Every command supports `--json` for programmatic use:
|
|
92
164
|
|
|
93
165
|
```bash
|
|
94
166
|
gibil create --name task --repo https://github.com/user/repo --json --ttl 30
|
package/dist/index.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
3
|
-
${
|
|
4
|
-
${
|
|
5
|
-
${
|
|
6
|
-
${
|
|
7
|
-
${
|
|
8
|
-
${
|
|
9
|
-
`,
|
|
2
|
+
var Xt=Object.defineProperty;var Ke=(t,e)=>()=>(t&&(e=t(t=0)),e);var rt=(t,e)=>{for(var n in e)Xt(t,n,{get:e[n],enumerable:!0})};import{homedir as nn}from"os";import{join as B,resolve as on}from"path";import{existsSync as rn}from"fs";function xe(){let t=process.argv[1];if(t){let e=on(t);if(rn(e))return{command:process.execPath,args:[e,"mcp"]}}return{command:"gibil",args:["mcp"]}}var J,S,H=Ke(()=>{"use strict";J=B(nn(),".gibil"),S={root:J,instances:B(J,"instances"),keys:B(J,"keys"),jobs:B(J,"jobs"),instanceFile:t=>B(J,"instances",`${t}.json`),keyDir:t=>B(J,"keys",t),privateKey:t=>B(J,"keys",t,"id_ed25519"),publicKey:t=>B(J,"keys",t,"id_ed25519.pub")}});var We={};rt(We,{clearApiKey:()=>ze,fetchUsage:()=>qe,getApiKey:()=>E,getApiUrl:()=>dn,getApiUrlFromConfig:()=>Se,getDefaultAgent:()=>pe,getHetznerToken:()=>Je,getServerDefaults:()=>fn,saveApiKey:()=>Ue,saveDefaultAgent:()=>Ie,saveHetznerToken:()=>fe,saveServerDefaults:()=>Be,trackUsage:()=>K,verifyApiKey:()=>q});import{readFile as sn,writeFile as an,mkdir as cn}from"fs/promises";import{existsSync as ln}from"fs";import{join as un}from"path";async function R(){if(!ln(Fe))return{};let t=await sn(Fe,"utf-8");return JSON.parse(t)}async function de(t){await cn(S.root,{recursive:!0,mode:448}),await an(Fe,JSON.stringify(t,null,2),{mode:384})}async function Ue(t){let e=await R();e.api_key=t,await de(e)}async function E(){return process.env.GIBIL_API_KEY?process.env.GIBIL_API_KEY:(await R()).api_key??null}async function ze(){let t=await R();delete t.api_key,await de(t)}function dn(){return process.env.GIBIL_API_URL??ut}async function Se(){return process.env.GIBIL_API_URL?process.env.GIBIL_API_URL:(await R()).api_url??ut}async function fe(t){let e=await R();e.hetzner_token=t,await de(e)}async function Je(){return process.env.HETZNER_API_TOKEN?process.env.HETZNER_API_TOKEN:(await R()).hetzner_token??null}async function Be(t,e){let n=await R();n.default_server_type=t,n.default_location=e,await de(n)}async function fn(){let t=await R();return{serverType:t.default_server_type??"cax11",location:t.default_location??"fsn1"}}async function Ie(t){let e=await R();t?e.default_agent=t:delete e.default_agent,await de(e)}async function pe(){return(await R()).default_agent??null}async function q(t){let e=await Se(),n=await fetch(`${e}/auth-verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({api_key:t})});if(n.status===401)throw new Error("Invalid API key. Get one at https://gibil.dev");if(!n.ok){let o=await n.text();throw new Error(`API error (${n.status}): ${o}`)}return await n.json()}async function K(t,e,n,o){let r=await Se(),l=await fetch(`${r}/usage-track`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({api_key:t,event:e,instance_name:n,server_type:o})});if(l.status===429)throw new Error("Plan limit reached. Upgrade at https://gibil.dev/pricing");if(!l.ok){let s=await l.text();throw new Error(`Usage tracking failed (${l.status}): ${s}`)}}async function qe(t){let e=await Se(),n=await fetch(`${e}/usage-get`,{headers:{Authorization:`Bearer ${t}`}});if(!n.ok){let o=await n.text();throw new Error(`Failed to fetch usage (${n.status}): ${o}`)}return await n.json()}var Fe,ut,L=Ke(()=>{"use strict";H();Fe=un(S.root,"config.json"),ut="https://zopdxjruwktjyjunitrv.supabase.co/functions/v1"});var Ct={};rt(Ct,{JobStore:()=>He,deleteJob:()=>qn,deleteJobsByInstance:()=>Qe,listJobs:()=>he,listJobsByInstance:()=>Wn,loadJob:()=>Bn,loadJobOrThrow:()=>Y,saveJob:()=>W});import{readFile as Fn,writeFile as Un,mkdir as kt,rm as zn,readdir as Jn}from"fs/promises";import{existsSync as _t}from"fs";import{join as jt}from"path";var He,ee,W,Bn,Y,qn,he,Wn,Qe,ae=Ke(()=>{"use strict";H();He=class{jobsDir;constructor(e){let n=e??S.root;this.jobsDir=jt(n,"jobs")}jobFile(e){if(!/^[a-zA-Z0-9_-]+$/.test(e))throw new Error(`Invalid job ID: "${e}"`);return jt(this.jobsDir,`${e}.json`)}async save(e){await kt(this.jobsDir,{recursive:!0,mode:448}),await Un(this.jobFile(e.id),JSON.stringify(e,null,2),{mode:384})}async load(e){let n=this.jobFile(e);if(!_t(n))return null;let o=await Fn(n,"utf-8");return JSON.parse(o)}async loadOrThrow(e){let n=await this.load(e);if(!n)throw new Error(`Job "${e}" not found. Run "gibil job list" to see active jobs.`);return n}async delete(e){let n=this.jobFile(e);_t(n)&&await zn(n)}async list(){await kt(this.jobsDir,{recursive:!0,mode:448});let e=await Jn(this.jobsDir),n=[];for(let o of e){if(!o.endsWith(".json"))continue;let r=o.replace(".json",""),l=await this.load(r);l&&n.push(l)}return n}async listByInstance(e){return(await this.list()).filter(o=>o.instance===e)}async deleteByInstance(e){let n=await this.listByInstance(e);for(let o of n)await this.delete(o.id)}},ee=new He,W=t=>ee.save(t),Bn=t=>ee.load(t),Y=t=>ee.loadOrThrow(t),qn=t=>ee.delete(t),he=()=>ee.list(),Wn=t=>ee.listByInstance(t),Qe=t=>ee.deleteByInstance(t)});import{Command as mo}from"commander";import{readFileSync as ho}from"fs";import{fileURLToPath as yo}from"url";import{dirname as wo,join as vo}from"path";import ne from"picocolors";var F=t=>ne.red(t),Qt=t=>ne.yellow(t),U=t=>ne.green(t),en=t=>ne.red(t),p=t=>ne.dim(t),h=t=>ne.bold(t),C="\u{1F98E}";var O=U("\u2713"),ue=en("\u2716"),st=Qt("\u26A0"),be="\u{12248}",at=`
|
|
3
|
+
${F(" /\\")}
|
|
4
|
+
${F(" / \\")}
|
|
5
|
+
${F(" / \u{1F525} \\")}
|
|
6
|
+
${F(" / \\")}
|
|
7
|
+
${p(" ~~~~~~~~")}
|
|
8
|
+
${h(" g i b i l")} ${p(be)}
|
|
9
|
+
`,ct=`${C} ${h("gibil")} ${p(be)}`,it=["\u280B","\u2819","\u2839","\u2838","\u283C","\u2834","\u2826","\u2827","\u2807","\u280F"],le=class{timer=null;frame=0;text;constructor(e){this.text=e}start(){return process.stderr.isTTY?(this.timer=setInterval(()=>{let e=F(it[this.frame%it.length]);process.stderr.write(`\r ${e} ${this.text}`),this.frame++},80),this):(process.stderr.write(` ${this.text}
|
|
10
10
|
`),this)}update(e){this.text=e,process.stderr.isTTY||process.stderr.write(` ${e}
|
|
11
|
-
`)}succeed(e){this.stop(),process.stderr.write(`\r ${
|
|
12
|
-
`)}fail(e){this.stop(),process.stderr.write(`\r ${
|
|
13
|
-
`)}stop(){this.timer&&(clearInterval(this.timer),this.timer=null,process.stderr.isTTY&&process.stderr.write("\r\x1B[K"))}};function
|
|
14
|
-
`)}function
|
|
15
|
-
Your Hetzner token may be invalid or expired. Run: gibil init --force`:
|
|
16
|
-
A server with this name already exists. Try a different --name or run: gibil destroy <name>`:
|
|
17
|
-
This server type may not be available in your region. Run: gibil init --force`),new Error(`Hetzner API error (${
|
|
18
|
-
`)}function mn(t){let e=[];if(t.startsWith("node:")){let n=t.split(":")[1]??"20";e.push("# Install Node.js"),e.push(`curl -fsSL https://deb.nodesource.com/setup_${n}.x | bash - > /dev/null 2>&1`),e.push("apt-get install -y -qq nodejs > /dev/null 2>&1"),e.push("npm install -g pnpm@latest > /dev/null 2>&1"),e.push("")}else if(t.startsWith("python:")){let n=t.split(":")[1]??"3.12";e.push("# Install Python"),e.push(`apt-get install -y -qq python${n} python3-pip python3-venv > /dev/null 2>&1`),e.push("")}else if(t.startsWith("go:")){let n=t.split(":")[1]??"1.22";e.push("# Install Go"),e.push('GO_ARCH=$(uname -m | sed "s/x86_64/amd64/" | sed "s/aarch64/arm64/")'),e.push(`wget -q https://go.dev/dl/go${n}.linux-\${GO_ARCH}.tar.gz -O /tmp/go.tar.gz`),e.push("tar -C /usr/local -xzf /tmp/go.tar.gz"),e.push("export PATH=$PATH:/usr/local/go/bin"),e.push('echo "export PATH=\\$PATH:/usr/local/go/bin" >> /root/.bashrc'),e.push("")}else e.push("# Install Node.js (default)"),e.push("curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1"),e.push("apt-get install -y -qq nodejs > /dev/null 2>&1"),e.push("npm install -g pnpm@latest > /dev/null 2>&1"),e.push("");return e}function pn(){return["# Install Docker","curl -fsSL https://get.docker.com | sh > /dev/null 2>&1","systemctl enable docker --now"]}function fn(t){let e=[];e.push(`# Start service: ${t.name.replace(/[^a-zA-Z0-9_-]/g,"")}`);let o=`docker run -d --name ${t.name.replace(/[^a-zA-Z0-9_-]/g,"")}`;if(t.port&&(o+=` -p ${t.port}:${t.port}`),t.env)for(let[r,c]of Object.entries(t.env))o+=` -e ${r}=${P(c)}`;return o+=` ${t.image}`,e.push(o),e.push(""),e}function P(t){return`'${t.replace(/'/g,"'\\''")}'`}import{readFile as gn}from"fs/promises";import{existsSync as st,statSync as hn}from"fs";import{join as yn}from"path";import{parse as ct}from"yaml";async function ne(t){let e=t.match(/github\.com\/([^/]+)\/([^/.]+)/);if(!e)return null;let[,n,o]=e,r=`https://raw.githubusercontent.com/${n}/${o}/HEAD/.gibil.yml`;try{let c={};process.env.GITHUB_TOKEN&&(c.Authorization=`token ${process.env.GITHUB_TOKEN}`);let i=await fetch(r,{signal:AbortSignal.timeout(1e4),headers:c});if(!i.ok)return null;let l=await i.text();return vn(l)}catch{return null}}var bn=".gibil.yml";async function oe(t){let e;if(st(t)&&hn(t).isFile()?e=t:e=yn(t,bn),!st(e))return null;let n=await gn(e,"utf-8"),o=ct(n);return lt(o)}function vn(t){let e=ct(t);return lt(e)}function lt(t){if(!t||typeof t!="object")throw new Error("Invalid .gibil.yml: must be a YAML object");let e=t,n={};return typeof e.name=="string"&&(n.name=e.name),typeof e.image=="string"&&(n.image=e.image),typeof e.server_type=="string"&&(n.server_type=e.server_type),typeof e.location=="string"&&(n.location=e.location),Array.isArray(e.services)&&(n.services=e.services.map(o=>{let r=o;if(typeof r.name!="string"||typeof r.image!="string")throw new Error("Each service must have a 'name' and 'image' field");return{name:r.name,image:r.image,port:typeof r.port=="number"?r.port:void 0,env:at(r.env,`service "${r.name}"`)}})),Array.isArray(e.tasks)&&(n.tasks=e.tasks.map(o=>{let r=o;if(typeof r.name!="string"||typeof r.command!="string")throw new Error("Each task must have a 'name' and 'command' field");return{name:r.name,command:r.command}})),e.env!==void 0&&(n.env=at(e.env,"top-level")),n}function at(t,e){if(t==null)return;if(typeof t!="object"||Array.isArray(t))throw new Error(`env in ${e} must be a key-value object`);let n={};for(let[o,r]of Object.entries(t))if(typeof r=="string")n[o]=r;else if(typeof r=="number"||typeof r=="boolean")n[o]=String(r);else throw new Error(`env.${o} in ${e} must be a string, number, or boolean \u2014 got ${typeof r}`);return Object.keys(n).length>0?n:void 0}N();import{readFile as wn,writeFile as $n,mkdir as ut,rm as xn,readdir as Sn}from"fs/promises";import{existsSync as dt}from"fs";import{join as ze}from"path";var Ue=class{instancesDir;keysDir;constructor(e){let n=e??S.root;this.instancesDir=ze(n,"instances"),this.keysDir=ze(n,"keys")}async ensureDirectories(){await ut(this.instancesDir,{recursive:!0,mode:448}),await ut(this.keysDir,{recursive:!0,mode:448})}instanceFile(e){return ze(this.instancesDir,`${e}.json`)}async save(e){await this.ensureDirectories(),await $n(this.instanceFile(e.name),JSON.stringify(e,null,2),{mode:384})}async load(e){let n=this.instanceFile(e);if(!dt(n))return null;let o=await wn(n,"utf-8");return JSON.parse(o)}async loadOrThrow(e){let n=await this.load(e);if(!n)throw new Error(`Instance "${e}" not found. Run "gibil list" to see active instances.`);return n}async loadActiveOrThrow(e){let n=await this.loadOrThrow(e);if(new Date>new Date(n.expiresAt))throw new Error(`Instance "${e}" has expired (TTL was ${n.ttlMinutes}m). Run "gibil destroy ${e}" to clean up.`);return n}async delete(e){let n=this.instanceFile(e);dt(n)&&await xn(n)}async list(){await this.ensureDirectories();let e=await Sn(this.instancesDir),n=[];for(let o of e){if(!o.endsWith(".json"))continue;let r=o.replace(".json",""),c=await this.load(r);c&&n.push(c)}return n}},le=new Ue;var Y=t=>le.save(t);var ke=t=>le.loadOrThrow(t),j=t=>le.loadActiveOrThrow(t),Ie=t=>le.delete(t),Z=()=>le.list();import{randomBytes as kn}from"crypto";function X(t=6){return kn(Math.ceil(t/2)).toString("hex").slice(0,t)}function je(){return`gibil-${X()}`}function mt(){return`fleet-${X(8)}`}function _e(){return`j-${X(8)}`}N();var In=/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/;function pt(t){if(!In.test(t))throw new Error(`Invalid instance name "${t}". Names must be 1-63 chars, start with alphanumeric, and contain only [a-zA-Z0-9_-].`);return t}function ue(t,e){let n=parseInt(t,10);if(isNaN(n)||n<=0)throw new Error(`${e} must be a positive integer, got "${t}"`);return n}D();import{execSync as Ce}from"child_process";import{readFileSync as jn}from"fs";function _n(){try{let t=Ce("git config user.name",{encoding:"utf-8"}).trim(),e=Ce("git config user.email",{encoding:"utf-8"}).trim();if(!t||!e)return;let n;try{if(Ce("git config gpg.format",{encoding:"utf-8"}).trim()==="ssh"){let r=Ce("git config user.signingkey",{encoding:"utf-8"}).trim();if(r)try{n=jn(r,"utf-8").trim()}catch{(r.startsWith("ssh-")||r.startsWith("key::"))&&(n=r.replace(/^key::/,""))}}}catch{}return{name:t,email:e,signingKey:n}}catch{return}}async function Ee(t,e,n){s.step("Generating SSH keys...");let o=await $e(e),r;try{s.step("Uploading SSH key..."),r=await t.createSSHKey(`gibil-${e}-${X(4)}`,o.publicKey),n.repo&&n.repo.includes("github.com")&&!process.env.GITHUB_TOKEN&&s.debug("No GITHUB_TOKEN set \u2014 private repos will fail to clone. Set GITHUB_TOKEN to enable private repo access.");let c=_n(),i=Se({repo:n.repo,config:n.config??void 0,ttlMinutes:n.ttlMinutes,githubToken:process.env.GITHUB_TOKEN,gitIdentity:c}),l=s.spin("Creating server on Hetzner..."),a=await t.createServer(e,r.id,i,n.serverType??n.config?.server_type,n.location??n.config?.location);l.succeed("Server created");let u=s.spin("VM booting..."),p=(await t.waitForReady(a.id)).public_net.ipv4.ip;u.succeed(`VM running at ${p}`);let d=new Date,g={name:e,serverId:a.id,ip:p,sshKeyId:r.id,keyPath:S.privateKey(e),status:"running",createdAt:d.toISOString(),ttlMinutes:n.ttlMinutes,expiresAt:new Date(d.getTime()+n.ttlMinutes*6e4).toISOString(),repo:n.repo,fleetId:n.fleetId,gitIdentity:c};await Y(g);let $=s.spin("Waiting for SSH...");if(await xe(e,p),$.succeed("SSH ready"),n.repo||n.config){let k=s.spin("Provisioning (runtime, repo, deps)..."),A=36e4,h=5e3,x=Date.now(),H=!1;for(;Date.now()-x<A;){try{if((await v({instanceName:e,ip:p,command:"test -f /root/.gibil-ready && echo ready || echo waiting",timeoutMs:1e4})).stdout.trim()==="ready"){H=!0;break}}catch{}await new Promise(K=>setTimeout(K,h))}if(H)k.succeed("Provisioning complete");else{k.fail("Provisioning may have failed");try{let K=await v({instanceName:e,ip:p,command:"tail -20 /var/log/cloud-init-output.log 2>/dev/null || echo 'No cloud-init log found'",timeoutMs:1e4});s.info(K.stdout)}catch{s.warn("Could not read cloud-init log.")}}}return g}catch(c){if(s.error(`Failed to create instance "${e}", cleaning up...`),await te(e).catch(i=>s.warn(`Could not clean up SSH keys: ${i instanceof Error?i.message:String(i)}`)),r){let i=r.id;await t.deleteSSHKey(i).catch(l=>s.warn(`Could not delete Hetzner SSH key ${i}: ${l instanceof Error?l.message:String(l)}`))}throw c}}function ft(t){let e=Math.max(0,Math.floor((new Date(t.expiresAt).getTime()-Date.now())/1e3));return{name:t.name,ip:t.ip,ssh:`ssh -i ${t.keyPath} -o StrictHostKeyChecking=no root@${t.ip}`,status:t.status,ttl_remaining:e,created_at:t.createdAt,fleet_id:t.fleetId}}function gt(t){t.command("create").description("Spin up one or more ephemeral dev machines").option("-n, --name <name>","Instance name").option("-f, --fleet <count>","Number of instances to create in parallel").option("-r, --repo <git-url>","Git repository to clone on startup").option("--json","Output instance info as JSON").option("--ttl <minutes>","Auto-destroy after N minutes","60").option("-c, --config <path>","Path to .gibil.yml config").option("--server-type <type>","Hetzner server type (e.g. cpx11, cpx21)").option("--location <loc>","Hetzner location (e.g. fsn1, nbg1)").option("-q, --quiet","Suppress non-essential output").option("-e, --env <KEY=VALUE...>","Environment variables to set on the server").action(async e=>{e.json&&w(!0);let n=ue(e.ttl??"60","TTL");if(n>10080)throw new Error("TTL cannot exceed 7 days (10080 minutes).");let o=ue(e.fleet??"1","Fleet count");if(o>20)throw new Error("Fleet size cannot exceed 20. Contact support for higher limits.");e.name&&pt(e.name);let r={};if(e.env)for(let a of e.env){let u=a.indexOf("=");if(u<=0)throw new Error(`Invalid --env format: "${a}". Use KEY=VALUE.`);r[a.slice(0,u)]=a.slice(u+1)}r.GITHUB_TOKEN&&!process.env.GITHUB_TOKEN&&(process.env.GITHUB_TOKEN=r.GITHUB_TOKEN);let c=null;e.config?c=await oe(e.config):e.repo?c=await ne(e.repo)??await oe(process.cwd()):c=await oe(process.cwd()),Object.keys(r).length>0&&(c||(c={}),c.env={...c.env,...r});let i=await E();if(i){s.info("Verifying API key...");let a=await U(i);s.info(` Authenticated as ${a.user.email} (${a.user.plan})`)}let l=await T.create();if(o===1){let a=e.name??je(),u=Date.now(),m=s.spin(`Forging "${a}"...`),p=await Ee(l,a,{repo:e.repo,ttlMinutes:n,config:c,serverType:e.serverType,location:e.location}),d=((Date.now()-u)/1e3).toFixed(1);m.succeed(I.createReady(a,d)),i&&await R(i,"create",p.name,e.serverType).catch(g=>s.debug(`Usage tracking failed: ${g instanceof Error?g.message:String(g)}`)),e.json?s.json(ft(p)):(s.info(""),s.info(ye("Server ready",[`${f("Name:")} ${y(p.name)}`,`${f("IP:")} ${p.ip}`,`${f("TTL:")} ${n} minutes`,`${f("SSH:")} ${y(`gibil ssh ${p.name}`)}`])),s.info(""),s.info(f(" Try:")),s.info(` ${y(`gibil run ${p.name} "<your test command>"`)}`),s.info(` ${y(`gibil ssh ${p.name}`)}`),s.info(` ${y(`gibil destroy ${p.name}`)}`),s.info(""))}else{let a=mt(),u=e.name??"gibil",m=Date.now(),p=s.spin(`Forging fleet "${a}" \u2014 ${o} servers...`),d=Array.from({length:o},(h,x)=>`${u}-${x+1}-${a.slice(6)}`),g=await Promise.allSettled(d.map(h=>Ee(l,h,{repo:e.repo,ttlMinutes:n,config:c,serverType:e.serverType,location:e.location,fleetId:a}))),$=[],k=[];for(let h=0;h<g.length;h++){let x=g[h];x.status==="fulfilled"?$.push(x.value):k.push(`${d[h]}: ${x.reason instanceof Error?x.reason.message:String(x.reason)}`)}let A=((Date.now()-m)/1e3).toFixed(1);if(p.succeed(I.fleetReady($.length,o)+` ${f(`(${A}s)`)}`),i&&await Promise.all($.map(h=>R(i,"create",h.name,e.serverType).catch(x=>s.debug(`Usage tracking failed for ${h.name}: ${x instanceof Error?x.message:String(x)}`)))),e.json)s.json({fleet_id:a,instances:$.map(ft),errors:k});else{s.info("");for(let h of $)s.info(` ${M} ${y(h.name)} ${f("\u2192")} ${h.ip}`);for(let h of k)s.info(` ${ae} ${h}`);s.info("")}}})}import{spawn as Cn}from"child_process";function ht(t){t.command("ssh <name>").description("SSH into a running ephemeral machine").action(async e=>{let n=await j(e),o=["-A","-i",n.keyPath,"-o","StrictHostKeyChecking=no","-o","UserKnownHostsFile=/dev/null","-o","LogLevel=ERROR",`root@${n.ip}`];Cn("ssh",o,{stdio:"inherit"}).on("exit",c=>{process.exit(c??0)})})}re();function $t(t){t.command("run <name> <command...>").description("Execute a command on a running instance").option("--json","Output result as JSON").option("--timeout <seconds>","Command timeout in seconds (default: 30)").option("-b, --background","Run in background, return job ID immediately").action(async(e,n,o)=>{o.json&&w(!0);let r=await j(e),c=n.join(" "),i=o.timeout?ue(o.timeout,"Timeout")*1e3:3e4;if(o.background){let a=_e(),u="/root/.gibil-jobs",m=`${u}/${a}.log`,p=`${u}/${a}.exit`,d=`${u}/${a}.pid`,g=`${u}/${a}.sh`,$=["#!/bin/bash",`nohup bash -c '${c.replace(/'/g,"'\\''")}' > ${m} 2>&1 &`,"BGPID=$!",`echo $BGPID > ${d}`,`(wait $BGPID 2>/dev/null; echo $? > ${p}) &`,"echo $BGPID"].join(`
|
|
19
|
-
`),
|
|
20
|
-
${
|
|
21
|
-
${
|
|
22
|
-
${f(`${o.length} server(s)`)}`)})}function It(t){if(t<=0)return"expired";let e=Math.floor(t/60),n=t%60;return e>=60?`${Math.floor(e/60)}h ${e%60}m`:`${e}m ${n}s`}function Mn(t){let e=Date.now()-new Date(t).getTime(),n=Math.floor(e/1e3);return It(n)}function jt(t){t.command("extend <name>").description("Extend the TTL of a running instance").requiredOption("--ttl <minutes>","New TTL in minutes from now").option("--json","Output result as JSON").action(async(e,n)=>{n.json&&w(!0);let o=await j(e),r=parseInt(n.ttl,10);(isNaN(r)||r<=0)&&(s.error("TTL must be a positive number of minutes"),process.exit(1)),await v({instanceName:e,ip:o.ip,command:["pkill -f 'sleep.*shutdown' || true",`(sleep ${r*60} && shutdown -h now) &`].join(" && ")});let c=new Date(Date.now()+r*6e4).toISOString();o.ttlMinutes=r,o.expiresAt=c,await Y(o),n.json?s.json({name:o.name,ttl_minutes:r,expires_at:c}):s.info(`\u2713 Extended "${e}" TTL to ${r} minutes (expires ${c})`)})}import{readFile as Rn}from"fs/promises";import{randomBytes as Dn}from"crypto";function _t(t){t.command("exec <name>").description("Upload and run a local script on an instance").requiredOption("-s, --script <path>","Path to local script").option("--json","Output result as JSON").action(async(e,n)=>{n.json&&w(!0);let o=await j(e),r=await Rn(n.script,"utf-8");s.info(`Uploading and running script "${n.script}" on "${e}"...`);let c=Buffer.from(r).toString("base64"),i=`/tmp/gibil-script-${Dn(4).toString("hex")}.sh`,l=await v({instanceName:e,ip:o.ip,command:`echo '${c}' | base64 -d > ${i} && chmod +x ${i} && ${i}; EXIT=$?; rm -f ${i}; exit $EXIT`,stream:!n.json});n.json?s.json({instance:e,script:n.script,stdout:l.stdout,stderr:l.stderr,exit_code:l.exitCode}):l.exitCode!==0&&s.error(`Script exited with code ${l.exitCode}`),process.exit(l.exitCode??1)})}D();import{createInterface as Kn}from"readline";function Ct(t){let e=Kn({input:process.stdin,output:process.stderr});return new Promise(n=>{e.question(t,o=>{e.close(),n(o.trim())})})}function Et(t){let e=t.command("auth").description("Manage authentication");e.command("login").description("Log in with your Gibil API key").option("--key <api-key>","API key (or enter interactively)").option("--json","Output result as JSON").action(async n=>{n.json&&w(!0);let o=n.key??process.env.GIBIL_API_KEY;o||(o=await Ct("Enter your API key: ")),o||(s.error("No API key provided."),process.exit(1)),o.startsWith("pk_")||(s.error('Invalid key format. API keys start with "pk_".'),process.exit(1)),s.info("Verifying API key...");try{let r=await U(o);await De(o),n.json?s.json({authenticated:!0,email:r.user.email,plan:r.user.plan}):(s.info(I.authSuccess),s.detail("Email",r.user.email),s.detail("Plan",r.user.plan),s.detail("Limits",`${r.limits.max_concurrent} concurrent servers, ${r.limits.remaining_hours}h remaining`))}catch(r){s.error(r instanceof Error?r.message:String(r)),process.exit(1)}}),e.command("setup").description("Configure Hetzner API token (stored in ~/.gibil/config.json)").option("--token <token>","Hetzner API token (or enter interactively)").action(async n=>{let o=n.token;o||(o=await Ct("Enter your Hetzner API token: ")),o||(s.error("No token provided."),process.exit(1));try{let c=await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${o}`}})).json();c.error&&(s.error(`Invalid token: ${c.error.message}`),process.exit(1))}catch{s.error("Could not verify token with Hetzner API."),process.exit(1)}await ce(o),s.success("Hetzner token saved to ~/.gibil/config.json")}),e.command("logout").description("Clear stored API key").action(async()=>{await Ke(),s.info(I.authLogout)}),e.command("status").description("Show current authentication status").option("--json","Output result as JSON").action(async n=>{n.json&&w(!0);let o=await E();if(!o){n.json?s.json({authenticated:!1}):s.info(`Not logged in. Run ${y("gibil auth login")} to authenticate.`);return}try{let r=await U(o);n.json?s.json({authenticated:!0,email:r.user.email,plan:r.user.plan,limits:r.limits}):(s.success(`Authenticated as ${r.user.email}`),s.detail("Plan",r.user.plan),s.detail("Concurrent servers",String(r.limits.max_concurrent)),s.detail("Hours remaining",String(r.limits.remaining_hours)))}catch{n.json?s.json({authenticated:!1,error:"Key verification failed"}):s.error(`Stored API key is invalid. Run ${y("gibil auth login")} to re-authenticate.`)}})}D();function Tt(t){t.command("usage").description("Show current month's usage and plan limits").option("--json","Output as JSON").action(async e=>{e.json&&w(!0);let n=await E();n||(s.error('Not logged in. Run "gibil auth login" first.'),process.exit(1));try{let o=await Fe(n);if(e.json)s.json(o);else{let r=Math.round(o.vm_hours_used/o.vm_hours_limit*100);s.info(`Plan: ${o.plan}`),s.info(`VM hours: ${o.vm_hours_used.toFixed(1)} / ${o.vm_hours_limit}h (${r}%)`),s.info(`Active instances: ${o.active_instances} / ${o.max_concurrent}`),r>80&&s.warn("Running low on hours. Upgrade at https://gibil.dev/pricing")}}catch(o){s.error(o instanceof Error?o.message:String(o)),process.exit(1)}})}import{McpServer as Ln}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as Gn}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as b}from"zod";N();import{execSync as Pe}from"child_process";import{readFileSync as Fn}from"fs";re();re();async function qe(t){let e=await q(t);if(e.status!=="running")return{status:e.status,exitCode:e.exitCode};let n=await j(e.instance),o="/root/.gibil-jobs",r=`${o}/${t}.exit`,c=`${o}/${t}.log`,l=(await v({instanceName:e.instance,ip:n.ip,command:`test -f ${r} && cat ${r} || echo RUNNING`,timeoutMs:1e4})).stdout.trim();if(l==="RUNNING")return{status:"running"};let a=parseInt(l,10),u=await v({instanceName:e.instance,ip:n.ip,command:`cat ${c} 2>/dev/null || echo ''`,timeoutMs:1e4}),m=a===0?"done":"failed",p=new Date,d=Math.round((p.getTime()-new Date(e.startedAt).getTime())/1e3);return e.status=m,e.exitCode=a,e.completedAt=p.toISOString(),await B(e),{status:m,exitCode:a,stdout:u.stdout,durationS:d}}function Pt(t){let e=t.command("job").description("Manage background jobs");e.command("status <id>").description("Check status of a background job").option("--json","Output result as JSON").action(async(n,o)=>{o.json&&w(!0);let r=await q(n),c=await qe(n);o.json?s.json({job_id:n,instance:r.instance,command:r.command,status:c.status,exit_code:c.exitCode,started_at:r.startedAt,duration_s:c.durationS,...c.stdout!==void 0?{stdout:c.stdout}:{}}):c.status==="running"?(s.info(`Job ${n} is still running on "${r.instance}"`),s.info(` Command: ${r.command}`),s.info(` Started: ${r.startedAt}`)):(s.info(`Job ${n}: ${c.status} (exit code ${c.exitCode}, ${c.durationS}s)`),c.stdout&&process.stdout.write(c.stdout))}),e.command("list").description("List all background jobs").option("--json","Output result as JSON").action(async n=>{n.json&&w(!0);let o=await de();if(o.length===0){n.json?s.json([]):s.info("No background jobs.");return}if(n.json)s.json(o.map(r=>({job_id:r.id,instance:r.instance,command:r.command,status:r.status,started_at:r.startedAt,exit_code:r.exitCode})));else for(let r of o){let c=r.status==="running"?"\u27F3 running":r.status==="done"?"\u2713 done":`\u2717 ${r.status}`;s.info(` ${r.id} ${c} ${r.instance} ${r.command}`)}}),e.command("cancel <id>").description("Cancel a running background job").option("--json","Output result as JSON").action(async(n,o)=>{o.json&&w(!0);let r=await q(n);if(r.status!=="running"){o.json?s.json({job_id:n,status:r.status,message:"Job is not running"}):s.info(`Job ${n} is not running (status: ${r.status})`);return}let c=await j(r.instance);await v({instanceName:r.instance,ip:c.ip,command:`kill -- -${r.pid} 2>/dev/null || kill ${r.pid} 2>/dev/null || true`,timeoutMs:1e4}),r.status="cancelled",r.completedAt=new Date().toISOString(),await B(r),o.json?s.json({job_id:n,status:"cancelled"}):s.info(`Job ${n} cancelled.`)}),e.command("logs <id>").description("Fetch output of a background job").option("--json","Output result as JSON").option("-f, --follow","Follow log output (tail -f)").action(async(n,o)=>{o.json&&w(!0);let r=await q(n),c=await j(r.instance),i=`/root/.gibil-jobs/${n}.log`,l=o.follow?`tail -f ${i}`:`cat ${i} 2>/dev/null || echo '(no output yet)'`,a=o.follow?3e5:1e4,u=await v({instanceName:r.instance,ip:c.ip,command:l,stream:!o.json,timeoutMs:a});o.json&&s.json({job_id:n,stdout:u.stdout})})}function Jn(){try{let t=Pe("git config user.name",{encoding:"utf-8"}).trim(),e=Pe("git config user.email",{encoding:"utf-8"}).trim();if(!t||!e)return;let n;try{if(Pe("git config gpg.format",{encoding:"utf-8"}).trim()==="ssh"){let r=Pe("git config user.signingkey",{encoding:"utf-8"}).trim();if(r)try{n=Fn(r,"utf-8").trim()}catch{(r.startsWith("ssh-")||r.startsWith("key::"))&&(n=r.replace(/^key::/,""))}}}catch{}return{name:t,email:e,signingKey:n}}catch{return}}async function me(t,e){if(t)return t;if(e)return j(e);let o=(await Z()).filter(r=>new Date<new Date(r.expiresAt));if(o.length===0)throw new Error("No active servers. Use create_server first.");if(o.length===1)return o[0];throw new Error(`Multiple servers running: ${o.map(r=>r.name).join(", ")}. Pass the "server" parameter to specify which one.`)}function W(t,e,n=3e4){return v({instanceName:t.name,ip:t.ip,command:e,stream:!1,timeoutMs:n})}function O(t){return`'${t.replace(/'/g,"'\\''")}'`}async function At(t){let e=null;if(t&&(e=await j(t),e.gitIdentity)){let{name:i,email:l,signingKey:a}=e.gitIdentity,u=[`git config --global user.name ${O(i)}`,`git config --global user.email ${O(l)}`];a&&u.push("git config --global gpg.format ssh",`git config --global user.signingkey ${O("key::"+a)}`,"git config --global commit.gpgsign true"),W(e,u.join(" && ")).catch(()=>{})}let n=t?`gibil-${t}`:"gibil",o=new Ln({name:n,version:"0.4.0"});e||(o.tool("create_server","Forge a new ephemeral server with a full Linux environment (Ubuntu 24.04, Node.js 20, pnpm). Clones the repo to /root/project and waits until fully provisioned. After creation, use vm_bash to run commands, vm_read/vm_write for files, vm_grep to search code. Destroy with destroy_server when done.",{name:b.string().optional().describe("Server name (auto-generated if omitted)"),repo:b.string().optional().describe("Git repo URL to clone on boot"),ttl:b.number().optional().describe("Auto-destroy after N minutes (default: 60)"),server_type:b.string().optional().describe("Hetzner server type (default: auto-detected)"),location:b.string().optional().describe("Hetzner datacenter (default: auto-detected)"),env:b.record(b.string(),b.string()).optional().describe("Environment variables to set on the server")},async({name:i,repo:l,ttl:a,server_type:u,location:m,env:p})=>{try{let d=i??je(),g=await T.create(),$=await $e(d),k=await g.createSSHKey(`gibil-${d}-${X(4)}`,$.publicKey),A=Jn(),h=l?await ne(l):null;p&&Object.keys(p).length>0&&(h||(h={}),h.env={...h.env,...p});let x=(h?.services?.length??0)>0,H=a??(x?120:60),K=Se({repo:l,config:h??void 0,ttlMinutes:H,githubToken:process.env.GITHUB_TOKEN,gitIdentity:A}),fe=await g.createServer(d,k.id,K,u,m),ie=(await g.waitForReady(fe.id)).public_net.ipv4.ip,Ve=new Date,Lt={name:d,serverId:fe.id,ip:ie,sshKeyId:k.id,keyPath:S.privateKey(d),status:"running",createdAt:Ve.toISOString(),ttlMinutes:H,expiresAt:new Date(Ve.getTime()+H*6e4).toISOString(),repo:l,gitIdentity:A};await Y(Lt),await xe(d,ie);let Ae="ready";if(l||h){let Gt=Date.now(),Ye=!1;for(;Date.now()-Gt<36e4;){try{if((await v({instanceName:d,ip:ie,command:"test -f /root/.gibil-ready && echo ready || echo waiting",timeoutMs:1e4})).stdout.trim()==="ready"){Ye=!0;break}}catch{}await new Promise(Ne=>setTimeout(Ne,5e3))}if(!Ye){Ae="timeout";try{Ae=`timeout \u2014 cloud-init log:
|
|
23
|
-
${(await
|
|
24
|
-
`),
|
|
25
|
-
`)||"(no output)"}],isError:
|
|
26
|
-
`),u.succeed("MCP configured for Claude Code")}catch{u.fail("Could not auto-configure MCP"),
|
|
27
|
-
${
|
|
11
|
+
`)}succeed(e){this.stop(),process.stderr.write(`\r ${O} ${e??this.text}
|
|
12
|
+
`)}fail(e){this.stop(),process.stderr.write(`\r ${ue} ${e??this.text}
|
|
13
|
+
`)}stop(){this.timer&&(clearInterval(this.timer),this.timer=null,process.stderr.isTTY&&process.stderr.write("\r\x1B[K"))}};function $e(t,e){let n=Math.max(t.length+4,...e.map(a=>Le(a).length+4)),o=`${p("\u256D")}${p("\u2500".repeat(n))}${p("\u256E")}`,r=`${p("\u2570")}${p("\u2500".repeat(n))}${p("\u256F")}`,l=`${p("\u2502")} ${C} ${h(t)}${" ".repeat(n-Le(t).length-4)}${p("\u2502")}`,s=`${p("\u251C")}${p("\u2500".repeat(n))}${p("\u2524")}`,c=e.map(a=>{let u=n-Le(a).length-2;return`${p("\u2502")} ${a}${" ".repeat(Math.max(0,u))}${p("\u2502")}`});return[o,l,s,...c,r].join(`
|
|
14
|
+
`)}function Le(t){return t.replace(/\x1b\[[0-9;]*m/g,"")}var k={welcome:`${C} Your first fire. Welcome to Gibil.`,noInstances:`${C} No fires burning. Gibil sleeps.`,destroyAll:`${C} All fires extinguished. Gibil moves on.`,destroySingle:t=>`${C} "${t}" \u2014 fire out.`,authSuccess:`${C} Logged in. The forge is yours.`,authLogout:`${C} Logged out. The forge cools.`,createReady:(t,e)=>`${C} "${t}" forged ${p(`(${e}s)`)}`,fleetReady:(t,e)=>`${C} Fleet forged \u2014 ${t}/${e} fires lit.`,ttlWarning:(t,e)=>`${C} ${t} \u2014 flame is low (${e}m remaining)`,initComplete:`${C} The forge is ready. Run ${h("gibil create")} to light your first fire.`,setupNeeded:`${C} No forge configured. Run ${h("gibil init")} to get started.`};var tn="info",Ge=!1,lt={debug:0,info:1,warn:2,error:3,silent:4};function $(t){Ge=t}function z(t){return Ge&&t!=="error"?!1:lt[t]>=lt[tn]}var i={debug(t,...e){z("debug")&&console.debug(`${p("[debug]")} ${t}`,...e)},info(t,...e){z("info")&&console.log(t,...e)},warn(t,...e){z("warn")&&console.warn(`${st} ${t}`,...e)},error(t,...e){z("error")&&console.error(`${ue} ${t}`,...e)},success(t){z("info")&&console.log(`${O} ${t}`)},step(t){z("info")&&console.log(` ${p("\u203A")} ${t}`)},flame(t){z("info")&&console.log(t)},detail(t,e){z("info")&&console.log(` ${p(t+":")} ${e}`)},spin(t){return Ge?new le(t):new le(t).start()},json(t){console.log(JSON.stringify(t,null,2))}};var pn="https://api.hetzner.cloud/v1",T=class t{token;constructor(e){this.token=e}static async create(e){let{getHetznerToken:n}=await Promise.resolve().then(()=>(L(),We)),o=e??await n();if(!o)throw new Error("HETZNER_API_TOKEN is required. Run 'gibil init' or set it in your environment.");return new t(o)}async request(e,n,o){let r=`${pn}${n}`;i.debug(`${e} ${r}`);let l=await fetch(r,{method:e,headers:{Authorization:`Bearer ${this.token}`,"Content-Type":"application/json"},body:o?JSON.stringify(o):void 0,signal:AbortSignal.timeout(3e4)});if(!l.ok){let s=await l.text(),c;try{c=JSON.parse(s).error?.message??s}catch{c=s}let a="";throw l.status===401||l.status===403?a=`
|
|
15
|
+
Your Hetzner token may be invalid or expired. Run: gibil init --force`:l.status===409&&c.includes("name")?a=`
|
|
16
|
+
A server with this name already exists. Try a different --name or run: gibil destroy <name>`:l.status===422&&(c.includes("location")||c.includes("server_type"))&&(a=`
|
|
17
|
+
This server type may not be available in your region. Run: gibil init --force`),new Error(`Hetzner API error (${l.status}): ${c}${a}`)}return l.status===204?{}:await l.json()}async createServer(e,n,o,r,l){if(!r||!l){let{getServerDefaults:u}=await Promise.resolve().then(()=>(L(),We)),f=await u();r=r??f.serverType,l=l??f.location}if(r.startsWith("cax")&&!["fsn1","nbg1"].includes(l))throw new Error(`ARM server type "${r}" is not available in "${l}". Use --location fsn1 or --location nbg1, or switch to an x86 type (cpx11, cpx21, etc.).`);let a={name:e,server_type:r,image:"ubuntu-24.04",ssh_keys:[n],labels:{gibil:"true","gibil-name":e},location:l};i.debug(`createServer payload: ${JSON.stringify({name:e,server_type:r,image:"ubuntu-24.04",location:l})}`),o&&(a.user_data=o);try{return(await this.request("POST","/servers",a)).server}catch(u){let f=`(server_type=${r}, location=${l}). Try a different --server-type or --location.`;throw u instanceof Error?new Error(`${u.message} ${f}`):u}}async destroyServer(e){await this.request("DELETE",`/servers/${e}`)}async getServer(e){return(await this.request("GET",`/servers/${e}`)).server}async listServers(e="gibil=true"){return(await this.request("GET",`/servers?label_selector=${encodeURIComponent(e)}&per_page=50`)).servers}async waitForReady(e,n=12e4){let o=Date.now(),r=3e3;for(;Date.now()-o<n;){let l=await this.getServer(e);if(l.status==="running"&&l.public_net.ipv4.ip!=="0.0.0.0")return l;i.debug(`Server ${e} status: ${l.status}, waiting...`),await new Promise(s=>setTimeout(s,r))}throw new Error(`Server ${e} did not become ready within ${n/1e3}s`)}async createSSHKey(e,n){return(await this.request("POST","/ssh_keys",{name:e,public_key:n})).ssh_key}async deleteSSHKey(e){await this.request("DELETE",`/ssh_keys/${e}`)}};H();import{mkdir as gn,rm as dt,readFile as mn,chmod as hn}from"fs/promises";import{existsSync as ft}from"fs";import{execFile as yn}from"child_process";import{promisify as wn}from"util";var vn=wn(yn);async function ke(t){let e=S.keyDir(t);ft(e)&&await dt(e,{recursive:!0}),await gn(e,{recursive:!0});let n=S.privateKey(t),o=S.publicKey(t);await vn("ssh-keygen",["-t","ed25519","-f",n,"-N","","-C",`gibil-${t}`]),await hn(n,384);let r=await mn(o,"utf-8");return{privateKeyPath:n,publicKeyPath:o,publicKey:r.trim()}}async function oe(t){let e=S.keyDir(t);ft(e)&&await dt(e,{recursive:!0})}H();import{Client as bn}from"ssh2";import{readFile as $n}from"fs/promises";async function b(t){let{instanceName:e,ip:n,command:o,stream:r=!1,timeoutMs:l=3e4}=t,s=await $n(S.privateKey(e),"utf-8");return new Promise((c,a)=>{let u=new bn,f="",g="";u.on("ready",()=>{i.debug(`SSH connected to ${n}`),u.exec(o,(d,m)=>{if(d)return u.end(),a(d);m.on("data",y=>{let I=y.toString();f+=I,r&&process.stdout.write(I)}),m.stderr.on("data",y=>{let I=y.toString();g+=I,r&&process.stderr.write(I)}),m.on("close",y=>{u.end(),c({stdout:f,stderr:g,exitCode:y??0})})})}).on("error",d=>{let m="";d.code==="ECONNREFUSED"?m=" (instance may have been destroyed or is still booting)":d.code==="EHOSTUNREACH"?m=" (IP unreachable \u2014 instance may not be running)":d.code==="ETIMEDOUT"&&(m=" (connection timed out \u2014 check if instance is running with 'gibil list')"),a(new Error(`SSH connection to ${n} failed: ${d.message}${m}`))}).connect({host:n,port:22,username:"root",privateKey:s,readyTimeout:l,hostVerifier:()=>!0,agent:process.env.SSH_AUTH_SOCK,agentForward:!0})})}async function _e(t,e,n=12e4){let o=Date.now(),r=5e3;for(;Date.now()-o<n;)try{await b({instanceName:t,ip:e,command:"echo ready",timeoutMs:1e4});return}catch{i.debug(`SSH not ready on ${e}, retrying...`),await new Promise(l=>setTimeout(l,r))}throw new Error(`SSH did not become available on ${e} within ${n/1e3}s`)}function je(t){let{repo:e,config:n,ttlMinutes:o,githubToken:r,gitIdentity:l}=t,s=["#!/bin/bash","set -euo pipefail","","# \u2500\u2500 Gibil cloud-init \u2500\u2500","export HOME=/root","export DEBIAN_FRONTEND=noninteractive"];r&&s.push(`export GITHUB_TOKEN=${P(r)}`),s.push("","# Base packages","apt-get update -qq","apt-get install -y -qq git curl wget build-essential unzip > /dev/null 2>&1","","# Install GitHub CLI","if ! type gh > /dev/null 2>&1; then"," curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null"," chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg"," ARCH=$(dpkg --print-architecture)",' echo "deb [arch=${ARCH} signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list'," apt-get update -qq && apt-get install -y -qq gh > /dev/null 2>&1","fi","");let c=n?.image??"node:20";if(s.push(...xn(c)),t.agent){let a=kn(t.agent);a&&(s.push(`# Install ${t.agent} + tmux`),t.agent==="aider"&&s.push("apt-get install -y -qq python3-pip > /dev/null 2>&1"),s.push(`${a} > /dev/null 2>&1`,"apt-get install -y -qq tmux > /dev/null 2>&1",""))}if(n?.services&&n.services.length>0){s.push(...Sn()),s.push("");for(let a of n.services)s.push(...In(a))}if(n?.env){s.push("# Environment variables");for(let[a,u]of Object.entries(n.env))s.push(`export ${a}=${P(u)}`),s.push(`echo ${P(`${a}=${u}`)} >> /etc/environment`);s.push("")}if(s.push("# Configure git"),l?(s.push(`git config --global user.email ${P(l.email)}`),s.push(`git config --global user.name ${P(l.name)}`),l.signingKey&&(s.push("git config --global gpg.format ssh"),s.push(`git config --global user.signingkey ${P("key::"+l.signingKey)}`),s.push("git config --global commit.gpgsign true"),s.push("git config --global tag.gpgsign true"),s.push("mkdir -p /root/.ssh"),s.push(`echo ${P(l.email+" "+l.signingKey)} > /root/.ssh/allowed_signers`),s.push("git config --global gpg.ssh.allowedSignersFile /root/.ssh/allowed_signers"))):(s.push("git config --global user.email 'gibil@bot.dev'"),s.push("git config --global user.name 'Gibil Bot'")),s.push(""),e){let a=e.match(/github\.com\/([^/]+\/[^/.]+)/);if(s.push("# Clone repository"),s.push("cd /root"),a){let u=a[1];s.push('if [ -n "${GITHUB_TOKEN:-}" ]; then'),s.push(` CLONE_URL="https://x-access-token:\${GITHUB_TOKEN}@github.com/${u}.git"`),s.push("else"),s.push(` CLONE_URL='https://github.com/${u}.git'`),s.push("fi"),s.push('timeout 300 git clone "$CLONE_URL" /root/project || { echo "Git clone failed or timed out"; exit 1; }')}else s.push(`timeout 300 git clone ${P(e)} /root/project || { echo "Git clone failed or timed out"; exit 1; }`);s.push("cd /root/project"),s.push(""),s.push('if [ -n "${GITHUB_TOKEN:-}" ]; then'),s.push(' echo "${GITHUB_TOKEN}" | gh auth login --with-token 2>/dev/null || true'),a&&s.push(` git -C /root/project remote set-url origin "https://x-access-token:\${GITHUB_TOKEN}@github.com/${a[1]}.git"`),s.push("fi"),s.push("")}if(o&&o>0&&(s.push("# Auto-destroy after TTL"),s.push(`echo "shutdown -h now" | at now + ${o} minutes 2>/dev/null || true`),s.push(`(sleep ${o*60} && shutdown -h now) &`),s.push("")),s.push("# Clean up cloud-init secrets"),s.push("rm -f /var/lib/cloud/instance/user-data.txt"),s.push(""),s.push("# Signal that infrastructure is ready"),s.push("touch /root/.gibil-ready"),s.push('echo "Gibil infrastructure ready"'),s.push(""),e&&n?.tasks&&n.tasks.length>0){s.push("# Run project tasks"),s.push("cd /root/project");for(let a of n.tasks)s.push(`echo '\u25B6 Running task: '${P(a.name)}`),s.push(`if ! ${a.command}; then`),s.push(` echo '\u2717 Task failed: '${P(a.name)}`),s.push(" touch /root/.gibil-tasks-failed"),s.push("fi");s.push(""),s.push("# Signal tasks complete"),s.push("if [ ! -f /root/.gibil-tasks-failed ]; then"),s.push(" touch /root/.gibil-tasks-done"),s.push(' echo "Gibil tasks complete"'),s.push("else"),s.push(' echo "Gibil tasks finished with errors"'),s.push("fi")}return s.join(`
|
|
18
|
+
`)}function xn(t){let e=[];if(t.startsWith("node:")){let n=t.split(":")[1]??"20";e.push("# Install Node.js"),e.push(`curl -fsSL https://deb.nodesource.com/setup_${n}.x | bash - > /dev/null 2>&1`),e.push("apt-get install -y -qq nodejs > /dev/null 2>&1"),e.push("npm install -g pnpm@latest > /dev/null 2>&1"),e.push("")}else if(t.startsWith("python:")){let n=t.split(":")[1]??"3.12";e.push("# Install Python"),e.push(`apt-get install -y -qq python${n} python3-pip python3-venv > /dev/null 2>&1`),e.push("")}else if(t.startsWith("go:")){let n=t.split(":")[1]??"1.22";e.push("# Install Go"),e.push('GO_ARCH=$(uname -m | sed "s/x86_64/amd64/" | sed "s/aarch64/arm64/")'),e.push(`wget -q https://go.dev/dl/go${n}.linux-\${GO_ARCH}.tar.gz -O /tmp/go.tar.gz`),e.push("tar -C /usr/local -xzf /tmp/go.tar.gz"),e.push("export PATH=$PATH:/usr/local/go/bin"),e.push('echo "export PATH=\\$PATH:/usr/local/go/bin" >> /root/.bashrc'),e.push("")}else e.push("# Install Node.js (default)"),e.push("curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1"),e.push("apt-get install -y -qq nodejs > /dev/null 2>&1"),e.push("npm install -g pnpm@latest > /dev/null 2>&1"),e.push("");return e}function Sn(){return["# Install Docker","curl -fsSL https://get.docker.com | sh > /dev/null 2>&1","systemctl enable docker --now"]}function In(t){let e=[];e.push(`# Start service: ${t.name.replace(/[^a-zA-Z0-9_-]/g,"")}`);let o=`docker run -d --name ${t.name.replace(/[^a-zA-Z0-9_-]/g,"")}`;if(t.port&&(o+=` -p ${t.port}:${t.port}`),t.env)for(let[r,l]of Object.entries(t.env))o+=` -e ${r}=${P(l)}`;return o+=` ${P(t.image)}`,e.push(o),e.push(""),e}var pt={claude:"npm install -g @anthropic-ai/claude-code",aider:"pip install --break-system-packages aider-chat",codex:"npm install -g @openai/codex"},re={claude:["ANTHROPIC_API_KEY"],aider:["ANTHROPIC_API_KEY","OPENAI_API_KEY"],codex:["OPENAI_API_KEY"]},M=Object.keys(pt);function kn(t){return pt[t]??null}function P(t){return`'${t.replace(/'/g,"'\\''")}'`}import{readFile as _n}from"fs/promises";import{existsSync as gt,statSync as jn}from"fs";import{join as Cn}from"path";import{parse as ht}from"yaml";async function ie(t){let e=t.match(/github\.com\/([^/]+)\/([^/.]+)/);if(!e)return null;let[,n,o]=e,r=`https://raw.githubusercontent.com/${n}/${o}/HEAD/.gibil.yml`;try{let l={};process.env.GITHUB_TOKEN&&(l.Authorization=`token ${process.env.GITHUB_TOKEN}`);let s=await fetch(r,{signal:AbortSignal.timeout(1e4),headers:l});if(!s.ok)return null;let c=await s.text();return Tn(c)}catch{return null}}var En=".gibil.yml";async function se(t){let e;if(gt(t)&&jn(t).isFile()?e=t:e=Cn(t,En),!gt(e))return null;let n=await _n(e,"utf-8"),o=ht(n);return yt(o)}function Tn(t){let e=ht(t);return yt(e)}function yt(t){if(!t||typeof t!="object")throw new Error("Invalid .gibil.yml: must be a YAML object");let e=t,n={};return typeof e.name=="string"&&(n.name=e.name),typeof e.image=="string"&&(n.image=e.image),typeof e.server_type=="string"&&(n.server_type=e.server_type),typeof e.location=="string"&&(n.location=e.location),Array.isArray(e.services)&&(n.services=e.services.map(o=>{let r=o;if(typeof r.name!="string"||typeof r.image!="string")throw new Error("Each service must have a 'name' and 'image' field");return{name:r.name,image:r.image,port:typeof r.port=="number"?r.port:void 0,env:mt(r.env,`service "${r.name}"`)}})),Array.isArray(e.tasks)&&(n.tasks=e.tasks.map(o=>{let r=o;if(typeof r.name!="string"||typeof r.command!="string")throw new Error("Each task must have a 'name' and 'command' field");return{name:r.name,command:r.command}})),e.env!==void 0&&(n.env=mt(e.env,"top-level")),n}function mt(t,e){if(t==null)return;if(typeof t!="object"||Array.isArray(t))throw new Error(`env in ${e} must be a key-value object`);let n={};for(let[o,r]of Object.entries(t))if(typeof r=="string")n[o]=r;else if(typeof r=="number"||typeof r=="boolean")n[o]=String(r);else throw new Error(`env.${o} in ${e} must be a string, number, or boolean \u2014 got ${typeof r}`);return Object.keys(n).length>0?n:void 0}H();import{readFile as Pn,writeFile as An,mkdir as wt,rm as Nn,readdir as On}from"fs/promises";import{existsSync as vt}from"fs";import{join as Ye}from"path";var Ve=class{instancesDir;keysDir;constructor(e){let n=e??S.root;this.instancesDir=Ye(n,"instances"),this.keysDir=Ye(n,"keys")}async ensureDirectories(){await wt(this.instancesDir,{recursive:!0,mode:448}),await wt(this.keysDir,{recursive:!0,mode:448})}instanceFile(e){return Ye(this.instancesDir,`${e}.json`)}async save(e){await this.ensureDirectories(),await An(this.instanceFile(e.name),JSON.stringify(e,null,2),{mode:384})}async load(e){let n=this.instanceFile(e);if(!vt(n))return null;let o=await Pn(n,"utf-8");return JSON.parse(o)}async loadOrThrow(e){let n=await this.load(e);if(!n)throw new Error(`Instance "${e}" not found. Run "gibil list" to see active instances.`);return n}async loadActiveOrThrow(e){let n=await this.loadOrThrow(e);if(new Date>new Date(n.expiresAt))throw new Error(`Instance "${e}" has expired (TTL was ${n.ttlMinutes}m). Run "gibil destroy ${e}" to clean up.`);return n}async delete(e){let n=this.instanceFile(e);vt(n)&&await Nn(n)}async list(){await this.ensureDirectories();let e=await On(this.instancesDir),n=[];for(let o of e){if(!o.endsWith(".json"))continue;let r=o.replace(".json",""),l=await this.load(r);l&&n.push(l)}return n}},ge=new Ve;var Z=t=>ge.save(t);var Ce=t=>ge.loadOrThrow(t),_=t=>ge.loadActiveOrThrow(t),Ee=t=>ge.delete(t),X=()=>ge.list();import{randomBytes as Hn}from"crypto";function Q(t=6){return Hn(Math.ceil(t/2)).toString("hex").slice(0,t)}function Te(){return`gibil-${Q()}`}function bt(){return`fleet-${Q(8)}`}function Pe(){return`j-${Q(8)}`}H();var Rn=/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/;function Ae(t){if(!Rn.test(t))throw new Error(`Invalid instance name "${t}". Names must be 1-63 chars, start with alphanumeric, and contain only [a-zA-Z0-9_-].`);return t}function me(t,e){let n=parseInt(t,10);if(isNaN(n)||n<=0)throw new Error(`${e} must be a positive integer, got "${t}"`);return n}L();import{execSync as Ne}from"child_process";import{readFileSync as Mn}from"fs";function Dn(){try{let t=Ne("git config user.name",{encoding:"utf-8"}).trim(),e=Ne("git config user.email",{encoding:"utf-8"}).trim();if(!t||!e)return;let n;try{if(Ne("git config gpg.format",{encoding:"utf-8"}).trim()==="ssh"){let r=Ne("git config user.signingkey",{encoding:"utf-8"}).trim();if(r)try{n=Mn(r,"utf-8").trim()}catch{(r.startsWith("ssh-")||r.startsWith("key::"))&&(n=r.replace(/^key::/,""))}}}catch{}return{name:t,email:e,signingKey:n}}catch{return}}async function Oe(t,e,n){i.step("Generating SSH keys...");let o=await ke(e),r,l;try{i.step("Uploading SSH key..."),r=await t.createSSHKey(`gibil-${e}-${Q(4)}`,o.publicKey),n.repo&&n.repo.includes("github.com")&&!process.env.GITHUB_TOKEN&&i.debug("No GITHUB_TOKEN set \u2014 private repos will fail to clone. Set GITHUB_TOKEN to enable private repo access.");let s=Dn(),c=je({repo:n.repo,config:n.config??void 0,ttlMinutes:n.ttlMinutes,githubToken:process.env.GITHUB_TOKEN,gitIdentity:s,agent:n.agent}),a=i.spin("Creating server on Hetzner..."),u=await t.createServer(e,r.id,c,n.serverType??n.config?.server_type,n.location??n.config?.location);l=u.id,a.succeed("Server created");let f=i.spin("VM booting..."),d=(await t.waitForReady(u.id)).public_net.ipv4.ip;f.succeed(`VM running at ${d}`);let m=new Date,y={name:e,serverId:u.id,ip:d,sshKeyId:r.id,keyPath:S.privateKey(e),status:"running",createdAt:m.toISOString(),ttlMinutes:n.ttlMinutes,expiresAt:new Date(m.getTime()+n.ttlMinutes*6e4).toISOString(),repo:n.repo,fleetId:n.fleetId,gitIdentity:s};await Z(y);let I=i.spin("Waiting for SSH...");if(await _e(e,d),I.succeed("SSH ready"),n.repo||n.config){let N=i.spin("Provisioning (runtime, repo, deps)..."),w=36e4,x=5e3,G=Date.now(),te=!1;for(;Date.now()-G<w;){try{if((await b({instanceName:e,ip:d,command:"test -f /root/.gibil-ready && echo ready || echo waiting",timeoutMs:1e4})).stdout.trim()==="ready"){te=!0;break}}catch{}await new Promise(D=>setTimeout(D,x))}if(te)N.succeed("Provisioning complete");else{N.fail("Provisioning may have failed");try{let D=await b({instanceName:e,ip:d,command:"tail -20 /var/log/cloud-init-output.log 2>/dev/null || echo 'No cloud-init log found'",timeoutMs:1e4});i.info(D.stdout)}catch{i.warn("Could not read cloud-init log.")}}}return y}catch(s){throw i.error(`Failed to create instance "${e}", cleaning up...`),l&&await t.destroyServer(l).catch(c=>i.warn(`Could not destroy Hetzner server ${l}: ${c instanceof Error?c.message:String(c)}`)),r&&await t.deleteSSHKey(r.id).catch(c=>i.warn(`Could not delete Hetzner SSH key ${r.id}: ${c instanceof Error?c.message:String(c)}`)),await oe(e).catch(c=>i.warn(`Could not clean up local SSH keys: ${c instanceof Error?c.message:String(c)}`)),s}}function $t(t){let e=Math.max(0,Math.floor((new Date(t.expiresAt).getTime()-Date.now())/1e3));return{name:t.name,ip:t.ip,ssh:`ssh -i ${t.keyPath} -o StrictHostKeyChecking=no root@${t.ip}`,status:t.status,ttl_remaining:e,created_at:t.createdAt,fleet_id:t.fleetId}}function xt(t){t.command("create").description("Spin up one or more ephemeral dev machines").option("-n, --name <name>","Instance name").option("-f, --fleet <count>","Number of instances to create in parallel").option("-r, --repo <git-url>","Git repository to clone on startup").option("--json","Output instance info as JSON").option("--ttl <minutes>","Auto-destroy after N minutes","60").option("-c, --config <path>","Path to .gibil.yml config").option("--server-type <type>","Hetzner server type (e.g. cpx11, cpx21)").option("--location <loc>","Hetzner location (e.g. fsn1, nbg1)").option("-q, --quiet","Suppress non-essential output").option("-e, --env <KEY=VALUE...>","Environment variables to set on the server").option("--agent <name>","Install a coding agent (claude, aider, codex)").action(async e=>{e.json&&$(!0);let n=me(e.ttl??"60","TTL");if(n>10080)throw new Error("TTL cannot exceed 7 days (10080 minutes).");let o=me(e.fleet??"1","Fleet count");if(o>20)throw new Error("Fleet size cannot exceed 20. Contact support for higher limits.");if(e.name&&Ae(e.name),!e.agent){let a=await pe();a&&(e.agent=a)}if(e.agent&&!M.includes(e.agent))throw new Error(`Unknown agent "${e.agent}". Supported: ${M.join(", ")}`);let r={};if(e.env)for(let a of e.env){let u=a.indexOf("=");if(u<=0)throw new Error(`Invalid --env format: "${a}". Use KEY=VALUE.`);r[a.slice(0,u)]=a.slice(u+1)}r.GITHUB_TOKEN&&!process.env.GITHUB_TOKEN&&(process.env.GITHUB_TOKEN=r.GITHUB_TOKEN);let l=null;e.config?l=await se(e.config):e.repo?l=await ie(e.repo)??await se(process.cwd()):l=await se(process.cwd()),Object.keys(r).length>0&&(l||(l={}),l.env={...l.env,...r});let s=await E();if(s){i.info("Verifying API key...");let a=await q(s);i.info(` Authenticated as ${a.user.email} (${a.user.plan})`)}if(e.agent&&!re[e.agent]?.some(u=>l?.env?.[u]||r[u])){let u=re[e.agent]?.join(" or ")??"";i.warn(`${e.agent} needs ${u}. SSH in and export it (recommended) or pass with --env.`)}let c=await T.create();if(o===1){let a=e.name??Te(),u=Date.now(),f=i.spin(`Forging "${a}"...`),g=await Oe(c,a,{repo:e.repo,ttlMinutes:n,config:l,serverType:e.serverType,location:e.location,agent:e.agent}),d=((Date.now()-u)/1e3).toFixed(1);f.succeed(k.createReady(a,d)),s&&await K(s,"create",g.name,e.serverType).catch(m=>i.debug(`Usage tracking failed: ${m instanceof Error?m.message:String(m)}`)),e.json?i.json($t(g)):(i.info(""),i.info($e("Server ready",[`${p("Name:")} ${h(g.name)}`,`${p("IP:")} ${g.ip}`,`${p("TTL:")} ${n} minutes`,`${p("SSH:")} ${h(`gibil ssh ${g.name}`)}`])),i.info(""),i.info(p(" Try:")),i.info(` ${h(`gibil run ${g.name} "<your test command>"`)}`),i.info(` ${h(`gibil ssh ${g.name}`)}`),i.info(` ${h(`gibil destroy ${g.name}`)}`),i.info(""))}else{let a=bt(),u=e.name??"gibil",f=Date.now(),g=i.spin(`Forging fleet "${a}" \u2014 ${o} servers...`),d=Array.from({length:o},(w,x)=>`${u}-${x+1}-${a.slice(6)}`),m=await Promise.allSettled(d.map(w=>Oe(c,w,{repo:e.repo,ttlMinutes:n,config:l,serverType:e.serverType,location:e.location,fleetId:a,agent:e.agent}))),y=[],I=[];for(let w=0;w<m.length;w++){let x=m[w];x.status==="fulfilled"?y.push(x.value):I.push(`${d[w]}: ${x.reason instanceof Error?x.reason.message:String(x.reason)}`)}let N=((Date.now()-f)/1e3).toFixed(1);if(g.succeed(k.fleetReady(y.length,o)+` ${p(`(${N}s)`)}`),s&&await Promise.all(y.map(w=>K(s,"create",w.name,e.serverType).catch(x=>i.debug(`Usage tracking failed for ${w.name}: ${x instanceof Error?x.message:String(x)}`)))),e.json)i.json({fleet_id:a,instances:y.map($t),errors:I});else{i.info("");for(let w of y)i.info(` ${O} ${h(w.name)} ${p("\u2192")} ${w.ip}`);for(let w of I)i.info(` ${ue} ${w}`);i.info("")}}})}import{spawn as Gn}from"child_process";import{spawn as Kn}from"child_process";import{existsSync as Ln}from"fs";function Xe(t){if(t.includes(":")){let e=t.split(":");if(e.length!==3)throw new Error(`Invalid port mapping "${t}". Use PORT or LOCAL:HOST:REMOTE.`);let[n,,o]=e;Ze(n,t),Ze(o,t)}else Ze(t,t)}function Ze(t,e){let n=parseInt(t,10);if(isNaN(n)||n<1||n>65535||String(n)!==t)throw new Error(`Invalid port number in "${e}". Must be 1-65535.`)}function St(t,e){if(!Ln(t.keyPath))throw new Error(`SSH key not found: ${t.keyPath}. The instance may have been destroyed.`);let n=[];for(let o of e){Xe(o);let r=o.includes(":")?o:`${o}:localhost:${o}`,l=o.includes(":")?o.split(":")[0]:o;n.push(l),Kn("ssh",["-f","-N","-L",r,"-i",t.keyPath,"-o","StrictHostKeyChecking=no","-o","UserKnownHostsFile=/dev/null","-o","LogLevel=ERROR","-o","ExitOnForwardFailure=yes",`root@${t.ip}`],{stdio:"ignore",detached:!0}).unref()}return n}function It(t){t.command("ssh <name>").description("SSH into a running ephemeral machine").option("-p, --port <ports...>","Forward local port(s) to server (e.g. --port 3000 --port 5432)").action(async(e,n)=>{let o=await _(e),r=["-A","-i",o.keyPath,"-o","StrictHostKeyChecking=no","-o","UserKnownHostsFile=/dev/null","-o","LogLevel=ERROR"];if(n.port&&n.port.length>0){for(let s of n.port){Xe(s);let c=s.includes(":")?s:`${s}:localhost:${s}`;r.push("-L",c)}i.info("");for(let s of n.port){let c=s.includes(":")?s.split(":")[0]:s;i.info(` Forwarding ${h(`localhost:${c}`)} \u2192 ${e}:${s}`)}i.info(""),i.info(p(" Tunnel active while SSH session is open. Ctrl+C to stop.")),i.info("")}r.push(`root@${o.ip}`),Gn("ssh",r,{stdio:"inherit"}).on("exit",s=>{process.exit(s??0)})})}ae();function Et(t){t.command("run <name> <command...>").description("Execute a command on a running instance").option("--json","Output result as JSON").option("--timeout <seconds>","Command timeout in seconds (default: 30)").option("-b, --background","Run in background, return job ID immediately").action(async(e,n,o)=>{o.json&&$(!0);let r=await _(e),l=n.join(" "),s=o.timeout?me(o.timeout,"Timeout")*1e3:3e4;if(o.background){let a=Pe(),u="/root/.gibil-jobs",f=`${u}/${a}.log`,g=`${u}/${a}.exit`,d=`${u}/${a}.pid`,m=`${u}/${a}.sh`,y=["#!/bin/bash",`nohup bash -c '${l.replace(/'/g,"'\\''")}' > ${f} 2>&1 &`,"BGPID=$!",`echo $BGPID > ${d}`,`(wait $BGPID 2>/dev/null; echo $? > ${g}) &`,"echo $BGPID"].join(`
|
|
19
|
+
`),I=Buffer.from(y).toString("base64"),N=`mkdir -p ${u} && echo '${I}' | base64 -d > ${m} && chmod +x ${m} && bash ${m}`,w=await b({instanceName:e,ip:r.ip,command:N,timeoutMs:1e4}),x=parseInt(w.stdout.trim(),10);isNaN(x)&&(i.error("Failed to start background job \u2014 could not capture PID"),process.exit(1)),await W({id:a,instance:e,command:l,pid:x,status:"running",startedAt:new Date().toISOString()}),o.json?i.json({job_id:a,instance:e,status:"running",pid:x}):(i.info(`Background job started: ${a} (PID ${x})`),i.info(` Poll: gibil job ${a}`));return}i.info(`Running on "${e}" (${r.ip}): ${l}`);let c=await b({instanceName:e,ip:r.ip,command:l,stream:!o.json,timeoutMs:s});o.json?i.json({instance:e,command:l,stdout:c.stdout,stderr:c.stderr,exit_code:c.exitCode}):c.exitCode!==0&&i.error(`Command exited with code ${c.exitCode}`),process.exit(c.exitCode??1)})}ae();L();async function Tt(t,e){let n=await Ce(e);i.info(`Destroying instance "${e}" (server ${n.serverId})...`);try{await t.destroyServer(n.serverId)}catch(r){i.warn(`Could not delete server ${n.serverId}: ${r instanceof Error?r.message:String(r)}`)}try{await t.deleteSSHKey(n.sshKeyId)}catch(r){i.warn(`Could not delete SSH key ${n.sshKeyId}: ${r instanceof Error?r.message:String(r)}`)}await oe(e),await Qe(e),await Ee(e);let o=await E();o&&await K(o,"destroy",e).catch(r=>i.warn(`Usage tracking failed (billing may be inaccurate): ${r instanceof Error?r.message:String(r)}`)),i.info(` ${O} ${k.destroySingle(e)}`)}function Pt(t){t.command("destroy [name]").description("Destroy a running ephemeral machine").option("-a, --all","Destroy all gibil instances").option("--json","Output result as JSON").action(async(e,n)=>{if(n.json&&$(!0),n.all){let o=await X();if(o.length===0){n.json?i.json({destroyed:[],failed:[]}):i.info(k.noInstances);return}let r=await T.create();i.info(`Destroying ${o.length} instance(s)...`);let l=await Promise.allSettled(o.map(a=>Tt(r,a.name))),s=[],c=[];for(let a=0;a<l.length;a++)if(l[a].status==="fulfilled")s.push(o[a].name);else{let u=l[a].reason;c.push(`${o[a].name}: ${u instanceof Error?u.message:String(u)}`)}n.json?i.json({destroyed:s,failed:c}):c.length===0?i.info(`
|
|
20
|
+
${k.destroyAll}`):i.info(`
|
|
21
|
+
${s.length} destroyed, ${c.length} failed`)}else{e||(i.error('Specify an instance name or use --all. Run "gibil list" to see instances.'),process.exit(1));let o=await T.create();await Tt(o,e),n.json&&i.json({destroyed:[e]})}})}function At(t){t.command("list").alias("ls").description("List all active gibil instances").option("--json","Output as JSON").action(async e=>{e.json&&$(!0);let n=await X();if(n.length===0){e.json?i.json({instances:[]}):i.info(k.noInstances);return}let o=n.map(r=>{let l=Math.max(0,Math.floor((new Date(r.expiresAt).getTime()-Date.now())/1e3));return{name:r.name,ip:r.ip,ssh:`ssh -i ${r.keyPath} -o StrictHostKeyChecking=no root@${r.ip}`,status:r.status,ttl_remaining:l,created_at:r.createdAt,fleet_id:r.fleetId}});if(e.json){i.json({instances:o});return}i.info(p(`${"NAME".padEnd(30)} ${"IP".padEnd(18)} ${"STATUS".padEnd(12)} ${"TTL".padEnd(10)} ${"AGE".padEnd(10)}`)),i.info(p("\u2500".repeat(80)));for(let r of o){let l=Nt(r.ttl_remaining),s=Yn(r.created_at),c=r.name.padEnd(30),a=r.status.padEnd(12),u=l.padEnd(10),f=s.padEnd(10),g=r.status==="running"?U(a):F(a),d=r.ttl_remaining<=300?F(u):u;i.info(`${h(c)} ${r.ip.padEnd(18)} ${g} ${d} ${p(f)}`)}i.info(`
|
|
22
|
+
${p(`${o.length} server(s)`)}`)})}function Nt(t){if(t<=0)return"expired";let e=Math.floor(t/60),n=t%60;return e>=60?`${Math.floor(e/60)}h ${e%60}m`:`${e}m ${n}s`}function Yn(t){let e=Date.now()-new Date(t).getTime(),n=Math.floor(e/1e3);return Nt(n)}function Ot(t){t.command("extend <name>").description("Extend the TTL of a running instance").requiredOption("--ttl <minutes>","New TTL in minutes from now").option("--json","Output result as JSON").action(async(e,n)=>{n.json&&$(!0);let o=await _(e),r=parseInt(n.ttl,10);(isNaN(r)||r<=0)&&(i.error("TTL must be a positive number of minutes"),process.exit(1)),await b({instanceName:e,ip:o.ip,command:["pkill -f 'sleep.*shutdown' || true",`(sleep ${r*60} && shutdown -h now) &`].join(" && ")});let l=new Date(Date.now()+r*6e4).toISOString();o.ttlMinutes=r,o.expiresAt=l,await Z(o),n.json?i.json({name:o.name,ttl_minutes:r,expires_at:l}):i.info(`\u2713 Extended "${e}" TTL to ${r} minutes (expires ${l})`)})}import{readFile as Vn}from"fs/promises";import{randomBytes as Zn}from"crypto";function Ht(t){t.command("exec <name>").description("Upload and run a local script on an instance").requiredOption("-s, --script <path>","Path to local script").option("--json","Output result as JSON").action(async(e,n)=>{n.json&&$(!0);let o=await _(e),r=await Vn(n.script,"utf-8");i.info(`Uploading and running script "${n.script}" on "${e}"...`);let l=Buffer.from(r).toString("base64"),s=`/tmp/gibil-script-${Zn(4).toString("hex")}.sh`,c=await b({instanceName:e,ip:o.ip,command:`echo '${l}' | base64 -d > ${s} && chmod +x ${s} && ${s}; EXIT=$?; rm -f ${s}; exit $EXIT`,stream:!n.json});n.json?i.json({instance:e,script:n.script,stdout:c.stdout,stderr:c.stderr,exit_code:c.exitCode}):c.exitCode!==0&&i.error(`Script exited with code ${c.exitCode}`),process.exit(c.exitCode??1)})}L();import{createInterface as Xn}from"readline";function Rt(t){let e=Xn({input:process.stdin,output:process.stderr});return new Promise(n=>{e.question(t,o=>{e.close(),n(o.trim())})})}function Mt(t){let e=t.command("auth").description("Manage authentication");e.command("login").description("Log in with your Gibil API key").option("--key <api-key>","API key (or enter interactively)").option("--json","Output result as JSON").action(async n=>{n.json&&$(!0);let o=n.key??process.env.GIBIL_API_KEY;o||(o=await Rt("Enter your API key: ")),o||(i.error("No API key provided."),process.exit(1)),o.startsWith("pk_")||(i.error('Invalid key format. API keys start with "pk_".'),process.exit(1)),i.info("Verifying API key...");try{let r=await q(o);await Ue(o),n.json?i.json({authenticated:!0,email:r.user.email,plan:r.user.plan}):(i.info(k.authSuccess),i.detail("Email",r.user.email),i.detail("Plan",r.user.plan),i.detail("Limits",`${r.limits.max_concurrent} concurrent servers, ${r.limits.remaining_hours}h remaining`))}catch(r){i.error(r instanceof Error?r.message:String(r)),process.exit(1)}}),e.command("setup").description("Configure Hetzner API token (stored in ~/.gibil/config.json)").option("--token <token>","Hetzner API token (or enter interactively)").action(async n=>{let o=n.token;o||(o=await Rt("Enter your Hetzner API token: ")),o||(i.error("No token provided."),process.exit(1));try{let l=await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${o}`}})).json();l.error&&(i.error(`Invalid token: ${l.error.message}`),process.exit(1))}catch(r){i.error(`Could not verify token: ${r instanceof Error?r.message:"Check your network."}`),process.exit(1)}await fe(o),i.success("Hetzner token saved to ~/.gibil/config.json")}),e.command("logout").description("Clear stored API key").action(async()=>{await ze(),i.info(k.authLogout)}),e.command("status").description("Show current authentication status").option("--json","Output result as JSON").action(async n=>{n.json&&$(!0);let o=await E();if(!o){n.json?i.json({authenticated:!1}):i.info(`Not logged in. Run ${h("gibil auth login")} to authenticate.`);return}try{let r=await q(o);n.json?i.json({authenticated:!0,email:r.user.email,plan:r.user.plan,limits:r.limits}):(i.success(`Authenticated as ${r.user.email}`),i.detail("Plan",r.user.plan),i.detail("Concurrent servers",String(r.limits.max_concurrent)),i.detail("Hours remaining",String(r.limits.remaining_hours)))}catch{n.json?i.json({authenticated:!1,error:"Key verification failed"}):i.error(`Stored API key is invalid. Run ${h("gibil auth login")} to re-authenticate.`)}})}L();function Dt(t){t.command("usage").description("Show current month's usage and plan limits").option("--json","Output as JSON").action(async e=>{e.json&&$(!0);let n=await E();n||(i.error('Not logged in. Run "gibil auth login" first.'),process.exit(1));try{let o=await qe(n);if(e.json)i.json(o);else{let r=Math.round(o.vm_hours_used/o.vm_hours_limit*100);i.info(`Plan: ${o.plan}`),i.info(`VM hours: ${o.vm_hours_used.toFixed(1)} / ${o.vm_hours_limit}h (${r}%)`),i.info(`Active instances: ${o.active_instances} / ${o.max_concurrent}`),r>80&&i.warn("Running low on hours. Upgrade at https://gibil.dev/pricing")}}catch(o){i.error(o instanceof Error?o.message:String(o)),process.exit(1)}})}import{McpServer as Qn}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as eo}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as v}from"zod";H();import{execSync as Re}from"child_process";import{readFileSync as to}from"fs";ae();ae();async function et(t){let e=await Y(t);if(e.status!=="running")return{status:e.status,exitCode:e.exitCode};let n=await _(e.instance),o="/root/.gibil-jobs",r=`${o}/${t}.exit`,l=`${o}/${t}.log`,c=(await b({instanceName:e.instance,ip:n.ip,command:`test -f ${r} && cat ${r} || echo RUNNING`,timeoutMs:1e4})).stdout.trim();if(c==="RUNNING")return{status:"running"};let a=parseInt(c,10),u=await b({instanceName:e.instance,ip:n.ip,command:`cat ${l} 2>/dev/null || echo ''`,timeoutMs:1e4}),f=a===0?"done":"failed",g=new Date,d=Math.round((g.getTime()-new Date(e.startedAt).getTime())/1e3);return e.status=f,e.exitCode=a,e.completedAt=g.toISOString(),await W(e),{status:f,exitCode:a,stdout:u.stdout,durationS:d}}function Kt(t){let e=t.command("job").description("Manage background jobs");e.command("status <id>").description("Check status of a background job").option("--json","Output result as JSON").action(async(n,o)=>{o.json&&$(!0);let r=await Y(n),l=await et(n);o.json?i.json({job_id:n,instance:r.instance,command:r.command,status:l.status,exit_code:l.exitCode,started_at:r.startedAt,duration_s:l.durationS,...l.stdout!==void 0?{stdout:l.stdout}:{}}):l.status==="running"?(i.info(`Job ${n} is still running on "${r.instance}"`),i.info(` Command: ${r.command}`),i.info(` Started: ${r.startedAt}`)):(i.info(`Job ${n}: ${l.status} (exit code ${l.exitCode}, ${l.durationS}s)`),l.stdout&&process.stdout.write(l.stdout))}),e.command("list").description("List all background jobs").option("--json","Output result as JSON").action(async n=>{n.json&&$(!0);let o=await he();if(o.length===0){n.json?i.json([]):i.info("No background jobs.");return}if(n.json)i.json(o.map(r=>({job_id:r.id,instance:r.instance,command:r.command,status:r.status,started_at:r.startedAt,exit_code:r.exitCode})));else for(let r of o){let l=r.status==="running"?"\u27F3 running":r.status==="done"?"\u2713 done":`\u2717 ${r.status}`;i.info(` ${r.id} ${l} ${r.instance} ${r.command}`)}}),e.command("cancel <id>").description("Cancel a running background job").option("--json","Output result as JSON").action(async(n,o)=>{o.json&&$(!0);let r=await Y(n);if(r.status!=="running"){o.json?i.json({job_id:n,status:r.status,message:"Job is not running"}):i.info(`Job ${n} is not running (status: ${r.status})`);return}let l=await _(r.instance);await b({instanceName:r.instance,ip:l.ip,command:`kill -- -${r.pid} 2>/dev/null || kill ${r.pid} 2>/dev/null || true`,timeoutMs:1e4}),r.status="cancelled",r.completedAt=new Date().toISOString(),await W(r),o.json?i.json({job_id:n,status:"cancelled"}):i.info(`Job ${n} cancelled.`)}),e.command("logs <id>").description("Fetch output of a background job").option("--json","Output result as JSON").option("-f, --follow","Follow log output (tail -f)").action(async(n,o)=>{o.json&&$(!0);let r=await Y(n),l=await _(r.instance),s=`/root/.gibil-jobs/${n}.log`,c=o.follow?`tail -f ${s}`:`cat ${s} 2>/dev/null || echo '(no output yet)'`,a=o.follow?3e5:1e4,u=await b({instanceName:r.instance,ip:l.ip,command:c,stream:!o.json,timeoutMs:a});o.json&&i.json({job_id:n,stdout:u.stdout})})}function no(){try{let t=Re("git config user.name",{encoding:"utf-8"}).trim(),e=Re("git config user.email",{encoding:"utf-8"}).trim();if(!t||!e)return;let n;try{if(Re("git config gpg.format",{encoding:"utf-8"}).trim()==="ssh"){let r=Re("git config user.signingkey",{encoding:"utf-8"}).trim();if(r)try{n=to(r,"utf-8").trim()}catch{(r.startsWith("ssh-")||r.startsWith("key::"))&&(n=r.replace(/^key::/,""))}}}catch{}return{name:t,email:e,signingKey:n}}catch{return}}async function ye(t,e){if(t)return t;if(e)return _(e);let o=(await X()).filter(r=>new Date<new Date(r.expiresAt));if(o.length===0)throw new Error("No active servers. Use create_server first.");if(o.length===1)return o[0];throw new Error(`Multiple servers running: ${o.map(r=>r.name).join(", ")}. Pass the "server" parameter to specify which one.`)}function V(t,e,n=3e4){return b({instanceName:t.name,ip:t.ip,command:e,stream:!1,timeoutMs:n})}function A(t){return`'${t.replace(/'/g,"'\\''")}'`}async function Lt(t){let e=null;if(t&&(e=await _(t),e.gitIdentity)){let{name:s,email:c,signingKey:a}=e.gitIdentity,u=[`git config --global user.name ${A(s)}`,`git config --global user.email ${A(c)}`];a&&u.push("git config --global gpg.format ssh",`git config --global user.signingkey ${A("key::"+a)}`,"git config --global commit.gpgsign true"),V(e,u.join(" && ")).catch(()=>{})}let n=t?`gibil-${t}`:"gibil",o=new Qn({name:n,version:"0.4.0"});e||(o.tool("create_server","Forge a new ephemeral server with a full Linux environment (Ubuntu 24.04, Node.js 20, pnpm). Clones the repo to /root/project and waits until fully provisioned. After creation, use vm_bash to run commands, vm_read/vm_write for files, vm_grep to search code. Destroy with destroy_server when done.",{name:v.string().optional().describe("Server name (auto-generated if omitted)"),repo:v.string().optional().describe("Git repo URL to clone on boot"),ttl:v.number().optional().describe("Auto-destroy after N minutes (default: 60)"),server_type:v.string().optional().describe("Hetzner server type (default: auto-detected)"),location:v.string().optional().describe("Hetzner datacenter (default: auto-detected)"),env:v.record(v.string(),v.string()).optional().describe("Environment variables to set on the server")},async({name:s,repo:c,ttl:a,server_type:u,location:f,env:g})=>{try{let d=s??Te();s&&Ae(s);let m=await T.create(),y=await ke(d),I=await m.createSSHKey(`gibil-${d}-${Q(4)}`,y.publicKey),N=no(),w=c?await ie(c):null;g&&Object.keys(g).length>0&&(w||(w={}),w.env={...w.env,...g});let x=(w?.services?.length??0)>0,G=a??(x?120:60),te=je({repo:c,config:w??void 0,ttlMinutes:G,githubToken:process.env.GITHUB_TOKEN,gitIdentity:N}),D=await m.createServer(d,I.id,te,u,f),ce=(await m.waitForReady(D.id)).public_net.ipv4.ip,nt=new Date,Vt={name:d,serverId:D.id,ip:ce,sshKeyId:I.id,keyPath:S.privateKey(d),status:"running",createdAt:nt.toISOString(),ttlMinutes:G,expiresAt:new Date(nt.getTime()+G*6e4).toISOString(),repo:c,gitIdentity:N};await Z(Vt),await _e(d,ce);let Me="ready";if(c||w){let Zt=Date.now(),ot=!1;for(;Date.now()-Zt<36e4;){try{if((await b({instanceName:d,ip:ce,command:"test -f /root/.gibil-ready && echo ready || echo waiting",timeoutMs:1e4})).stdout.trim()==="ready"){ot=!0;break}}catch{}await new Promise(De=>setTimeout(De,5e3))}if(!ot){Me="timeout";try{Me=`timeout \u2014 cloud-init log:
|
|
23
|
+
${(await b({instanceName:d,ip:ce,command:"tail -20 /var/log/cloud-init-output.log 2>/dev/null || echo 'no log'",timeoutMs:1e4})).stdout}`}catch{}}}return{content:[{type:"text",text:JSON.stringify({name:d,ip:ce,ttl_minutes:G,status:"running",provisioning:Me,working_directory:c?"/root/project":"/root",hint:c?'Server ready. Run commands with vm_bash, e.g.: vm_bash({ command: "pnpm test" })':"Server ready. Clone a repo or run commands with vm_bash."},null,2)}]}}catch(d){return{content:[{type:"text",text:`Failed to create server: ${d instanceof Error?d.message:String(d)}`}],isError:!0}}}),o.tool("destroy_server","Burn a server. Deletes the Hetzner VM, SSH keys, and local metadata. Always destroy servers when done to avoid costs. Works on expired instances too.",{name:v.string().describe("Name of the server to destroy")},async({name:s})=>{try{let c=await Ce(s),a=await T.create();await a.destroyServer(c.serverId).catch(()=>{}),await a.deleteSSHKey(c.sshKeyId).catch(()=>{}),await oe(s).catch(()=>{});let{deleteJobsByInstance:u}=await Promise.resolve().then(()=>(ae(),Ct));return await u(s).catch(()=>{}),await Ee(s),{content:[{type:"text",text:`Server "${s}" destroyed.`}]}}catch(c){return{content:[{type:"text",text:`Failed to destroy: ${c instanceof Error?c.message:String(c)}`}],isError:!0}}}),o.tool("list_servers","List all active gibil servers with their names, IPs, and remaining TTL.",{},async()=>{let s=await X();if(s.length===0)return{content:[{type:"text",text:"No servers running. Use create_server to forge one."}]};let c=s.map(a=>{let u=Math.max(0,Math.floor((new Date(a.expiresAt).getTime()-Date.now())/1e3));return{name:a.name,ip:a.ip,status:a.status,ttl_remaining_seconds:u,ttl_warning:u<300?"Less than 5 minutes left \u2014 extend with extend_server or finish up":void 0,repo:a.repo}});return{content:[{type:"text",text:JSON.stringify(c,null,2)}]}}),o.tool("extend_server","Extend a server's TTL. Resets the auto-destroy timer.",{name:v.string().describe("Server name"),ttl:v.number().describe("New TTL in minutes from now")},async({name:s,ttl:c})=>{try{if(c<1||c>1440)return{content:[{type:"text",text:"TTL must be between 1 and 1440 minutes (24 hours)."}],isError:!0};let a=Math.floor(c),u=await _(s),f=await V(u,`pkill -f 'sleep.*shutdown' || true && (sleep ${a*60} && shutdown -h now) &`);return f.exitCode!==0?{content:[{type:"text",text:`Failed to extend TTL: ${f.stderr}`}],isError:!0}:(u.ttlMinutes=c,u.expiresAt=new Date(Date.now()+c*6e4).toISOString(),await Z(u),{content:[{type:"text",text:`Server "${s}" TTL extended to ${c} minutes.`}]})}catch(a){return{content:[{type:"text",text:`Failed to extend: ${a instanceof Error?a.message:String(a)}`}],isError:!0}}}));let r=v.string().optional().describe("Server name (auto-selects if only one is running)");o.tool("vm_bash","Run a shell command on a remote server. Default working directory is /root/project. Use for: installing deps, running tests, git operations, builds. For commands over 2 minutes, set background=true to get a job_id you can poll with vm_job_status.",{command:v.string().describe("Shell command to execute"),working_dir:v.string().optional().describe("Working directory (default: /root/project)"),timeout_ms:v.number().optional().describe("Timeout in ms (default: 120000). Increase for long builds or test suites."),background:v.boolean().optional().describe("Run in background, return job ID for polling"),server:r},async s=>{let c=await ye(e,s.server),a=s.working_dir??"/root/project",u=`cd ${A(a)} 2>/dev/null || cd /root && ${s.command}`;if(s.background){let d=Pe(),m="/root/.gibil-jobs",y=`${m}/${d}.log`,I=`${m}/${d}.exit`,N=`${m}/${d}.pid`,w=`${m}/${d}.sh`,x=["#!/bin/bash",`nohup bash -c '${u.replace(/'/g,"'\\''")}' > ${y} 2>&1 &`,"BGPID=$!",`echo $BGPID > ${N}`,`(wait $BGPID 2>/dev/null; echo $? > ${I}) &`,"echo $BGPID"].join(`
|
|
24
|
+
`),G=Buffer.from(x).toString("base64"),te=`mkdir -p ${m} && echo '${G}' | base64 -d > ${w} && chmod +x ${w} && bash ${w}`,D=await V(c,te,1e4),ve=parseInt(D.stdout.trim(),10);return isNaN(ve)?{content:[{type:"text",text:"Failed to start background job \u2014 could not capture PID"}],isError:!0}:(await W({id:d,instance:c.name,command:s.command,pid:ve,status:"running",startedAt:new Date().toISOString()}),{content:[{type:"text",text:JSON.stringify({job_id:d,instance:c.name,status:"running",pid:ve,hint:"Poll with vm_job_status({ job_id }) to check completion."},null,2)}]})}let f=await V(c,u,s.timeout_ms??12e4);return{content:[{type:"text",text:[f.stdout,f.stderr].filter(Boolean).join(`
|
|
25
|
+
`)||"(no output)"}],isError:f.exitCode!==0}}),o.tool("vm_job_status","Check the status of a background job started with vm_bash(background=true). Returns status, exit code, and output when done.",{job_id:v.string().describe("Job ID returned by vm_bash with background=true")},async s=>{try{let c=await Y(s.job_id),a=await et(s.job_id);return{content:[{type:"text",text:JSON.stringify({job_id:s.job_id,instance:c.instance,command:c.command,status:a.status,exit_code:a.exitCode,started_at:c.startedAt,duration_s:a.durationS,...a.stdout!==void 0?{stdout:a.stdout}:{}},null,2)}],isError:a.status==="failed"}}catch(c){return{content:[{type:"text",text:`Error: ${c instanceof Error?c.message:String(c)}`}],isError:!0}}}),o.tool("vm_job_list","List all background jobs across all servers.",{},async()=>{let c=(await he()).map(a=>({job_id:a.id,instance:a.instance,command:a.command,status:a.status,started_at:a.startedAt,exit_code:a.exitCode}));return{content:[{type:"text",text:JSON.stringify(c,null,2)}]}}),o.tool("vm_read","Read a file from a remote server. Returns the file contents with line numbers.",{path:v.string().describe("Absolute path on the server (e.g. /root/project/src/app.ts)"),offset:v.number().optional().describe("Start at line N (1-based)"),limit:v.number().optional().describe("Max lines to return"),server:r},async s=>{let c=await ye(e,s.server),a=A(s.path),u=`cat -n ${a}`;s.offset&&s.limit?u=`sed -n '${s.offset},${s.offset+s.limit-1}p' ${a} | cat -n`:s.offset?u=`tail -n +${s.offset} ${a} | cat -n`:s.limit&&(u=`head -n ${s.limit} ${a} | cat -n`);let f=await V(c,u);return f.exitCode!==0?{content:[{type:"text",text:`Error: ${f.stderr}`}],isError:!0}:{content:[{type:"text",text:f.stdout}]}}),o.tool("vm_write","Write content to a file on a remote server. Creates parent directories if needed. Overwrites existing files.",{path:v.string().describe("Absolute path on the server"),content:v.string().describe("File content to write"),server:r},async s=>{let c=await ye(e,s.server),a=Buffer.from(s.content).toString("base64"),u=A(s.path),f=`mkdir -p "$(dirname ${u})" && echo '${a}' | base64 -d > ${u}`,g=await V(c,f);return g.exitCode!==0?{content:[{type:"text",text:`Error: ${g.stderr}`}],isError:!0}:{content:[{type:"text",text:`Wrote ${s.path}`}]}}),o.tool("vm_ls","List files and directories on a remote server.",{path:v.string().optional().describe("Directory path (default: /root/project)"),glob:v.string().optional().describe("Glob pattern to filter (e.g. '**/*.ts')"),server:r},async s=>{let c=await ye(e,s.server),a=s.path??"/root/project",u;s.glob?u=`cd ${A(a)} && find . -path ${A("./"+s.glob)} -type f 2>/dev/null | sort | head -200`:u=`ls -la ${A(a)}`;let f=await V(c,u);return f.exitCode!==0?{content:[{type:"text",text:`Error: ${f.stderr}`}],isError:!0}:{content:[{type:"text",text:f.stdout}]}}),o.tool("vm_grep","Search for a pattern in files on a remote server. Uses ripgrep if available, falls back to grep.",{pattern:v.string().describe("Regex pattern to search for"),path:v.string().optional().describe("Directory or file to search (default: /root/project)"),include:v.string().optional().describe("File glob to include (e.g. '*.ts')"),server:r},async s=>{let c=await ye(e,s.server),a=s.path??"/root/project",u=A(s.pattern),f=A(a),g;if(s.include){let y=A(s.include);g=`cd ${f} && (rg -n --glob ${y} ${u} 2>/dev/null || grep -rn --include=${y} ${u} .) | head -100`}else g=`cd ${f} && (rg -n ${u} 2>/dev/null || grep -rn ${u} .) | head -100`;return{content:[{type:"text",text:(await V(c,g)).stdout||"(no matches)"}]}});let l=new eo;await o.connect(l)}H();function Gt(t){t.command("mcp [name]").description("Start an MCP server (used by Claude Code, Cursor, and other agents)").option("--print-config","Print the MCP JSON config (with resolved binary path) and exit").action(async(e,n)=>{if(n.printConfig){let o={mcpServers:{gibil:xe()}};console.log(JSON.stringify(o,null,2)),console.error(""),console.error("Copy this to one of:"),console.error(" ~/.claude/mcp.json (Claude Code CLI)"),console.error(" .claude/mcp.json (project-level)"),console.error(" Claude Code settings (VS Code extension)");return}try{await Lt(e)}catch(o){i.error(o instanceof Error?o.message:String(o)),process.exit(1)}})}L();import{createInterface as oo}from"readline";import{existsSync as ro,readFileSync as io,writeFileSync as so,mkdirSync as ao}from"fs";import{join as tt}from"path";import{homedir as co}from"os";H();function Ft(t){let e=oo({input:process.stdin,output:process.stderr});return new Promise(n=>{e.question(t,o=>{e.close(),n(o.trim())})})}async function lo(){let t=!!await Je(),e=!!await E();return{hetzner:t,apiKey:e}}function Ut(t){t.command("init").description("Set up gibil \u2014 configure your forge in 60 seconds").option("--force","Reconfigure even if already set up").action(async e=>{console.error(at);let n=await lo();if(n.hetzner&&!e.force){i.info(`${O} Already configured.`),n.apiKey?(i.detail("Hetzner",U("connected")),i.detail("Gibil API",U("connected"))):(i.detail("Hetzner",U("connected")),i.detail("Gibil API",p("not configured (optional)"))),i.info(""),i.info(` Run ${h("gibil init --force")} to reconfigure.`),i.info(` Run ${h("gibil create")} to forge a server.`);return}i.info(""),i.info(h("Step 1: Hetzner API Token")),i.info(p(" Your servers run on Hetzner Cloud. You need an API token.")),i.info(p(" Get one at: https://console.hetzner.cloud \u2192 API Tokens")),i.info("");let o=await Ft(" Hetzner API token: ");o||(i.error("No token provided. Run gibil init again when ready."),process.exit(1));let r=i.spin("Verifying Hetzner token...");try{let m=await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${o}`}})).json();m.error&&(r.fail(`Invalid token: ${m.error.message}`),process.exit(1)),r.succeed("Hetzner token verified")}catch{r.fail("Could not reach Hetzner API. Check your network."),process.exit(1)}await fe(o);let l=i.spin("Detecting available server types..."),s="cax11",c="fsn1",a=[{type:"cax11",location:"fsn1"},{type:"cpx11",location:"fsn1"}];for(let d of a)try{let y=await(await fetch("https://api.hetzner.cloud/v1/servers",{method:"POST",headers:{Authorization:`Bearer ${o}`,"Content-Type":"application/json"},body:JSON.stringify({name:"gibil-probe",server_type:d.type,image:"ubuntu-24.04",location:d.location,start_after_create:!1})})).json();if(y.server){await fetch(`https://api.hetzner.cloud/v1/servers/${y.server.id}`,{method:"DELETE",headers:{Authorization:`Bearer ${o}`}}),s=d.type,c=d.location;break}}catch{}await Be(s,c),l.succeed(`Default server type: ${s} (${c})`);let u=i.spin("Configuring MCP for Claude Code...");try{let d=tt(co(),".claude"),m=tt(d,".mcp.json");ao(d,{recursive:!0});let y={};try{y=JSON.parse(io(m,"utf-8"))}catch{}y.mcpServers||(y.mcpServers={}),y.mcpServers.gibil=xe(),so(m,JSON.stringify(y,null,2)+`
|
|
26
|
+
`),u.succeed("MCP configured for Claude Code")}catch{u.fail("Could not auto-configure MCP"),i.info(p(" Run gibil mcp --print-config for manual setup"))}i.info(""),i.info(h("Default coding agent (optional)")),i.info(p(` Install a coding agent on every server. Options: ${M.join(", ")}`)),i.info(p(" Press Enter to skip \u2014 you can always use --agent later.")),i.info("");let g=(await Ft(" Default agent [none]: ")).toLowerCase().trim();g&&M.includes(g)?(await Ie(g),i.info(` ${O} Default agent: ${U(g)}`)):g?i.info(p(` Unknown agent "${g}", skipping. Use --agent with: ${M.join(", ")}`)):(await Ie(null),i.info(p(" No default agent. Use --agent claude (or aider, codex) when creating servers."))),i.info(""),i.info(k.initComplete),i.info(""),i.info(p(" Try it now:")),i.info(` ${h('gibil branch feat/my-feature --run "pnpm test"')}`),i.info(` ${h("gibil ssh feat-my-feature")}`),i.info(` ${h("gibil destroy feat-my-feature")}`),i.info(""),i.info(p(" Or with full control:")),i.info(` ${h("gibil create --name demo --repo https://github.com/lukeed/clsx --ttl 10")}`),i.info(` ${h('gibil run demo "npm test"')}`),i.info(` ${h("gibil destroy demo")}`),i.info(""),i.info(p(" Later:")),i.info(` ${h("gibil auth login")} ${p("Add a Gibil API key (optional)")}`),i.info(` ${h("gibil mcp --print-config")} ${p("MCP setup for other editors")}`),i.info("")})}async function zt(){if(process.env.HETZNER_API_TOKEN)return!1;let t=tt(S.root,"config.json");return!ro(t)}import{execSync as uo}from"child_process";import{existsSync as we}from"fs";L();function fo(){try{let t=uo("git remote get-url origin",{encoding:"utf-8",stdio:["pipe","pipe","pipe"]}).trim();if(!t)throw new Error("empty");return t}catch{throw new Error("Not in a git repo, or no remote configured. Use --repo to specify.")}}function po(t){if(!t||!t.trim())throw new Error("Branch name cannot be empty.");if(/[;&|$`(){}<>!;"'\s\\]/.test(t))throw new Error(`Invalid branch name "${t}". Contains shell-unsafe characters.`)}function Bt(t){return t.replace(/\//g,"-").replace(/[^a-z0-9-]/gi,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").toLowerCase().slice(0,40)}function go(){return we("pnpm-lock.yaml")?"pnpm install":we("bun.lockb")||we("bun.lock")?"bun install":we("yarn.lock")?"yarn install":we("package-lock.json")?"npm install":null}async function Jt(t,e,n){let o=Bt(e),r=Date.now(),l=i.spin(`Forging "${o}" for branch ${h(e)}...`),s=await Oe(t,o,{repo:n.repo,ttlMinutes:n.ttlMinutes,config:n.config,serverType:n.serverType,location:n.location,agent:n.agent}),c=i.spin(`Checking out ${h(e)}...`);if((await b({instanceName:o,ip:s.ip,command:"cd /root/project && git rev-parse --abbrev-ref HEAD",timeoutMs:1e4})).stdout.trim()===e)c.succeed(`Already on ${e}`);else{let f=await b({instanceName:o,ip:s.ip,command:`cd /root/project && git fetch origin '${e.replace(/'/g,"'\\''")}' && git checkout '${e.replace(/'/g,"'\\''")}'`,timeoutMs:6e4});f.exitCode!==0?(c.fail(`Failed to checkout ${e}`),f.stderr&&i.info(p(f.stderr.trim()))):c.succeed(`Checked out ${e}`)}if(!(!n.noTasks&&n.config?.tasks&&n.config.tasks.length>0)){let f=go();if(f){let g=i.spin(`Installing deps (${f})...`),d=await b({instanceName:o,ip:s.ip,command:`cd /root/project && ${f}`,timeoutMs:3e5});d.exitCode!==0?(g.fail("Dep install failed"),d.stderr&&i.info(p(d.stderr.trim().slice(-500)))):g.succeed("Deps installed")}}if(n.run)if(n.port&&n.port.length>0)i.info(`Starting: ${h(n.run)} (background)`),await b({instanceName:o,ip:s.ip,command:`cd /root/project && nohup ${n.run} > /tmp/gibil-run.log 2>&1 &`,timeoutMs:3e4}),await new Promise(f=>setTimeout(f,3e3));else{i.info(""),i.info(`Running: ${h(n.run)}`);let f=await b({instanceName:o,ip:s.ip,command:`cd /root/project && ${n.run}`,stream:!n.json,timeoutMs:3e5});n.json&&i.info(f.stdout),f.exitCode!==0&&i.info(p(`Exit code: ${f.exitCode}`))}if(n.port&&n.port.length>0){let f=St(s,n.port);i.info("");for(let g of f)i.info(` ${h(`http://localhost:${g}`)} \u2192 ${o}:${g}`);i.info(""),i.info(p(" Tunnel running in background. Kill with: lsof -ti :PORT | xargs kill"))}let u=((Date.now()-r)/1e3).toFixed(1);return l.succeed(k.createReady(o,u)),n.json?console.log(JSON.stringify({name:o,branch:e,ip:s.ip,ttl_minutes:n.ttlMinutes,ssh:`gibil ssh ${o}`})):(i.info(""),i.info($e(`${e}`,[`Server: ${o}`,`Branch: ${e}`,`IP: ${s.ip}`,`TTL: ${n.ttlMinutes} minutes`,"",`SSH: gibil ssh ${o}`,`Test: gibil run ${o} "pnpm test"`,`Done: gibil destroy ${o}`]))),s}function qt(t){let e=t.command("branch <branches...>").description("Spin up a branch on a clean Linux server").option("-r, --repo <git-url>","Git repo URL (auto-detected from cwd)").option("--run <command>","Run a command after checkout").option("--ttl <minutes>","Auto-destroy after N minutes","30").option("--json","Output as JSON").option("--no-tasks","Skip .gibil.yml tasks").option("--server-type <type>","Hetzner server type").option("--location <loc>","Hetzner location").option("--agent <name>","Install a coding agent (claude, aider, codex)").option("-p, --port <ports...>","Forward local port(s) to server (e.g. --port 3000)").action(async(n,o)=>{o.json&&$(!0);for(let u of n)po(u);let r=parseInt(o.ttl,10);if(isNaN(r)||r<=0)throw new Error("TTL must be a positive number of minutes.");let l=o.repo??fo(),s=null;if(s=await ie(l)??await se(process.cwd()),!o.agent){let u=await pe();u&&(o.agent=u)}if(o.agent){if(!M.includes(o.agent))throw new Error(`Unknown agent "${o.agent}". Supported: ${M.join(", ")}`);if(!re[o.agent]?.some(f=>s?.env?.[f])){let f=re[o.agent]?.join(" or ")??"";i.warn(`${o.agent} needs ${f}. SSH in and export it (recommended) or pass with --env.`)}}let c=await E();if(c){let u=await q(c);i.info(`Authenticated as ${u.user.email} (${u.user.plan})`)}let a=await T.create();if(n.length===1){let u=await Jt(a,n[0],{repo:l,ttlMinutes:r,config:s,run:o.run,json:o.json,noTasks:o.noTasks,serverType:o.serverType,location:o.location,agent:o.agent,port:o.port});c&&await K(c,"create",u.name).catch(()=>{})}else{i.info(`Forging ${h(String(n.length))} branches in parallel...`),i.info("");let u=await Promise.allSettled(n.map(d=>Jt(a,d,{repo:l,ttlMinutes:r,config:s,run:o.run,json:o.json,noTasks:o.noTasks,serverType:o.serverType,location:o.location,agent:o.agent,port:o.port}))),f=u.filter(d=>d.status==="fulfilled"),g=u.filter(d=>d.status==="rejected");if(!o.json){if(i.info(""),i.info(`${f.length}/${n.length} branches ready.`),g.length>0)for(let d=0;d<u.length;d++){let m=u[d];m.status==="rejected"&&i.error(` ${n[d]}: ${m.reason instanceof Error?m.reason.message:String(m.reason)}`)}i.info(""),i.info(p(`Destroy all: gibil destroy ${n.map(Bt).join(" ")}`))}if(c)for(let d of u)d.status==="fulfilled"&&await K(c,"create",d.value.name).catch(()=>{});g.length>0&&process.exit(1)}})}function Wt(){let t=process.argv.indexOf("checkout");t>=2&&t===2&&(process.argv[t]="branch")}try{await import("dotenv/config")}catch{}var bo=wo(yo(import.meta.url)),Yt={version:"0.0.0"};for(let t of["../package.json","../../package.json"])try{Yt=JSON.parse(ho(vo(bo,t),"utf-8"));break}catch{}var j=new mo;j.name("gibil").description("Ephemeral dev compute for humans and AI agents").version(`${Yt.version} ${be}`,"-v, --version").addHelpText("before",`
|
|
27
|
+
${ct}
|
|
28
28
|
`).addHelpText("after",`
|
|
29
|
-
${
|
|
30
|
-
`);
|
|
29
|
+
${p("Docs:")} https://gibil.dev/docs
|
|
30
|
+
`);Ut(j);xt(j);It(j);Et(j);Pt(j);At(j);Ot(j);Ht(j);Mt(j);Dt(j);Kt(j);Gt(j);qt(j);async function $o(){Wt();let t=process.argv.slice(2);!(t.length===0||t.includes("init")||t.includes("auth")||t.includes("--help")||t.includes("-h")||t.includes("--version")||t.includes("-v")||t.includes("-V")||t.includes("mcp")||t.includes("ssh")||t.includes("run")||t.includes("exec")||t.includes("list")||t.includes("ls"))&&await zt()&&(i.info(""),i.info(k.setupNeeded),i.info(""),process.exit(1));try{await j.parseAsync(process.argv)}catch(n){n instanceof Error&&i.error(n.message),process.exit(1)}}$o();
|