gibil 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1714 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/utils/paths.ts
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+ var GIBIL_DIR, paths;
16
+ var init_paths = __esm({
17
+ "src/utils/paths.ts"() {
18
+ "use strict";
19
+ GIBIL_DIR = join(homedir(), ".gibil");
20
+ paths = {
21
+ root: GIBIL_DIR,
22
+ instances: join(GIBIL_DIR, "instances"),
23
+ keys: join(GIBIL_DIR, "keys"),
24
+ instanceFile: (name) => join(GIBIL_DIR, "instances", `${name}.json`),
25
+ keyDir: (name) => join(GIBIL_DIR, "keys", name),
26
+ privateKey: (name) => join(GIBIL_DIR, "keys", name, "id_ed25519"),
27
+ publicKey: (name) => join(GIBIL_DIR, "keys", name, "id_ed25519.pub")
28
+ };
29
+ }
30
+ });
31
+
32
+ // src/utils/auth.ts
33
+ var auth_exports = {};
34
+ __export(auth_exports, {
35
+ clearApiKey: () => clearApiKey,
36
+ fetchUsage: () => fetchUsage,
37
+ getApiKey: () => getApiKey,
38
+ getApiUrl: () => getApiUrl,
39
+ getApiUrlFromConfig: () => getApiUrlFromConfig,
40
+ getHetznerToken: () => getHetznerToken,
41
+ saveApiKey: () => saveApiKey,
42
+ saveHetznerToken: () => saveHetznerToken,
43
+ trackUsage: () => trackUsage,
44
+ verifyApiKey: () => verifyApiKey
45
+ });
46
+ import { readFile, writeFile, mkdir } from "fs/promises";
47
+ import { existsSync } from "fs";
48
+ import { join as join2 } from "path";
49
+ async function readConfig() {
50
+ if (!existsSync(CONFIG_FILE)) return {};
51
+ const raw = await readFile(CONFIG_FILE, "utf-8");
52
+ return JSON.parse(raw);
53
+ }
54
+ async function writeConfig(config) {
55
+ await mkdir(paths.root, { recursive: true });
56
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
57
+ }
58
+ async function saveApiKey(apiKey) {
59
+ const config = await readConfig();
60
+ config.api_key = apiKey;
61
+ await writeConfig(config);
62
+ }
63
+ async function getApiKey() {
64
+ if (process.env.GIBIL_API_KEY) return process.env.GIBIL_API_KEY;
65
+ const config = await readConfig();
66
+ return config.api_key ?? null;
67
+ }
68
+ async function clearApiKey() {
69
+ const config = await readConfig();
70
+ delete config.api_key;
71
+ await writeConfig(config);
72
+ }
73
+ function getApiUrl() {
74
+ return process.env.GIBIL_API_URL ?? DEFAULT_API_URL;
75
+ }
76
+ async function getApiUrlFromConfig() {
77
+ if (process.env.GIBIL_API_URL) return process.env.GIBIL_API_URL;
78
+ const config = await readConfig();
79
+ return config.api_url ?? DEFAULT_API_URL;
80
+ }
81
+ async function saveHetznerToken(token) {
82
+ const config = await readConfig();
83
+ config.hetzner_token = token;
84
+ await writeConfig(config);
85
+ }
86
+ async function getHetznerToken() {
87
+ if (process.env.HETZNER_API_TOKEN) return process.env.HETZNER_API_TOKEN;
88
+ const config = await readConfig();
89
+ return config.hetzner_token ?? null;
90
+ }
91
+ async function verifyApiKey(apiKey) {
92
+ const apiUrl = await getApiUrlFromConfig();
93
+ const res = await fetch(`${apiUrl}/auth-verify`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({ api_key: apiKey })
97
+ });
98
+ if (res.status === 401) {
99
+ throw new Error("Invalid API key. Get one at https://gibil.dev");
100
+ }
101
+ if (!res.ok) {
102
+ const text = await res.text();
103
+ throw new Error(`API error (${res.status}): ${text}`);
104
+ }
105
+ return await res.json();
106
+ }
107
+ async function trackUsage(apiKey, event, instanceName, serverType) {
108
+ const apiUrl = await getApiUrlFromConfig();
109
+ const res = await fetch(`${apiUrl}/usage-track`, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({
113
+ api_key: apiKey,
114
+ event,
115
+ instance_name: instanceName,
116
+ server_type: serverType
117
+ })
118
+ });
119
+ if (res.status === 429) {
120
+ throw new Error(
121
+ "Plan limit reached. Upgrade at https://gibil.dev/pricing"
122
+ );
123
+ }
124
+ if (!res.ok) {
125
+ const text = await res.text();
126
+ throw new Error(`Usage tracking failed (${res.status}): ${text}`);
127
+ }
128
+ }
129
+ async function fetchUsage(apiKey) {
130
+ const apiUrl = await getApiUrlFromConfig();
131
+ const res = await fetch(`${apiUrl}/usage-get`, {
132
+ headers: { Authorization: `Bearer ${apiKey}` }
133
+ });
134
+ if (!res.ok) {
135
+ const text = await res.text();
136
+ throw new Error(`Failed to fetch usage (${res.status}): ${text}`);
137
+ }
138
+ return await res.json();
139
+ }
140
+ var CONFIG_FILE, DEFAULT_API_URL;
141
+ var init_auth = __esm({
142
+ "src/utils/auth.ts"() {
143
+ "use strict";
144
+ init_paths();
145
+ CONFIG_FILE = join2(paths.root, "config.json");
146
+ DEFAULT_API_URL = "https://zopdxjruwktjyjunitrv.supabase.co/functions/v1";
147
+ }
148
+ });
149
+
150
+ // src/cli/index.ts
151
+ import { Command } from "commander";
152
+
153
+ // src/utils/logger.ts
154
+ var currentLevel = "info";
155
+ var jsonMode = false;
156
+ var LEVEL_ORDER = {
157
+ debug: 0,
158
+ info: 1,
159
+ warn: 2,
160
+ error: 3,
161
+ silent: 4
162
+ };
163
+ function setJsonMode(enabled) {
164
+ jsonMode = enabled;
165
+ }
166
+ function shouldLog(level) {
167
+ if (jsonMode && level !== "error") return false;
168
+ return LEVEL_ORDER[level] >= LEVEL_ORDER[currentLevel];
169
+ }
170
+ var logger = {
171
+ debug(msg, ...args) {
172
+ if (shouldLog("debug")) console.debug(`[debug] ${msg}`, ...args);
173
+ },
174
+ info(msg, ...args) {
175
+ if (shouldLog("info")) console.log(msg, ...args);
176
+ },
177
+ warn(msg, ...args) {
178
+ if (shouldLog("warn")) console.warn(`\u26A0 ${msg}`, ...args);
179
+ },
180
+ error(msg, ...args) {
181
+ if (shouldLog("error")) console.error(`\u2716 ${msg}`, ...args);
182
+ },
183
+ /** Output JSON to stdout — always works regardless of log level */
184
+ json(data) {
185
+ console.log(JSON.stringify(data, null, 2));
186
+ }
187
+ };
188
+
189
+ // src/providers/hetzner.ts
190
+ var API_BASE = "https://api.hetzner.cloud/v1";
191
+ var HetznerProvider = class _HetznerProvider {
192
+ token;
193
+ constructor(token) {
194
+ this.token = token;
195
+ }
196
+ static async create(token) {
197
+ const { getHetznerToken: getHetznerToken2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
198
+ const t = token ?? await getHetznerToken2();
199
+ if (!t) {
200
+ throw new Error(
201
+ "HETZNER_API_TOKEN is required. Run 'gibil auth setup' or set it in your environment."
202
+ );
203
+ }
204
+ return new _HetznerProvider(t);
205
+ }
206
+ // ── Low-level HTTP ──
207
+ async request(method, path, body) {
208
+ const url = `${API_BASE}${path}`;
209
+ logger.debug(`${method} ${url}`);
210
+ const res = await fetch(url, {
211
+ method,
212
+ headers: {
213
+ Authorization: `Bearer ${this.token}`,
214
+ "Content-Type": "application/json"
215
+ },
216
+ body: body ? JSON.stringify(body) : void 0
217
+ });
218
+ if (!res.ok) {
219
+ const text = await res.text();
220
+ let errorMsg;
221
+ try {
222
+ const parsed = JSON.parse(text);
223
+ errorMsg = parsed.error?.message ?? text;
224
+ } catch {
225
+ errorMsg = text;
226
+ }
227
+ throw new Error(`Hetzner API error (${res.status}): ${errorMsg}`);
228
+ }
229
+ if (res.status === 204) return {};
230
+ return await res.json();
231
+ }
232
+ // ── Server operations ──
233
+ async createServer(name, sshKeyId, userData, serverType = "cax11", location = "fsn1") {
234
+ const payload = {
235
+ name,
236
+ server_type: serverType,
237
+ image: "ubuntu-24.04",
238
+ ssh_keys: [sshKeyId],
239
+ labels: { gibil: "true", "gibil-name": name },
240
+ location
241
+ };
242
+ if (userData) {
243
+ payload.user_data = userData;
244
+ }
245
+ const res = await this.request(
246
+ "POST",
247
+ "/servers",
248
+ payload
249
+ );
250
+ return res.server;
251
+ }
252
+ async destroyServer(serverId) {
253
+ await this.request("DELETE", `/servers/${serverId}`);
254
+ }
255
+ async getServer(serverId) {
256
+ const res = await this.request(
257
+ "GET",
258
+ `/servers/${serverId}`
259
+ );
260
+ return res.server;
261
+ }
262
+ async listServers(labelSelector = "gibil=true") {
263
+ const res = await this.request(
264
+ "GET",
265
+ `/servers?label_selector=${encodeURIComponent(labelSelector)}&per_page=50`
266
+ );
267
+ return res.servers;
268
+ }
269
+ async waitForReady(serverId, timeoutMs = 12e4) {
270
+ const start = Date.now();
271
+ const pollInterval = 3e3;
272
+ while (Date.now() - start < timeoutMs) {
273
+ const server = await this.getServer(serverId);
274
+ if (server.status === "running" && server.public_net.ipv4.ip !== "0.0.0.0") {
275
+ return server;
276
+ }
277
+ logger.debug(
278
+ `Server ${serverId} status: ${server.status}, waiting...`
279
+ );
280
+ await new Promise((r) => setTimeout(r, pollInterval));
281
+ }
282
+ throw new Error(
283
+ `Server ${serverId} did not become ready within ${timeoutMs / 1e3}s`
284
+ );
285
+ }
286
+ // ── SSH key operations ──
287
+ async createSSHKey(name, publicKey) {
288
+ const res = await this.request(
289
+ "POST",
290
+ "/ssh_keys",
291
+ { name, public_key: publicKey }
292
+ );
293
+ return res.ssh_key;
294
+ }
295
+ async deleteSSHKey(keyId) {
296
+ await this.request("DELETE", `/ssh_keys/${keyId}`);
297
+ }
298
+ };
299
+
300
+ // src/ssh/keys.ts
301
+ init_paths();
302
+ import { mkdir as mkdir2, rm, readFile as readFile2, chmod } from "fs/promises";
303
+ import { existsSync as existsSync2 } from "fs";
304
+ import { execFile } from "child_process";
305
+ import { promisify } from "util";
306
+ var execFileAsync = promisify(execFile);
307
+ async function generateInstanceKeys(name) {
308
+ const keyDir = paths.keyDir(name);
309
+ if (existsSync2(keyDir)) {
310
+ await rm(keyDir, { recursive: true });
311
+ }
312
+ await mkdir2(keyDir, { recursive: true });
313
+ const privateKeyPath = paths.privateKey(name);
314
+ const publicKeyPath = paths.publicKey(name);
315
+ await execFileAsync("ssh-keygen", [
316
+ "-t",
317
+ "ed25519",
318
+ "-f",
319
+ privateKeyPath,
320
+ "-N",
321
+ "",
322
+ // no passphrase
323
+ "-C",
324
+ `gibil-${name}`
325
+ ]);
326
+ await chmod(privateKeyPath, 384);
327
+ const publicKeyContent = await readFile2(publicKeyPath, "utf-8");
328
+ return {
329
+ privateKeyPath,
330
+ publicKeyPath,
331
+ publicKey: publicKeyContent.trim()
332
+ };
333
+ }
334
+ async function deleteInstanceKeys(name) {
335
+ const keyDir = paths.keyDir(name);
336
+ if (existsSync2(keyDir)) {
337
+ await rm(keyDir, { recursive: true });
338
+ }
339
+ }
340
+
341
+ // src/ssh/exec.ts
342
+ init_paths();
343
+ import { Client } from "ssh2";
344
+ import { readFile as readFile3 } from "fs/promises";
345
+ async function sshExec(opts) {
346
+ const { instanceName, ip, command, stream = false, timeoutMs = 3e4 } = opts;
347
+ const privateKey = await readFile3(paths.privateKey(instanceName), "utf-8");
348
+ return new Promise((resolve, reject) => {
349
+ const conn = new Client();
350
+ let stdout = "";
351
+ let stderr = "";
352
+ conn.on("ready", () => {
353
+ logger.debug(`SSH connected to ${ip}`);
354
+ conn.exec(command, (err, channel) => {
355
+ if (err) {
356
+ conn.end();
357
+ return reject(err);
358
+ }
359
+ channel.on("data", (data) => {
360
+ const text = data.toString();
361
+ stdout += text;
362
+ if (stream) process.stdout.write(text);
363
+ });
364
+ channel.stderr.on("data", (data) => {
365
+ const text = data.toString();
366
+ stderr += text;
367
+ if (stream) process.stderr.write(text);
368
+ });
369
+ channel.on("close", (code) => {
370
+ conn.end();
371
+ resolve({ stdout, stderr, exitCode: code ?? 0 });
372
+ });
373
+ });
374
+ }).on("error", (err) => {
375
+ let hint = "";
376
+ if (err.code === "ECONNREFUSED") {
377
+ hint = " (instance may have been destroyed or is still booting)";
378
+ } else if (err.code === "EHOSTUNREACH") {
379
+ hint = " (IP unreachable \u2014 instance may not be running)";
380
+ } else if (err.code === "ETIMEDOUT") {
381
+ hint = " (connection timed out \u2014 check if instance is running with 'gibil list')";
382
+ }
383
+ reject(
384
+ new Error(`SSH connection to ${ip} failed: ${err.message}${hint}`)
385
+ );
386
+ }).connect({
387
+ host: ip,
388
+ port: 22,
389
+ username: "root",
390
+ privateKey,
391
+ readyTimeout: timeoutMs,
392
+ // Retry-friendly: don't reject on first attempt
393
+ hostVerifier: () => true,
394
+ // Forward local SSH agent for commit signing
395
+ agent: process.env.SSH_AUTH_SOCK,
396
+ agentForward: true
397
+ });
398
+ });
399
+ }
400
+ async function waitForSSH(instanceName, ip, timeoutMs = 12e4) {
401
+ const start = Date.now();
402
+ const retryInterval = 5e3;
403
+ while (Date.now() - start < timeoutMs) {
404
+ try {
405
+ await sshExec({
406
+ instanceName,
407
+ ip,
408
+ command: "echo ready",
409
+ timeoutMs: 1e4
410
+ });
411
+ return;
412
+ } catch {
413
+ logger.debug(`SSH not ready on ${ip}, retrying...`);
414
+ await new Promise((r) => setTimeout(r, retryInterval));
415
+ }
416
+ }
417
+ throw new Error(`SSH did not become available on ${ip} within ${timeoutMs / 1e3}s`);
418
+ }
419
+
420
+ // src/config/cloud-init.ts
421
+ function generateCloudInit(opts) {
422
+ const { repo, config, ttlMinutes, githubToken, gitIdentity } = opts;
423
+ const lines = [
424
+ "#!/bin/bash",
425
+ "set -euo pipefail",
426
+ "",
427
+ "# \u2500\u2500 Gibil cloud-init \u2500\u2500",
428
+ "export HOME=/root",
429
+ "export DEBIAN_FRONTEND=noninteractive"
430
+ ];
431
+ if (githubToken) {
432
+ lines.push(`export GITHUB_TOKEN=${shellEscape(githubToken)}`);
433
+ }
434
+ lines.push(
435
+ "",
436
+ "# Base packages",
437
+ "apt-get update -qq",
438
+ "apt-get install -y -qq git curl wget build-essential unzip > /dev/null 2>&1",
439
+ "",
440
+ "# Install GitHub CLI",
441
+ "if ! type gh > /dev/null 2>&1; then",
442
+ " curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null",
443
+ " chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg",
444
+ " ARCH=$(dpkg --print-architecture)",
445
+ ' 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',
446
+ " apt-get update -qq && apt-get install -y -qq gh > /dev/null 2>&1",
447
+ "fi",
448
+ ""
449
+ );
450
+ const image = config?.image ?? "node:20";
451
+ lines.push(...runtimeInstallScript(image));
452
+ if (config?.services && config.services.length > 0) {
453
+ lines.push(...dockerInstallScript());
454
+ lines.push("");
455
+ for (const svc of config.services) {
456
+ lines.push(...serviceStartScript(svc));
457
+ }
458
+ }
459
+ if (config?.env) {
460
+ lines.push("# Environment variables");
461
+ for (const [key, value] of Object.entries(config.env)) {
462
+ lines.push(`export ${key}=${shellEscape(value)}`);
463
+ lines.push(`echo 'export ${key}=${shellEscape(value)}' >> /root/.bashrc`);
464
+ }
465
+ lines.push("");
466
+ }
467
+ lines.push("# Configure git");
468
+ if (gitIdentity) {
469
+ lines.push(`git config --global user.email ${shellEscape(gitIdentity.email)}`);
470
+ lines.push(`git config --global user.name ${shellEscape(gitIdentity.name)}`);
471
+ if (gitIdentity.signingKey) {
472
+ lines.push(`git config --global gpg.format ssh`);
473
+ lines.push(`git config --global user.signingkey ${shellEscape("key::" + gitIdentity.signingKey)}`);
474
+ lines.push(`git config --global commit.gpgsign true`);
475
+ lines.push(`git config --global tag.gpgsign true`);
476
+ lines.push(`mkdir -p /root/.ssh`);
477
+ lines.push(`echo ${shellEscape(gitIdentity.email + " " + gitIdentity.signingKey)} > /root/.ssh/allowed_signers`);
478
+ lines.push(`git config --global gpg.ssh.allowedSignersFile /root/.ssh/allowed_signers`);
479
+ }
480
+ } else {
481
+ lines.push("git config --global user.email 'gibil@bot.dev'");
482
+ lines.push("git config --global user.name 'Gibil Bot'");
483
+ }
484
+ lines.push("");
485
+ if (repo) {
486
+ const repoMatch = repo.match(/github\.com\/([^/]+\/[^/.]+)/);
487
+ lines.push("# Clone repository");
488
+ lines.push(`cd /root`);
489
+ if (repoMatch) {
490
+ lines.push('if [ -n "${GITHUB_TOKEN:-}" ]; then');
491
+ lines.push(` CLONE_URL="https://x-access-token:\${GITHUB_TOKEN}@github.com/${repoMatch[1]}.git"`);
492
+ lines.push("else");
493
+ lines.push(` CLONE_URL=${shellEscape(repo)}`);
494
+ lines.push("fi");
495
+ lines.push(`timeout 300 git clone "$CLONE_URL" /root/project || { echo "Git clone failed or timed out"; exit 1; }`);
496
+ } else {
497
+ lines.push(`timeout 300 git clone ${shellEscape(repo)} /root/project || { echo "Git clone failed or timed out"; exit 1; }`);
498
+ }
499
+ lines.push(`cd /root/project`);
500
+ lines.push("");
501
+ lines.push('if [ -n "${GITHUB_TOKEN:-}" ]; then');
502
+ lines.push(' echo "${GITHUB_TOKEN}" | gh auth login --with-token 2>/dev/null || true');
503
+ if (repoMatch) {
504
+ lines.push(` git -C /root/project remote set-url origin "https://x-access-token:\${GITHUB_TOKEN}@github.com/${repoMatch[1]}.git"`);
505
+ }
506
+ lines.push("fi");
507
+ lines.push("");
508
+ }
509
+ if (ttlMinutes && ttlMinutes > 0) {
510
+ lines.push("# Auto-destroy after TTL");
511
+ lines.push(`echo "shutdown -h now" | at now + ${ttlMinutes} minutes 2>/dev/null || true`);
512
+ lines.push(
513
+ `(sleep ${ttlMinutes * 60} && shutdown -h now) &`
514
+ );
515
+ lines.push("");
516
+ }
517
+ lines.push("# Signal that infrastructure is ready");
518
+ lines.push("touch /root/.gibil-ready");
519
+ lines.push('echo "Gibil infrastructure ready"');
520
+ lines.push("");
521
+ if (repo && config?.tasks && config.tasks.length > 0) {
522
+ lines.push("# Run project tasks");
523
+ lines.push("cd /root/project");
524
+ for (const task of config.tasks) {
525
+ lines.push(`echo '\u25B6 Running task: '${shellEscape(task.name)}`);
526
+ lines.push(`if ! ${task.command}; then`);
527
+ lines.push(` echo '\u2717 Task failed: '${shellEscape(task.name)}`);
528
+ lines.push(` touch /root/.gibil-tasks-failed`);
529
+ lines.push(`fi`);
530
+ }
531
+ lines.push("");
532
+ lines.push("# Signal tasks complete");
533
+ lines.push("if [ ! -f /root/.gibil-tasks-failed ]; then");
534
+ lines.push(" touch /root/.gibil-tasks-done");
535
+ lines.push(' echo "Gibil tasks complete"');
536
+ lines.push("else");
537
+ lines.push(' echo "Gibil tasks finished with errors"');
538
+ lines.push("fi");
539
+ }
540
+ return lines.join("\n");
541
+ }
542
+ function runtimeInstallScript(image) {
543
+ const lines = [];
544
+ if (image.startsWith("node:")) {
545
+ const version = image.split(":")[1] ?? "20";
546
+ lines.push("# Install Node.js");
547
+ lines.push(
548
+ `curl -fsSL https://deb.nodesource.com/setup_${version}.x | bash - > /dev/null 2>&1`
549
+ );
550
+ lines.push("apt-get install -y -qq nodejs > /dev/null 2>&1");
551
+ lines.push("npm install -g pnpm@latest > /dev/null 2>&1");
552
+ lines.push("");
553
+ } else if (image.startsWith("python:")) {
554
+ const version = image.split(":")[1] ?? "3.12";
555
+ lines.push("# Install Python");
556
+ lines.push(
557
+ `apt-get install -y -qq python${version} python3-pip python3-venv > /dev/null 2>&1`
558
+ );
559
+ lines.push("");
560
+ } else if (image.startsWith("go:")) {
561
+ const version = image.split(":")[1] ?? "1.22";
562
+ lines.push("# Install Go");
563
+ lines.push(
564
+ `wget -q https://go.dev/dl/go${version}.linux-amd64.tar.gz -O /tmp/go.tar.gz`
565
+ );
566
+ lines.push("tar -C /usr/local -xzf /tmp/go.tar.gz");
567
+ lines.push("export PATH=$PATH:/usr/local/go/bin");
568
+ lines.push('echo "export PATH=\\$PATH:/usr/local/go/bin" >> /root/.bashrc');
569
+ lines.push("");
570
+ } else {
571
+ lines.push("# Install Node.js (default)");
572
+ lines.push(
573
+ "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1"
574
+ );
575
+ lines.push("apt-get install -y -qq nodejs > /dev/null 2>&1");
576
+ lines.push("npm install -g pnpm@latest > /dev/null 2>&1");
577
+ lines.push("");
578
+ }
579
+ return lines;
580
+ }
581
+ function dockerInstallScript() {
582
+ return [
583
+ "# Install Docker",
584
+ "curl -fsSL https://get.docker.com | sh > /dev/null 2>&1",
585
+ "systemctl enable docker --now"
586
+ ];
587
+ }
588
+ function serviceStartScript(svc) {
589
+ const lines = [];
590
+ lines.push(`# Start service: ${svc.name.replace(/[^a-zA-Z0-9_-]/g, "")}`);
591
+ const safeName = svc.name.replace(/[^a-zA-Z0-9_-]/g, "");
592
+ let dockerCmd = `docker run -d --name ${safeName}`;
593
+ if (svc.port) {
594
+ dockerCmd += ` -p ${svc.port}:${svc.port}`;
595
+ }
596
+ if (svc.env) {
597
+ for (const [key, value] of Object.entries(svc.env)) {
598
+ dockerCmd += ` -e ${key}=${shellEscape(value)}`;
599
+ }
600
+ }
601
+ dockerCmd += ` ${svc.image}`;
602
+ lines.push(dockerCmd);
603
+ lines.push("");
604
+ return lines;
605
+ }
606
+ function shellEscape(s) {
607
+ return `'${s.replace(/'/g, "'\\''")}'`;
608
+ }
609
+
610
+ // src/config/parser.ts
611
+ import { readFile as readFile4 } from "fs/promises";
612
+ import { existsSync as existsSync3, statSync } from "fs";
613
+ import { join as join3 } from "path";
614
+ import { parse as parseYaml } from "yaml";
615
+ var CONFIG_FILENAME = ".gibil.yml";
616
+ async function parseConfig(pathOrDir) {
617
+ let configPath;
618
+ if (existsSync3(pathOrDir) && statSync(pathOrDir).isFile()) {
619
+ configPath = pathOrDir;
620
+ } else {
621
+ configPath = join3(pathOrDir, CONFIG_FILENAME);
622
+ }
623
+ if (!existsSync3(configPath)) return null;
624
+ const raw = await readFile4(configPath, "utf-8");
625
+ const parsed = parseYaml(raw);
626
+ return validateConfig(parsed);
627
+ }
628
+ function parseConfigString(yamlContent) {
629
+ const parsed = parseYaml(yamlContent);
630
+ return validateConfig(parsed);
631
+ }
632
+ function validateConfig(raw) {
633
+ if (!raw || typeof raw !== "object") {
634
+ throw new Error("Invalid .gibil.yml: must be a YAML object");
635
+ }
636
+ const config = raw;
637
+ const result = {};
638
+ if (typeof config.name === "string") result.name = config.name;
639
+ if (typeof config.image === "string") result.image = config.image;
640
+ if (typeof config.server_type === "string")
641
+ result.server_type = config.server_type;
642
+ if (typeof config.location === "string") result.location = config.location;
643
+ if (Array.isArray(config.services)) {
644
+ result.services = config.services.map((s) => {
645
+ const svc = s;
646
+ if (typeof svc.name !== "string" || typeof svc.image !== "string") {
647
+ throw new Error(
648
+ "Each service must have a 'name' and 'image' field"
649
+ );
650
+ }
651
+ return {
652
+ name: svc.name,
653
+ image: svc.image,
654
+ port: typeof svc.port === "number" ? svc.port : void 0,
655
+ env: validateEnvRecord(svc.env, `service "${svc.name}"`)
656
+ };
657
+ });
658
+ }
659
+ if (Array.isArray(config.tasks)) {
660
+ result.tasks = config.tasks.map((t) => {
661
+ const task = t;
662
+ if (typeof task.name !== "string" || typeof task.command !== "string") {
663
+ throw new Error(
664
+ "Each task must have a 'name' and 'command' field"
665
+ );
666
+ }
667
+ return { name: task.name, command: task.command };
668
+ });
669
+ }
670
+ if (config.env !== void 0) {
671
+ result.env = validateEnvRecord(config.env, "top-level");
672
+ }
673
+ return result;
674
+ }
675
+ function validateEnvRecord(env, context) {
676
+ if (env === void 0 || env === null) return void 0;
677
+ if (typeof env !== "object" || Array.isArray(env)) {
678
+ throw new Error(`env in ${context} must be a key-value object`);
679
+ }
680
+ const result = {};
681
+ for (const [key, value] of Object.entries(env)) {
682
+ if (typeof value === "string") {
683
+ result[key] = value;
684
+ } else if (typeof value === "number" || typeof value === "boolean") {
685
+ result[key] = String(value);
686
+ } else {
687
+ throw new Error(
688
+ `env.${key} in ${context} must be a string, number, or boolean \u2014 got ${typeof value}`
689
+ );
690
+ }
691
+ }
692
+ return Object.keys(result).length > 0 ? result : void 0;
693
+ }
694
+
695
+ // src/utils/store.ts
696
+ init_paths();
697
+ import { readFile as readFile5, writeFile as writeFile2, mkdir as mkdir3, rm as rm2, readdir } from "fs/promises";
698
+ import { existsSync as existsSync4 } from "fs";
699
+ import { join as join4 } from "path";
700
+ var InstanceStore = class {
701
+ instancesDir;
702
+ keysDir;
703
+ constructor(baseDir) {
704
+ const root = baseDir ?? paths.root;
705
+ this.instancesDir = join4(root, "instances");
706
+ this.keysDir = join4(root, "keys");
707
+ }
708
+ async ensureDirectories() {
709
+ await mkdir3(this.instancesDir, { recursive: true });
710
+ await mkdir3(this.keysDir, { recursive: true });
711
+ }
712
+ instanceFile(name) {
713
+ return join4(this.instancesDir, `${name}.json`);
714
+ }
715
+ async save(meta) {
716
+ await this.ensureDirectories();
717
+ await writeFile2(this.instanceFile(meta.name), JSON.stringify(meta, null, 2));
718
+ }
719
+ async load(name) {
720
+ const filePath = this.instanceFile(name);
721
+ if (!existsSync4(filePath)) return null;
722
+ const raw = await readFile5(filePath, "utf-8");
723
+ return JSON.parse(raw);
724
+ }
725
+ async loadOrThrow(name) {
726
+ const meta = await this.load(name);
727
+ if (!meta) {
728
+ throw new Error(
729
+ `Instance "${name}" not found. Run "gibil list" to see active instances.`
730
+ );
731
+ }
732
+ return meta;
733
+ }
734
+ /** Load and verify the instance hasn't expired */
735
+ async loadActiveOrThrow(name) {
736
+ const meta = await this.loadOrThrow(name);
737
+ if (/* @__PURE__ */ new Date() > new Date(meta.expiresAt)) {
738
+ throw new Error(
739
+ `Instance "${name}" has expired (TTL was ${meta.ttlMinutes}m). Run "gibil destroy ${name}" to clean up.`
740
+ );
741
+ }
742
+ return meta;
743
+ }
744
+ async delete(name) {
745
+ const filePath = this.instanceFile(name);
746
+ if (existsSync4(filePath)) {
747
+ await rm2(filePath);
748
+ }
749
+ }
750
+ async list() {
751
+ await this.ensureDirectories();
752
+ const files = await readdir(this.instancesDir);
753
+ const instances = [];
754
+ for (const file of files) {
755
+ if (!file.endsWith(".json")) continue;
756
+ const name = file.replace(".json", "");
757
+ const meta = await this.load(name);
758
+ if (meta) instances.push(meta);
759
+ }
760
+ return instances;
761
+ }
762
+ };
763
+ var defaultStore = new InstanceStore();
764
+ var saveInstance = (meta) => defaultStore.save(meta);
765
+ var loadInstanceOrThrow = (name) => defaultStore.loadOrThrow(name);
766
+ var loadActiveInstanceOrThrow = (name) => defaultStore.loadActiveOrThrow(name);
767
+ var deleteInstance = (name) => defaultStore.delete(name);
768
+ var listInstances = () => defaultStore.list();
769
+
770
+ // src/utils/random.ts
771
+ import { randomBytes } from "crypto";
772
+ function randomSuffix(length = 6) {
773
+ return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
774
+ }
775
+ function defaultInstanceName() {
776
+ return `gibil-${randomSuffix()}`;
777
+ }
778
+ function fleetId() {
779
+ return `fleet-${randomSuffix(8)}`;
780
+ }
781
+
782
+ // src/cli/commands/create.ts
783
+ init_paths();
784
+
785
+ // src/utils/validate.ts
786
+ var INSTANCE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/;
787
+ function validateInstanceName(name) {
788
+ if (!INSTANCE_NAME_REGEX.test(name)) {
789
+ throw new Error(
790
+ `Invalid instance name "${name}". Names must be 1-63 chars, start with alphanumeric, and contain only [a-zA-Z0-9_-].`
791
+ );
792
+ }
793
+ return name;
794
+ }
795
+ function parsePositiveInt(value, label) {
796
+ const parsed = parseInt(value, 10);
797
+ if (isNaN(parsed) || parsed <= 0) {
798
+ throw new Error(`${label} must be a positive integer, got "${value}"`);
799
+ }
800
+ return parsed;
801
+ }
802
+
803
+ // src/cli/commands/create.ts
804
+ init_auth();
805
+ import { execSync } from "child_process";
806
+ import { readFileSync } from "fs";
807
+ function getLocalGitIdentity() {
808
+ try {
809
+ const name = execSync("git config user.name", { encoding: "utf-8" }).trim();
810
+ const email = execSync("git config user.email", { encoding: "utf-8" }).trim();
811
+ if (!name || !email) return void 0;
812
+ let signingKey;
813
+ try {
814
+ const format = execSync("git config gpg.format", { encoding: "utf-8" }).trim();
815
+ if (format === "ssh") {
816
+ const keyPath = execSync("git config user.signingkey", { encoding: "utf-8" }).trim();
817
+ if (keyPath) {
818
+ try {
819
+ signingKey = readFileSync(keyPath, "utf-8").trim();
820
+ } catch {
821
+ if (keyPath.startsWith("ssh-") || keyPath.startsWith("key::")) {
822
+ signingKey = keyPath.replace(/^key::/, "");
823
+ }
824
+ }
825
+ }
826
+ }
827
+ } catch {
828
+ }
829
+ return { name, email, signingKey };
830
+ } catch {
831
+ return void 0;
832
+ }
833
+ }
834
+ async function createSingleInstance(provider, name, opts) {
835
+ logger.info(` Generating SSH keys for "${name}"...`);
836
+ const keys = await generateInstanceKeys(name);
837
+ let sshKey;
838
+ try {
839
+ logger.info(` Uploading SSH key to Hetzner...`);
840
+ sshKey = await provider.createSSHKey(`gibil-${name}`, keys.publicKey);
841
+ const gitIdentity = getLocalGitIdentity();
842
+ const userData = generateCloudInit({
843
+ repo: opts.repo,
844
+ config: opts.config ?? void 0,
845
+ ttlMinutes: opts.ttlMinutes,
846
+ githubToken: process.env.GITHUB_TOKEN,
847
+ gitIdentity
848
+ });
849
+ logger.info(` Creating server...`);
850
+ const server = await provider.createServer(
851
+ name,
852
+ sshKey.id,
853
+ userData,
854
+ opts.config?.server_type ?? opts.serverType,
855
+ opts.config?.location ?? opts.location
856
+ );
857
+ logger.info(` Waiting for server to be ready...`);
858
+ const readyServer = await provider.waitForReady(server.id);
859
+ const ip = readyServer.public_net.ipv4.ip;
860
+ const now = /* @__PURE__ */ new Date();
861
+ const meta = {
862
+ name,
863
+ serverId: server.id,
864
+ ip,
865
+ sshKeyId: sshKey.id,
866
+ keyPath: paths.privateKey(name),
867
+ status: "running",
868
+ createdAt: now.toISOString(),
869
+ ttlMinutes: opts.ttlMinutes,
870
+ expiresAt: new Date(
871
+ now.getTime() + opts.ttlMinutes * 6e4
872
+ ).toISOString(),
873
+ repo: opts.repo,
874
+ fleetId: opts.fleetId,
875
+ gitIdentity
876
+ };
877
+ await saveInstance(meta);
878
+ logger.info(` Waiting for SSH on ${ip}...`);
879
+ await waitForSSH(name, ip);
880
+ logger.info(` \u2713 Instance "${name}" is ready at ${ip}`);
881
+ return meta;
882
+ } catch (error) {
883
+ logger.error(`Failed to create instance "${name}", cleaning up...`);
884
+ await deleteInstanceKeys(name).catch(
885
+ (e) => logger.warn(`Could not clean up SSH keys: ${e instanceof Error ? e.message : String(e)}`)
886
+ );
887
+ if (sshKey) {
888
+ const keyId = sshKey.id;
889
+ await provider.deleteSSHKey(keyId).catch(
890
+ (e) => logger.warn(`Could not delete Hetzner SSH key ${keyId}: ${e instanceof Error ? e.message : String(e)}`)
891
+ );
892
+ }
893
+ throw error;
894
+ }
895
+ }
896
+ function metaToOutput(meta) {
897
+ const ttlRemaining = Math.max(
898
+ 0,
899
+ Math.floor((new Date(meta.expiresAt).getTime() - Date.now()) / 1e3)
900
+ );
901
+ return {
902
+ name: meta.name,
903
+ ip: meta.ip,
904
+ ssh: `ssh -i ${meta.keyPath} -o StrictHostKeyChecking=no root@${meta.ip}`,
905
+ status: meta.status,
906
+ ttl_remaining: ttlRemaining,
907
+ created_at: meta.createdAt,
908
+ fleet_id: meta.fleetId
909
+ };
910
+ }
911
+ async function fetchRepoConfig(repoUrl) {
912
+ const match = repoUrl.match(
913
+ /github\.com\/([^/]+)\/([^/.]+)/
914
+ );
915
+ if (!match) {
916
+ logger.debug(`Cannot fetch config from non-GitHub repo: ${repoUrl}`);
917
+ return null;
918
+ }
919
+ const [, owner, repo] = match;
920
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.gibil.yml`;
921
+ logger.debug(`Fetching config from ${rawUrl}`);
922
+ try {
923
+ const res = await fetch(rawUrl, { signal: AbortSignal.timeout(1e4) });
924
+ if (!res.ok) {
925
+ logger.debug(`No .gibil.yml found in repo (${res.status})`);
926
+ return null;
927
+ }
928
+ const content = await res.text();
929
+ return parseConfigString(content);
930
+ } catch {
931
+ logger.debug("Failed to fetch repo config, continuing without it");
932
+ return null;
933
+ }
934
+ }
935
+ function registerCreateCommand(program2) {
936
+ program2.command("create").description("Spin up one or more ephemeral dev machines").option("-n, --name <name>", "Instance name").option(
937
+ "-f, --fleet <count>",
938
+ "Number of instances to create in parallel"
939
+ ).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").action(async (opts) => {
940
+ if (opts.json) setJsonMode(true);
941
+ const ttlMinutes = parsePositiveInt(opts.ttl ?? "60", "TTL");
942
+ if (ttlMinutes > 10080) {
943
+ throw new Error("TTL cannot exceed 7 days (10080 minutes).");
944
+ }
945
+ const fleetCount = parsePositiveInt(opts.fleet ?? "1", "Fleet count");
946
+ if (fleetCount > 20) {
947
+ throw new Error("Fleet size cannot exceed 20. Contact support for higher limits.");
948
+ }
949
+ if (opts.name) validateInstanceName(opts.name);
950
+ let config = null;
951
+ if (opts.config) {
952
+ config = await parseConfig(opts.config);
953
+ } else if (opts.repo) {
954
+ config = await fetchRepoConfig(opts.repo);
955
+ } else {
956
+ config = await parseConfig(process.cwd());
957
+ }
958
+ const apiKey = await getApiKey();
959
+ if (apiKey) {
960
+ logger.info("Verifying API key...");
961
+ const auth = await verifyApiKey(apiKey);
962
+ logger.info(` Authenticated as ${auth.user.email} (${auth.user.plan})`);
963
+ }
964
+ const provider = await HetznerProvider.create();
965
+ if (fleetCount === 1) {
966
+ const name = opts.name ?? defaultInstanceName();
967
+ logger.info(`Creating instance "${name}"...`);
968
+ const meta = await createSingleInstance(provider, name, {
969
+ repo: opts.repo,
970
+ ttlMinutes,
971
+ config,
972
+ serverType: opts.serverType,
973
+ location: opts.location
974
+ });
975
+ if (apiKey) {
976
+ await trackUsage(apiKey, "create", meta.name, opts.serverType).catch(
977
+ (e) => logger.debug(`Usage tracking failed: ${e instanceof Error ? e.message : String(e)}`)
978
+ );
979
+ }
980
+ if (opts.json) {
981
+ logger.json(metaToOutput(meta));
982
+ } else {
983
+ logger.info("");
984
+ logger.info(`Instance ready:`);
985
+ logger.info(` Name: ${meta.name}`);
986
+ logger.info(` IP: ${meta.ip}`);
987
+ logger.info(
988
+ ` SSH: ssh -i ${meta.keyPath} -o StrictHostKeyChecking=no root@${meta.ip}`
989
+ );
990
+ logger.info(` TTL: ${ttlMinutes} minutes`);
991
+ logger.info("");
992
+ logger.info(`Quick connect: gibil ssh ${meta.name}`);
993
+ }
994
+ } else {
995
+ const fId = fleetId();
996
+ const baseName = opts.name ?? "gibil";
997
+ logger.info(`Creating fleet "${fId}" with ${fleetCount} instances...`);
998
+ const names = Array.from(
999
+ { length: fleetCount },
1000
+ (_, i) => `${baseName}-${i + 1}-${fId.slice(6)}`
1001
+ );
1002
+ const results = await Promise.allSettled(
1003
+ names.map(
1004
+ (name) => createSingleInstance(provider, name, {
1005
+ repo: opts.repo,
1006
+ ttlMinutes,
1007
+ config,
1008
+ serverType: opts.serverType,
1009
+ location: opts.location,
1010
+ fleetId: fId
1011
+ })
1012
+ )
1013
+ );
1014
+ const succeeded = [];
1015
+ const failed = [];
1016
+ for (let i = 0; i < results.length; i++) {
1017
+ const result = results[i];
1018
+ if (result.status === "fulfilled") {
1019
+ succeeded.push(result.value);
1020
+ } else {
1021
+ failed.push(
1022
+ `${names[i]}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`
1023
+ );
1024
+ }
1025
+ }
1026
+ if (apiKey) {
1027
+ await Promise.all(
1028
+ succeeded.map(
1029
+ (m) => trackUsage(apiKey, "create", m.name, opts.serverType).catch(
1030
+ (e) => logger.debug(`Usage tracking failed for ${m.name}: ${e instanceof Error ? e.message : String(e)}`)
1031
+ )
1032
+ )
1033
+ );
1034
+ }
1035
+ if (opts.json) {
1036
+ logger.json({
1037
+ fleet_id: fId,
1038
+ instances: succeeded.map(metaToOutput),
1039
+ errors: failed
1040
+ });
1041
+ } else {
1042
+ logger.info("");
1043
+ logger.info(
1044
+ `Fleet "${fId}": ${succeeded.length}/${fleetCount} instances created`
1045
+ );
1046
+ for (const meta of succeeded) {
1047
+ logger.info(` \u2713 ${meta.name} \u2192 ${meta.ip}`);
1048
+ }
1049
+ for (const err of failed) {
1050
+ logger.error(` \u2717 ${err}`);
1051
+ }
1052
+ }
1053
+ }
1054
+ });
1055
+ }
1056
+
1057
+ // src/cli/commands/ssh.ts
1058
+ import { spawn } from "child_process";
1059
+ function registerSSHCommand(program2) {
1060
+ program2.command("ssh <name>").description("SSH into a running ephemeral machine").action(async (name) => {
1061
+ const meta = await loadActiveInstanceOrThrow(name);
1062
+ const sshArgs = [
1063
+ "-A",
1064
+ "-i",
1065
+ meta.keyPath,
1066
+ "-o",
1067
+ "StrictHostKeyChecking=no",
1068
+ "-o",
1069
+ "UserKnownHostsFile=/dev/null",
1070
+ "-o",
1071
+ "LogLevel=ERROR",
1072
+ `root@${meta.ip}`
1073
+ ];
1074
+ const proc = spawn("ssh", sshArgs, {
1075
+ stdio: "inherit"
1076
+ });
1077
+ proc.on("exit", (code) => {
1078
+ process.exit(code ?? 0);
1079
+ });
1080
+ });
1081
+ }
1082
+
1083
+ // src/cli/commands/run.ts
1084
+ function registerRunCommand(program2) {
1085
+ program2.command("run <name> <command...>").description("Execute a command on a running instance").option("--json", "Output result as JSON").action(async (name, commandParts, opts) => {
1086
+ if (opts.json) setJsonMode(true);
1087
+ const meta = await loadActiveInstanceOrThrow(name);
1088
+ const command = commandParts.join(" ");
1089
+ logger.info(`Running on "${name}" (${meta.ip}): ${command}`);
1090
+ const result = await sshExec({
1091
+ instanceName: name,
1092
+ ip: meta.ip,
1093
+ command,
1094
+ stream: !opts.json
1095
+ });
1096
+ if (opts.json) {
1097
+ logger.json({
1098
+ instance: name,
1099
+ command,
1100
+ stdout: result.stdout,
1101
+ stderr: result.stderr,
1102
+ exit_code: result.exitCode
1103
+ });
1104
+ } else if (result.exitCode !== 0) {
1105
+ logger.error(`Command exited with code ${result.exitCode}`);
1106
+ }
1107
+ process.exit(result.exitCode ?? 1);
1108
+ });
1109
+ }
1110
+
1111
+ // src/cli/commands/destroy.ts
1112
+ init_auth();
1113
+ async function destroySingle(provider, name) {
1114
+ const meta = await loadInstanceOrThrow(name);
1115
+ logger.info(`Destroying instance "${name}" (server ${meta.serverId})...`);
1116
+ try {
1117
+ await provider.destroyServer(meta.serverId);
1118
+ } catch (error) {
1119
+ logger.warn(
1120
+ `Could not delete server ${meta.serverId}: ${error instanceof Error ? error.message : String(error)}`
1121
+ );
1122
+ }
1123
+ try {
1124
+ await provider.deleteSSHKey(meta.sshKeyId);
1125
+ } catch (error) {
1126
+ logger.warn(
1127
+ `Could not delete SSH key ${meta.sshKeyId}: ${error instanceof Error ? error.message : String(error)}`
1128
+ );
1129
+ }
1130
+ await deleteInstanceKeys(name);
1131
+ await deleteInstance(name);
1132
+ const apiKey = await getApiKey();
1133
+ if (apiKey) {
1134
+ await trackUsage(apiKey, "destroy", name).catch(
1135
+ (e) => logger.warn(`Usage tracking failed (billing may be inaccurate): ${e instanceof Error ? e.message : String(e)}`)
1136
+ );
1137
+ }
1138
+ logger.info(` \u2713 Instance "${name}" destroyed`);
1139
+ }
1140
+ function registerDestroyCommand(program2) {
1141
+ program2.command("destroy [name]").description("Destroy a running ephemeral machine").option("-a, --all", "Destroy all gibil instances").option("--json", "Output result as JSON").action(async (name, opts) => {
1142
+ if (opts.json) setJsonMode(true);
1143
+ if (opts.all) {
1144
+ const instances = await listInstances();
1145
+ if (instances.length === 0) {
1146
+ if (opts.json) {
1147
+ logger.json({ destroyed: [], failed: [] });
1148
+ } else {
1149
+ logger.info("No instances to destroy.");
1150
+ }
1151
+ return;
1152
+ }
1153
+ const provider = await HetznerProvider.create();
1154
+ logger.info(`Destroying ${instances.length} instance(s)...`);
1155
+ const results = await Promise.allSettled(
1156
+ instances.map((inst) => destroySingle(provider, inst.name))
1157
+ );
1158
+ const destroyed = [];
1159
+ const failed = [];
1160
+ for (let i = 0; i < results.length; i++) {
1161
+ if (results[i].status === "fulfilled") {
1162
+ destroyed.push(instances[i].name);
1163
+ } else {
1164
+ const reason = results[i].reason;
1165
+ failed.push(
1166
+ `${instances[i].name}: ${reason instanceof Error ? reason.message : String(reason)}`
1167
+ );
1168
+ }
1169
+ }
1170
+ if (opts.json) {
1171
+ logger.json({ destroyed, failed });
1172
+ } else {
1173
+ logger.info(
1174
+ `
1175
+ ${destroyed.length} destroyed, ${failed.length} failed`
1176
+ );
1177
+ }
1178
+ } else {
1179
+ if (!name) {
1180
+ logger.error(
1181
+ 'Specify an instance name or use --all. Run "gibil list" to see instances.'
1182
+ );
1183
+ process.exit(1);
1184
+ }
1185
+ const provider = await HetznerProvider.create();
1186
+ await destroySingle(provider, name);
1187
+ if (opts.json) {
1188
+ logger.json({ destroyed: [name] });
1189
+ }
1190
+ }
1191
+ });
1192
+ }
1193
+
1194
+ // src/cli/commands/list.ts
1195
+ function registerListCommand(program2) {
1196
+ program2.command("list").alias("ls").description("List all active gibil instances").option("--json", "Output as JSON").action(async (opts) => {
1197
+ if (opts.json) setJsonMode(true);
1198
+ const instances = await listInstances();
1199
+ if (instances.length === 0) {
1200
+ if (opts.json) {
1201
+ logger.json({ instances: [] });
1202
+ } else {
1203
+ logger.info("No active instances.");
1204
+ }
1205
+ return;
1206
+ }
1207
+ const outputs = instances.map((meta) => {
1208
+ const ttlRemaining = Math.max(
1209
+ 0,
1210
+ Math.floor(
1211
+ (new Date(meta.expiresAt).getTime() - Date.now()) / 1e3
1212
+ )
1213
+ );
1214
+ return {
1215
+ name: meta.name,
1216
+ ip: meta.ip,
1217
+ ssh: `ssh -i ${meta.keyPath} -o StrictHostKeyChecking=no root@${meta.ip}`,
1218
+ status: meta.status,
1219
+ ttl_remaining: ttlRemaining,
1220
+ created_at: meta.createdAt,
1221
+ fleet_id: meta.fleetId
1222
+ };
1223
+ });
1224
+ if (opts.json) {
1225
+ logger.json({ instances: outputs });
1226
+ return;
1227
+ }
1228
+ logger.info(
1229
+ `${"NAME".padEnd(30)} ${"IP".padEnd(18)} ${"STATUS".padEnd(12)} ${"TTL".padEnd(10)} ${"AGE".padEnd(10)}`
1230
+ );
1231
+ logger.info("\u2500".repeat(80));
1232
+ for (const inst of outputs) {
1233
+ const ttl = formatDuration(inst.ttl_remaining);
1234
+ const age = formatAge(inst.created_at);
1235
+ logger.info(
1236
+ `${inst.name.padEnd(30)} ${inst.ip.padEnd(18)} ${inst.status.padEnd(12)} ${ttl.padEnd(10)} ${age.padEnd(10)}`
1237
+ );
1238
+ }
1239
+ logger.info(`
1240
+ ${outputs.length} instance(s)`);
1241
+ });
1242
+ }
1243
+ function formatDuration(seconds) {
1244
+ if (seconds <= 0) return "expired";
1245
+ const m = Math.floor(seconds / 60);
1246
+ const s = seconds % 60;
1247
+ if (m >= 60) {
1248
+ const h = Math.floor(m / 60);
1249
+ return `${h}h ${m % 60}m`;
1250
+ }
1251
+ return `${m}m ${s}s`;
1252
+ }
1253
+ function formatAge(createdAt) {
1254
+ const diffMs = Date.now() - new Date(createdAt).getTime();
1255
+ const seconds = Math.floor(diffMs / 1e3);
1256
+ return formatDuration(seconds);
1257
+ }
1258
+
1259
+ // src/cli/commands/extend.ts
1260
+ function registerExtendCommand(program2) {
1261
+ program2.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 (name, opts) => {
1262
+ if (opts.json) setJsonMode(true);
1263
+ const meta = await loadActiveInstanceOrThrow(name);
1264
+ const ttlMinutes = parseInt(opts.ttl, 10);
1265
+ if (isNaN(ttlMinutes) || ttlMinutes <= 0) {
1266
+ logger.error("TTL must be a positive number of minutes");
1267
+ process.exit(1);
1268
+ }
1269
+ await sshExec({
1270
+ instanceName: name,
1271
+ ip: meta.ip,
1272
+ command: [
1273
+ // Kill any existing shutdown timers
1274
+ "pkill -f 'sleep.*shutdown' || true",
1275
+ // Set new timer
1276
+ `(sleep ${ttlMinutes * 60} && shutdown -h now) &`
1277
+ ].join(" && ")
1278
+ });
1279
+ const newExpiry = new Date(
1280
+ Date.now() + ttlMinutes * 6e4
1281
+ ).toISOString();
1282
+ meta.ttlMinutes = ttlMinutes;
1283
+ meta.expiresAt = newExpiry;
1284
+ await saveInstance(meta);
1285
+ if (opts.json) {
1286
+ logger.json({
1287
+ name: meta.name,
1288
+ ttl_minutes: ttlMinutes,
1289
+ expires_at: newExpiry
1290
+ });
1291
+ } else {
1292
+ logger.info(
1293
+ `\u2713 Extended "${name}" TTL to ${ttlMinutes} minutes (expires ${newExpiry})`
1294
+ );
1295
+ }
1296
+ });
1297
+ }
1298
+
1299
+ // src/cli/commands/exec-script.ts
1300
+ import { readFile as readFile6 } from "fs/promises";
1301
+ import { randomBytes as randomBytes2 } from "crypto";
1302
+ function registerExecScriptCommand(program2) {
1303
+ program2.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 (name, opts) => {
1304
+ if (opts.json) setJsonMode(true);
1305
+ const meta = await loadActiveInstanceOrThrow(name);
1306
+ const scriptContent = await readFile6(opts.script, "utf-8");
1307
+ logger.info(
1308
+ `Uploading and running script "${opts.script}" on "${name}"...`
1309
+ );
1310
+ const encoded = Buffer.from(scriptContent).toString("base64");
1311
+ const remoteScript = `/tmp/gibil-script-${randomBytes2(4).toString("hex")}.sh`;
1312
+ const result = await sshExec({
1313
+ instanceName: name,
1314
+ ip: meta.ip,
1315
+ command: `echo '${encoded}' | base64 -d > ${remoteScript} && chmod +x ${remoteScript} && ${remoteScript}; EXIT=$?; rm -f ${remoteScript}; exit $EXIT`,
1316
+ stream: !opts.json
1317
+ });
1318
+ if (opts.json) {
1319
+ logger.json({
1320
+ instance: name,
1321
+ script: opts.script,
1322
+ stdout: result.stdout,
1323
+ stderr: result.stderr,
1324
+ exit_code: result.exitCode
1325
+ });
1326
+ } else if (result.exitCode !== 0) {
1327
+ logger.error(`Script exited with code ${result.exitCode}`);
1328
+ }
1329
+ process.exit(result.exitCode ?? 1);
1330
+ });
1331
+ }
1332
+
1333
+ // src/cli/commands/auth.ts
1334
+ init_auth();
1335
+ import { createInterface } from "readline";
1336
+ function prompt(question) {
1337
+ const rl = createInterface({
1338
+ input: process.stdin,
1339
+ output: process.stderr
1340
+ // stderr so stdout stays clean for --json
1341
+ });
1342
+ return new Promise((resolve) => {
1343
+ rl.question(question, (answer) => {
1344
+ rl.close();
1345
+ resolve(answer.trim());
1346
+ });
1347
+ });
1348
+ }
1349
+ function registerAuthCommand(program2) {
1350
+ const auth = program2.command("auth").description("Manage authentication");
1351
+ auth.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 (opts) => {
1352
+ if (opts.json) setJsonMode(true);
1353
+ let apiKey = opts.key ?? process.env.GIBIL_API_KEY;
1354
+ if (!apiKey) {
1355
+ apiKey = await prompt("Enter your API key: ");
1356
+ }
1357
+ if (!apiKey) {
1358
+ logger.error("No API key provided.");
1359
+ process.exit(1);
1360
+ }
1361
+ if (!apiKey.startsWith("pk_")) {
1362
+ logger.error('Invalid key format. API keys start with "pk_".');
1363
+ process.exit(1);
1364
+ }
1365
+ logger.info("Verifying API key...");
1366
+ try {
1367
+ const result = await verifyApiKey(apiKey);
1368
+ await saveApiKey(apiKey);
1369
+ if (opts.json) {
1370
+ logger.json({
1371
+ authenticated: true,
1372
+ email: result.user.email,
1373
+ plan: result.user.plan
1374
+ });
1375
+ } else {
1376
+ logger.info(`\u2713 Logged in as ${result.user.email}`);
1377
+ logger.info(` Plan: ${result.user.plan}`);
1378
+ logger.info(
1379
+ ` Limits: ${result.limits.max_concurrent} concurrent VMs, ${result.limits.remaining_hours}h remaining`
1380
+ );
1381
+ }
1382
+ } catch (error) {
1383
+ logger.error(
1384
+ error instanceof Error ? error.message : String(error)
1385
+ );
1386
+ process.exit(1);
1387
+ }
1388
+ });
1389
+ auth.command("setup").description("Configure Hetzner API token (stored in ~/.gibil/config.json)").option("--token <token>", "Hetzner API token (or enter interactively)").action(async (opts) => {
1390
+ let token = opts.token;
1391
+ if (!token) {
1392
+ token = await prompt("Enter your Hetzner API token: ");
1393
+ }
1394
+ if (!token) {
1395
+ logger.error("No token provided.");
1396
+ process.exit(1);
1397
+ }
1398
+ try {
1399
+ const res = await fetch("https://api.hetzner.cloud/v1/servers", {
1400
+ headers: { Authorization: `Bearer ${token}` }
1401
+ });
1402
+ const data = await res.json();
1403
+ if (data.error) {
1404
+ logger.error(`Invalid token: ${data.error.message}`);
1405
+ process.exit(1);
1406
+ }
1407
+ } catch {
1408
+ logger.error("Could not verify token with Hetzner API.");
1409
+ process.exit(1);
1410
+ }
1411
+ await saveHetznerToken(token);
1412
+ logger.info("\u2713 Hetzner token saved to ~/.gibil/config.json");
1413
+ });
1414
+ auth.command("logout").description("Clear stored API key").action(async () => {
1415
+ await clearApiKey();
1416
+ logger.info("\u2713 Logged out. API key removed from ~/.gibil/config.json");
1417
+ });
1418
+ auth.command("status").description("Show current authentication status").option("--json", "Output result as JSON").action(async (opts) => {
1419
+ if (opts.json) setJsonMode(true);
1420
+ const apiKey = await getApiKey();
1421
+ if (!apiKey) {
1422
+ if (opts.json) {
1423
+ logger.json({ authenticated: false });
1424
+ } else {
1425
+ logger.info('Not logged in. Run "gibil auth login" to authenticate.');
1426
+ }
1427
+ return;
1428
+ }
1429
+ try {
1430
+ const result = await verifyApiKey(apiKey);
1431
+ if (opts.json) {
1432
+ logger.json({
1433
+ authenticated: true,
1434
+ email: result.user.email,
1435
+ plan: result.user.plan,
1436
+ limits: result.limits
1437
+ });
1438
+ } else {
1439
+ logger.info(`\u2713 Authenticated as ${result.user.email}`);
1440
+ logger.info(` Plan: ${result.user.plan}`);
1441
+ logger.info(
1442
+ ` Concurrent VMs: ${result.limits.max_concurrent}`
1443
+ );
1444
+ logger.info(
1445
+ ` Hours remaining: ${result.limits.remaining_hours}`
1446
+ );
1447
+ }
1448
+ } catch {
1449
+ if (opts.json) {
1450
+ logger.json({ authenticated: false, error: "Key verification failed" });
1451
+ } else {
1452
+ logger.error(
1453
+ 'Stored API key is invalid. Run "gibil auth login" to re-authenticate.'
1454
+ );
1455
+ }
1456
+ }
1457
+ });
1458
+ }
1459
+
1460
+ // src/cli/commands/usage.ts
1461
+ init_auth();
1462
+ function registerUsageCommand(program2) {
1463
+ program2.command("usage").description("Show current month's usage and plan limits").option("--json", "Output as JSON").action(async (opts) => {
1464
+ if (opts.json) setJsonMode(true);
1465
+ const apiKey = await getApiKey();
1466
+ if (!apiKey) {
1467
+ logger.error('Not logged in. Run "gibil auth login" first.');
1468
+ process.exit(1);
1469
+ }
1470
+ try {
1471
+ const usage = await fetchUsage(apiKey);
1472
+ if (opts.json) {
1473
+ logger.json(usage);
1474
+ } else {
1475
+ const hoursPercent = Math.round(
1476
+ usage.vm_hours_used / usage.vm_hours_limit * 100
1477
+ );
1478
+ logger.info(`Plan: ${usage.plan}`);
1479
+ logger.info(
1480
+ `VM hours: ${usage.vm_hours_used.toFixed(1)} / ${usage.vm_hours_limit}h (${hoursPercent}%)`
1481
+ );
1482
+ logger.info(
1483
+ `Active instances: ${usage.active_instances} / ${usage.max_concurrent}`
1484
+ );
1485
+ if (hoursPercent > 80) {
1486
+ logger.warn(
1487
+ "Running low on hours. Upgrade at https://gibil.dev/pricing"
1488
+ );
1489
+ }
1490
+ }
1491
+ } catch (error) {
1492
+ logger.error(
1493
+ error instanceof Error ? error.message : String(error)
1494
+ );
1495
+ process.exit(1);
1496
+ }
1497
+ });
1498
+ }
1499
+
1500
+ // src/mcp/server.ts
1501
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1502
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1503
+ import { z } from "zod";
1504
+ var instance;
1505
+ function exec(command, timeoutMs = 3e4) {
1506
+ return sshExec({
1507
+ instanceName: instance.name,
1508
+ ip: instance.ip,
1509
+ command,
1510
+ stream: false,
1511
+ timeoutMs
1512
+ });
1513
+ }
1514
+ async function startMcpServer(instanceName) {
1515
+ instance = await loadActiveInstanceOrThrow(instanceName);
1516
+ if (instance.gitIdentity) {
1517
+ const { name, email, signingKey } = instance.gitIdentity;
1518
+ const cmds = [
1519
+ `git config --global user.name '${name.replace(/'/g, "'\\''")}'`,
1520
+ `git config --global user.email '${email.replace(/'/g, "'\\''")}'`
1521
+ ];
1522
+ if (signingKey) {
1523
+ cmds.push(
1524
+ `git config --global gpg.format ssh`,
1525
+ `git config --global user.signingkey 'key::${signingKey.replace(/'/g, "'\\''")}'`,
1526
+ `git config --global commit.gpgsign true`
1527
+ );
1528
+ }
1529
+ exec(cmds.join(" && ")).catch(() => {
1530
+ });
1531
+ }
1532
+ const server = new McpServer({
1533
+ name: `gibil-${instanceName}`,
1534
+ version: "0.1.0"
1535
+ });
1536
+ server.tool(
1537
+ "vm_bash",
1538
+ "Run a shell command on the remote VM. Use for: builds, tests, git, package managers, any shell operation.",
1539
+ {
1540
+ command: z.string().describe("Shell command to execute"),
1541
+ working_dir: z.string().optional().describe("Working directory (default: /root/project)"),
1542
+ timeout_ms: z.number().optional().describe("Timeout in ms (default: 30000)")
1543
+ },
1544
+ async ({ command, working_dir, timeout_ms }) => {
1545
+ const cwd = working_dir ?? "/root/project";
1546
+ const result = await exec(
1547
+ `cd ${cwd} 2>/dev/null || cd /root && ${command}`,
1548
+ timeout_ms ?? 3e4
1549
+ );
1550
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
1551
+ return {
1552
+ content: [
1553
+ {
1554
+ type: "text",
1555
+ text: output || "(no output)"
1556
+ }
1557
+ ],
1558
+ isError: result.exitCode !== 0
1559
+ };
1560
+ }
1561
+ );
1562
+ server.tool(
1563
+ "vm_read",
1564
+ "Read a file from the remote VM. Returns the file contents.",
1565
+ {
1566
+ path: z.string().describe("Absolute path on the VM (e.g. /root/project/src/app.ts)"),
1567
+ offset: z.number().optional().describe("Start at line N (1-based)"),
1568
+ limit: z.number().optional().describe("Max lines to return")
1569
+ },
1570
+ async ({ path, offset, limit }) => {
1571
+ let cmd = `cat -n '${path.replace(/'/g, "'\\''")}'`;
1572
+ if (offset && limit) {
1573
+ cmd = `sed -n '${offset},${offset + limit - 1}p' '${path.replace(/'/g, "'\\''")}' | cat -n`;
1574
+ } else if (offset) {
1575
+ cmd = `tail -n +${offset} '${path.replace(/'/g, "'\\''")}' | cat -n`;
1576
+ } else if (limit) {
1577
+ cmd = `head -n ${limit} '${path.replace(/'/g, "'\\''")}' | cat -n`;
1578
+ }
1579
+ const result = await exec(cmd);
1580
+ if (result.exitCode !== 0) {
1581
+ return {
1582
+ content: [{ type: "text", text: `Error: ${result.stderr}` }],
1583
+ isError: true
1584
+ };
1585
+ }
1586
+ return {
1587
+ content: [{ type: "text", text: result.stdout }]
1588
+ };
1589
+ }
1590
+ );
1591
+ server.tool(
1592
+ "vm_write",
1593
+ "Write content to a file on the remote VM. Creates parent directories if needed. Overwrites existing files.",
1594
+ {
1595
+ path: z.string().describe("Absolute path on the VM"),
1596
+ content: z.string().describe("File content to write")
1597
+ },
1598
+ async ({ path, content }) => {
1599
+ const encoded = Buffer.from(content).toString("base64");
1600
+ const cmd = `mkdir -p "$(dirname '${path.replace(/'/g, "'\\''")}')" && echo '${encoded}' | base64 -d > '${path.replace(/'/g, "'\\''")}'`;
1601
+ const result = await exec(cmd);
1602
+ if (result.exitCode !== 0) {
1603
+ return {
1604
+ content: [{ type: "text", text: `Error: ${result.stderr}` }],
1605
+ isError: true
1606
+ };
1607
+ }
1608
+ return {
1609
+ content: [{ type: "text", text: `Wrote ${path}` }]
1610
+ };
1611
+ }
1612
+ );
1613
+ server.tool(
1614
+ "vm_ls",
1615
+ "List files and directories on the remote VM.",
1616
+ {
1617
+ path: z.string().optional().describe("Directory path (default: /root/project)"),
1618
+ glob: z.string().optional().describe("Glob pattern to filter (e.g. '**/*.ts')")
1619
+ },
1620
+ async ({ path, glob }) => {
1621
+ const dir = path ?? "/root/project";
1622
+ let cmd;
1623
+ if (glob) {
1624
+ cmd = `cd '${dir.replace(/'/g, "'\\''")}' && find . -path './${glob}' -type f 2>/dev/null | sort | head -200`;
1625
+ } else {
1626
+ cmd = `ls -la '${dir.replace(/'/g, "'\\''")}'`;
1627
+ }
1628
+ const result = await exec(cmd);
1629
+ if (result.exitCode !== 0) {
1630
+ return {
1631
+ content: [{ type: "text", text: `Error: ${result.stderr}` }],
1632
+ isError: true
1633
+ };
1634
+ }
1635
+ return {
1636
+ content: [{ type: "text", text: result.stdout }]
1637
+ };
1638
+ }
1639
+ );
1640
+ server.tool(
1641
+ "vm_grep",
1642
+ "Search for a pattern in files on the remote VM. Uses ripgrep if available, falls back to grep.",
1643
+ {
1644
+ pattern: z.string().describe("Regex pattern to search for"),
1645
+ path: z.string().optional().describe("Directory or file to search in (default: /root/project)"),
1646
+ include: z.string().optional().describe("File glob to include (e.g. '*.ts')")
1647
+ },
1648
+ async ({ pattern, path, include }) => {
1649
+ const dir = path ?? "/root/project";
1650
+ const safePattern = pattern.replace(/'/g, "'\\''");
1651
+ const safeDir = dir.replace(/'/g, "'\\''");
1652
+ let cmd;
1653
+ if (include) {
1654
+ const safeInclude = include.replace(/'/g, "'\\''");
1655
+ cmd = `cd '${safeDir}' && (rg -n --glob '${safeInclude}' '${safePattern}' 2>/dev/null || grep -rn --include='${safeInclude}' '${safePattern}' .) | head -100`;
1656
+ } else {
1657
+ cmd = `cd '${safeDir}' && (rg -n '${safePattern}' 2>/dev/null || grep -rn '${safePattern}' .) | head -100`;
1658
+ }
1659
+ const result = await exec(cmd);
1660
+ const output = result.stdout || "(no matches)";
1661
+ return {
1662
+ content: [{ type: "text", text: output }]
1663
+ };
1664
+ }
1665
+ );
1666
+ const transport = new StdioServerTransport();
1667
+ await server.connect(transport);
1668
+ }
1669
+
1670
+ // src/cli/commands/mcp.ts
1671
+ function registerMcpCommand(program2) {
1672
+ program2.command("mcp <name>").description(
1673
+ "Start an MCP server for a running instance (used by Claude Code)"
1674
+ ).action(async (name) => {
1675
+ try {
1676
+ await startMcpServer(name);
1677
+ } catch (error) {
1678
+ logger.error(
1679
+ error instanceof Error ? error.message : String(error)
1680
+ );
1681
+ process.exit(1);
1682
+ }
1683
+ });
1684
+ }
1685
+
1686
+ // src/cli/index.ts
1687
+ try {
1688
+ await import("dotenv/config");
1689
+ } catch {
1690
+ }
1691
+ var program = new Command();
1692
+ program.name("gibil").description("Ephemeral dev compute for humans and AI agents").version("0.1.0");
1693
+ registerCreateCommand(program);
1694
+ registerSSHCommand(program);
1695
+ registerRunCommand(program);
1696
+ registerDestroyCommand(program);
1697
+ registerListCommand(program);
1698
+ registerExtendCommand(program);
1699
+ registerExecScriptCommand(program);
1700
+ registerAuthCommand(program);
1701
+ registerUsageCommand(program);
1702
+ registerMcpCommand(program);
1703
+ async function main() {
1704
+ try {
1705
+ await program.parseAsync(process.argv);
1706
+ } catch (error) {
1707
+ if (error instanceof Error) {
1708
+ logger.error(error.message);
1709
+ }
1710
+ process.exit(1);
1711
+ }
1712
+ }
1713
+ main();
1714
+ //# sourceMappingURL=index.js.map