hackerrun 0.1.0 → 0.1.2

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 CHANGED
@@ -7,7 +7,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
7
7
  });
8
8
 
9
9
  // src/index.ts
10
- import { Command as Command9 } from "commander";
10
+ import { Command as Command11 } from "commander";
11
11
 
12
12
  // src/commands/login.ts
13
13
  import { Command } from "commander";
@@ -211,7 +211,7 @@ function sleep(ms) {
211
211
 
212
212
  // src/commands/deploy.ts
213
213
  import { Command as Command2 } from "commander";
214
- import chalk7 from "chalk";
214
+ import chalk8 from "chalk";
215
215
  import ora4 from "ora";
216
216
 
217
217
  // src/lib/ssh-cert.ts
@@ -393,6 +393,150 @@ async function testIPv6Connectivity(vmIp, timeoutMs = 2e3) {
393
393
  return result;
394
394
  }
395
395
 
396
+ // src/lib/gateway-tunnel.ts
397
+ import { spawn } from "child_process";
398
+ import * as net2 from "net";
399
+ async function findAvailablePort() {
400
+ return new Promise((resolve, reject) => {
401
+ const server = net2.createServer();
402
+ server.listen(0, () => {
403
+ const addr = server.address();
404
+ const port = typeof addr === "object" && addr ? addr.port : 0;
405
+ server.close(() => resolve(port));
406
+ });
407
+ server.on("error", reject);
408
+ });
409
+ }
410
+ async function waitForTunnel(port, timeoutMs = 1e4) {
411
+ const startTime = Date.now();
412
+ while (Date.now() - startTime < timeoutMs) {
413
+ try {
414
+ await new Promise((resolve, reject) => {
415
+ const socket = net2.createConnection(port, "localhost", () => {
416
+ socket.destroy();
417
+ resolve();
418
+ });
419
+ socket.on("error", () => {
420
+ socket.destroy();
421
+ reject();
422
+ });
423
+ socket.setTimeout(500, () => {
424
+ socket.destroy();
425
+ reject();
426
+ });
427
+ });
428
+ return;
429
+ } catch {
430
+ await new Promise((r) => setTimeout(r, 200));
431
+ }
432
+ }
433
+ throw new Error(`Tunnel failed to establish on port ${port}`);
434
+ }
435
+ async function createTunnel(vmIp, gatewayIp, options = {}) {
436
+ const { timeoutMs = 15e3 } = options;
437
+ const localPort = await findAvailablePort();
438
+ const tunnelProcess = spawn("ssh", [
439
+ "-N",
440
+ // No remote command
441
+ "-L",
442
+ `${localPort}:[${vmIp}]:22`,
443
+ // Local port forward
444
+ "-o",
445
+ "StrictHostKeyChecking=no",
446
+ "-o",
447
+ "UserKnownHostsFile=/dev/null",
448
+ "-o",
449
+ "LogLevel=ERROR",
450
+ "-o",
451
+ "ExitOnForwardFailure=yes",
452
+ "-o",
453
+ "BatchMode=yes",
454
+ // No interactive prompts
455
+ "-o",
456
+ "ServerAliveInterval=5",
457
+ // Send keepalive every 5s (more aggressive)
458
+ "-o",
459
+ "ServerAliveCountMax=12",
460
+ // Allow 12 missed keepalives (60s) before disconnect
461
+ "-o",
462
+ "TCPKeepAlive=yes",
463
+ // Enable TCP keepalive
464
+ "-o",
465
+ "Compression=no",
466
+ // Disable compression for stability
467
+ "-o",
468
+ "IPQoS=throughput",
469
+ // Optimize for throughput
470
+ "-o",
471
+ "ConnectTimeout=30",
472
+ // Connection timeout
473
+ `root@${gatewayIp}`
474
+ ], {
475
+ detached: true,
476
+ stdio: ["ignore", "ignore", "ignore"]
477
+ // Ignore all stdio to prevent buffer issues
478
+ });
479
+ tunnelProcess.unref();
480
+ await waitForTunnel(localPort, timeoutMs);
481
+ return {
482
+ process: tunnelProcess,
483
+ localPort,
484
+ vmIp,
485
+ gatewayIp
486
+ };
487
+ }
488
+ function killTunnel(tunnel) {
489
+ try {
490
+ tunnel.process.kill();
491
+ } catch {
492
+ }
493
+ }
494
+ var TunnelManager = class {
495
+ activeTunnels = /* @__PURE__ */ new Map();
496
+ /**
497
+ * Get or create a tunnel for a VM
498
+ * Tunnels are cached by a key (e.g., appName or vmIp)
499
+ */
500
+ async ensureTunnel(key, vmIp, gatewayIp, options = {}) {
501
+ const existing = this.activeTunnels.get(key);
502
+ if (existing && !existing.process.killed) {
503
+ return existing;
504
+ }
505
+ const tunnel = await createTunnel(vmIp, gatewayIp, options);
506
+ this.activeTunnels.set(key, tunnel);
507
+ return tunnel;
508
+ }
509
+ /**
510
+ * Get an existing tunnel if available
511
+ */
512
+ getTunnel(key) {
513
+ const tunnel = this.activeTunnels.get(key);
514
+ if (tunnel && !tunnel.process.killed) {
515
+ return tunnel;
516
+ }
517
+ return void 0;
518
+ }
519
+ /**
520
+ * Close a specific tunnel
521
+ */
522
+ closeTunnel(key) {
523
+ const tunnel = this.activeTunnels.get(key);
524
+ if (tunnel) {
525
+ killTunnel(tunnel);
526
+ this.activeTunnels.delete(key);
527
+ }
528
+ }
529
+ /**
530
+ * Close all tunnels
531
+ */
532
+ closeAll() {
533
+ for (const tunnel of this.activeTunnels.values()) {
534
+ killTunnel(tunnel);
535
+ }
536
+ this.activeTunnels.clear();
537
+ }
538
+ };
539
+
396
540
  // src/lib/cluster.ts
397
541
  import { execSync as execSync2 } from "child_process";
398
542
  import ora2 from "ora";
@@ -434,7 +578,7 @@ var ClusterManager = class {
434
578
  */
435
579
  async initializeCluster(options) {
436
580
  const { appName, location, vmSize, storageSize, bootImage } = options;
437
- const vmName = `${appName}-vm-${this.generateId()}`;
581
+ const vmName = `${appName}-1`;
438
582
  let spinner = ora2(`Creating VM '${vmName}' in ${location}...`).start();
439
583
  try {
440
584
  spinner.text = "Fetching platform SSH keys...";
@@ -480,9 +624,12 @@ var ClusterManager = class {
480
624
  spinner.text = "Installing Docker and Uncloud...";
481
625
  spinner.stop();
482
626
  console.log(chalk2.cyan("\nInitializing uncloud (this may take a few minutes)..."));
483
- await this.initializeUncloud(vmWithIp.ip6, appName);
627
+ await this.initializeUncloud(vmWithIp.ip6, appName, gateway?.ipv4);
484
628
  spinner = ora2("Configuring Docker for NAT64...").start();
485
- await this.configureDockerNAT64(vmWithIp.ip6);
629
+ await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
630
+ spinner.text = "Waiting for uncloud services to be ready...";
631
+ await this.waitForUncloudReady(vmWithIp.ip6, gateway?.ipv4);
632
+ this.sshCertManager.cleanupAll();
486
633
  spinner.succeed(chalk2.green(`Cluster initialized successfully`));
487
634
  return savedCluster;
488
635
  } catch (error) {
@@ -498,13 +645,13 @@ var ClusterManager = class {
498
645
  if (!cluster) {
499
646
  throw new Error(`App '${appName}' not found`);
500
647
  }
501
- const primaryNode = await this.platformClient.getPrimaryNode(appName);
502
- if (!primaryNode || !primaryNode.ipv6) {
503
- throw new Error(`Primary node not found or has no IPv6 address`);
648
+ const existingNode = cluster.nodes.find((n) => n.ipv6);
649
+ if (!existingNode || !existingNode.ipv6) {
650
+ throw new Error(`No existing node with IPv6 address found to join`);
504
651
  }
505
652
  const gateway = await this.platformClient.getGateway(cluster.location);
506
653
  const privateSubnetId = gateway?.subnetId;
507
- const vmName = `${appName}-vm-${this.generateId()}`;
654
+ const vmName = this.generateNodeName(appName, cluster.nodes);
508
655
  let spinner = ora2(`Adding node '${vmName}' to cluster...`).start();
509
656
  try {
510
657
  spinner.text = "Fetching platform SSH keys...";
@@ -531,9 +678,9 @@ var ClusterManager = class {
531
678
  spinner.text = "Joining uncloud cluster...";
532
679
  spinner.stop();
533
680
  console.log(chalk2.cyan("\nJoining uncloud cluster..."));
534
- await this.joinUncloudCluster(vmWithIp.ip6, primaryNode.ipv6, appName);
681
+ await this.joinUncloudCluster(vmWithIp.ip6, existingNode.ipv6, appName);
535
682
  spinner = ora2("Configuring Docker for NAT64...").start();
536
- await this.configureDockerNAT64(vmWithIp.ip6);
683
+ await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
537
684
  spinner.succeed(chalk2.green(`Node '${vmName}' added successfully`));
538
685
  const newNode = {
539
686
  name: vmName,
@@ -556,17 +703,60 @@ var ClusterManager = class {
556
703
  * Before running uc, we get an SSH certificate from the platform
557
704
  * and add it to the ssh-agent. This allows uc to authenticate
558
705
  * since the VM only accepts platform SSH key or signed certificates.
706
+ *
707
+ * If direct IPv6 connectivity is not available, tunnels through the gateway.
559
708
  */
560
- async initializeUncloud(vmIp, contextName) {
709
+ async initializeUncloud(vmIp, contextName, gatewayIp) {
710
+ let tunnel = null;
711
+ let targetHost = vmIp;
561
712
  try {
562
713
  await this.sshCertManager.getSession(contextName, vmIp);
563
- execSync2(`uc machine init -c "${contextName}" --no-dns root@${vmIp}`, {
564
- stdio: "inherit",
565
- timeout: 6e5
566
- // 10 min timeout
567
- });
714
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
715
+ if (!canConnectDirect) {
716
+ if (!gatewayIp) {
717
+ throw new Error("No direct IPv6 connectivity and no gateway available");
718
+ }
719
+ console.log(chalk2.dim("No direct IPv6 connectivity, tunneling through gateway..."));
720
+ tunnel = await createTunnel(vmIp, gatewayIp);
721
+ targetHost = `localhost:${tunnel.localPort}`;
722
+ }
723
+ let initSucceeded = false;
724
+ try {
725
+ execSync2(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
726
+ stdio: "inherit",
727
+ timeout: 6e5
728
+ // 10 min timeout
729
+ });
730
+ initSucceeded = true;
731
+ } catch (error) {
732
+ console.log(chalk2.yellow("\nInitial setup had errors, will attempt to recover..."));
733
+ }
734
+ if (!initSucceeded) {
735
+ const maxCaddyAttempts = 5;
736
+ for (let attempt = 1; attempt <= maxCaddyAttempts; attempt++) {
737
+ console.log(chalk2.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
738
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
739
+ try {
740
+ execSync2(`yes | uc -c "${contextName}" caddy deploy`, {
741
+ stdio: "inherit",
742
+ timeout: 12e4
743
+ });
744
+ console.log(chalk2.green("Caddy deployed successfully"));
745
+ break;
746
+ } catch (caddyError) {
747
+ if (attempt >= maxCaddyAttempts) {
748
+ throw new Error(`Caddy deployment failed after ${maxCaddyAttempts} attempts. The machine may need more time to initialize.`);
749
+ }
750
+ }
751
+ }
752
+ }
753
+ console.log(chalk2.dim("Uncloud initialization complete."));
568
754
  } catch (error) {
569
755
  throw new Error(`Failed to initialize uncloud: ${error.message}`);
756
+ } finally {
757
+ if (tunnel) {
758
+ killTunnel(tunnel);
759
+ }
570
760
  }
571
761
  }
572
762
  /**
@@ -603,8 +793,10 @@ var ClusterManager = class {
603
793
  * 1. Enable IPv6 on Docker networks so containers get IPv6 addresses
604
794
  * 2. Block IPv4 forwarding from Docker to internet so IPv4 fails immediately
605
795
  * 3. Applications then use IPv6 (NAT64) which works via the gateway
796
+ *
797
+ * If direct IPv6 connectivity is not available, tunnels through the gateway.
606
798
  */
607
- async configureDockerNAT64(vmIp) {
799
+ async configureDockerNAT64(vmIp, gatewayIp) {
608
800
  const setupScript = `#!/bin/bash
609
801
  set -e
610
802
 
@@ -666,8 +858,17 @@ systemctl start docker-ipv6-nat64
666
858
 
667
859
  echo "Docker NAT64 configuration complete"
668
860
  `;
861
+ let tunnel = null;
862
+ let sshHost = vmIp;
863
+ let sshPortArgs = "";
669
864
  try {
670
- execSync2(`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${vmIp} 'bash -s' << 'REMOTESCRIPT'
865
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
866
+ if (!canConnectDirect && gatewayIp) {
867
+ tunnel = await createTunnel(vmIp, gatewayIp);
868
+ sshHost = "localhost";
869
+ sshPortArgs = `-p ${tunnel.localPort}`;
870
+ }
871
+ execSync2(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
671
872
  ${setupScript}
672
873
  REMOTESCRIPT`, {
673
874
  stdio: "inherit",
@@ -676,6 +877,10 @@ REMOTESCRIPT`, {
676
877
  });
677
878
  } catch (error) {
678
879
  throw new Error(`Failed to configure Docker for NAT64: ${error.message}`);
880
+ } finally {
881
+ if (tunnel) {
882
+ killTunnel(tunnel);
883
+ }
679
884
  }
680
885
  }
681
886
  /**
@@ -714,7 +919,52 @@ The VM may still be provisioning. You can:
714
919
  );
715
920
  }
716
921
  /**
717
- * Generate a random ID
922
+ * Wait for uncloud services to be ready on the VM
923
+ * Specifically checks that unregistry (Docker registry at 10.210.0.1:5000) is accepting connections
924
+ * This is critical for first deploy - push will fail if unregistry isn't ready
925
+ */
926
+ async waitForUncloudReady(vmIp, gatewayIp) {
927
+ const maxAttempts = 30;
928
+ const checkInterval = 2e3;
929
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
930
+ const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
931
+ const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
932
+ console.log(chalk2.dim(" Waiting for uncloud services..."));
933
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
934
+ try {
935
+ const result = execSync2(
936
+ `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
937
+ { encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
938
+ ).trim();
939
+ if (result.includes("ready")) {
940
+ console.log(chalk2.dim(" Uncloud services ready"));
941
+ return;
942
+ }
943
+ } catch (error) {
944
+ }
945
+ if (attempt % 5 === 0) {
946
+ console.log(chalk2.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
947
+ }
948
+ if (attempt < maxAttempts) {
949
+ await this.sleep(checkInterval);
950
+ }
951
+ }
952
+ console.log(chalk2.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
953
+ }
954
+ /**
955
+ * Generate a sequential node name: appName-1, appName-2, etc.
956
+ * Finds the next available number by checking existing node names
957
+ */
958
+ generateNodeName(appName, existingNodes) {
959
+ const existingNumbers = existingNodes.map((n) => {
960
+ const match = n.name.match(new RegExp(`^${appName}-(\\d+)$`));
961
+ return match ? parseInt(match[1], 10) : 0;
962
+ }).filter((n) => n > 0);
963
+ const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
964
+ return `${appName}-${maxNumber + 1}`;
965
+ }
966
+ /**
967
+ * Generate a random ID (used for legacy naming or fallback)
718
968
  */
719
969
  generateId() {
720
970
  return Math.random().toString(36).substring(2, 9);
@@ -833,11 +1083,18 @@ var UncloudManager = class {
833
1083
  }
834
1084
  /**
835
1085
  * Check if uncloud is installed, offer to install if not
1086
+ * @param options.nonInteractive - If true, fail immediately without prompting (for CI/CD)
836
1087
  */
837
- static async ensureInstalled() {
1088
+ static async ensureInstalled(options) {
838
1089
  if (this.isInstalled()) {
839
1090
  return;
840
1091
  }
1092
+ if (options?.nonInteractive) {
1093
+ console.error(chalk4.red("\n\u274C Uncloud CLI (uc) not found\n"));
1094
+ console.error("The uncloud CLI must be installed on the build VM.");
1095
+ console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
1096
+ process.exit(1);
1097
+ }
841
1098
  console.log(chalk4.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
842
1099
  console.log("Uncloud is required to deploy and manage your apps.");
843
1100
  console.log("Learn more: https://uncloud.run\n");
@@ -951,49 +1208,71 @@ function getPlatformToken() {
951
1208
 
952
1209
  // src/lib/platform-client.ts
953
1210
  var PLATFORM_API_URL2 = process.env.HACKERRUN_API_URL || "http://localhost:3000";
1211
+ var DEFAULT_RETRIES = 3;
1212
+ var RETRY_DELAY_MS = 2e3;
1213
+ function sleep2(ms) {
1214
+ return new Promise((resolve) => setTimeout(resolve, ms));
1215
+ }
1216
+ function isRetryableError(error, status) {
1217
+ if (error?.cause?.code === "ECONNREFUSED" || error?.cause?.code === "ECONNRESET" || error?.cause?.code === "ETIMEDOUT" || error?.message?.includes("fetch failed") || error?.message?.includes("network") || error?.message?.includes("ECONNREFUSED")) {
1218
+ return true;
1219
+ }
1220
+ if (status && status >= 500) {
1221
+ return true;
1222
+ }
1223
+ return false;
1224
+ }
954
1225
  var PlatformClient = class {
955
1226
  constructor(authToken) {
956
1227
  this.authToken = authToken;
957
1228
  }
958
- async request(method, path, body) {
1229
+ async request(method, path, body, options = {}) {
1230
+ const {
1231
+ retries = DEFAULT_RETRIES,
1232
+ retryDelay = RETRY_DELAY_MS,
1233
+ operation = `${method} ${path}`
1234
+ } = options;
959
1235
  const url = `${PLATFORM_API_URL2}${path}`;
960
- const headers = {
961
- Authorization: `Bearer ${this.authToken}`,
962
- "Content-Type": "application/json"
963
- // FIX STALE CONNECTION: If retry logic below doesn't reliably fix
964
- // "SocketError: other side closed" errors, uncomment this line and
965
- // remove the retry logic in the catch block below.
966
- // 'Connection': 'close',
967
- };
968
- const doFetch = () => fetch(url, {
969
- method,
970
- headers,
971
- body: body ? JSON.stringify(body) : void 0
972
- });
973
- let response;
974
- try {
975
- response = await doFetch();
976
- } catch (error) {
977
- const isSocketError = error instanceof Error && error.cause?.code === "UND_ERR_SOCKET";
978
- if (isSocketError) {
979
- response = await doFetch();
980
- } else {
981
- const errMsg = error instanceof Error ? error.message : "Unknown error";
982
- throw new Error(`Failed to connect to platform API (${url}): ${errMsg}`);
983
- }
984
- }
985
- if (!response.ok) {
986
- const errorText = await response.text();
987
- let errorMessage;
1236
+ let lastError = null;
1237
+ for (let attempt = 1; attempt <= retries; attempt++) {
988
1238
  try {
989
- const errorJson = JSON.parse(errorText);
990
- errorMessage = errorJson.error || response.statusText;
991
- } catch {
992
- errorMessage = errorText || response.statusText;
1239
+ const response = await fetch(url, {
1240
+ method,
1241
+ headers: {
1242
+ Authorization: `Bearer ${this.authToken}`,
1243
+ "Content-Type": "application/json"
1244
+ },
1245
+ body: body ? JSON.stringify(body) : void 0
1246
+ });
1247
+ if (!response.ok) {
1248
+ const errorText = await response.text();
1249
+ let errorMessage;
1250
+ try {
1251
+ const errorJson = JSON.parse(errorText);
1252
+ errorMessage = errorJson.error || response.statusText;
1253
+ } catch {
1254
+ errorMessage = errorText || response.statusText;
1255
+ }
1256
+ const error = new Error(`API error (${response.status}): ${errorMessage}`);
1257
+ if (isRetryableError(error, response.status) && attempt < retries) {
1258
+ lastError = error;
1259
+ await sleep2(retryDelay * attempt);
1260
+ continue;
1261
+ }
1262
+ throw error;
1263
+ }
1264
+ return response.json();
1265
+ } catch (error) {
1266
+ lastError = error;
1267
+ if (isRetryableError(error) && attempt < retries) {
1268
+ await sleep2(retryDelay * attempt);
1269
+ continue;
1270
+ }
1271
+ const errorMessage = error.message || "Unknown error";
1272
+ throw new Error(`${operation} failed: ${errorMessage}`);
993
1273
  }
994
- throw new Error(`API error (${response.status}): ${errorMessage}`);
995
1274
  }
996
- return response.json();
1275
+ throw lastError || new Error(`${operation} failed after ${retries} attempts`);
997
1276
  }
998
1277
  // ==================== App State Management ====================
999
1278
  /**
@@ -1008,7 +1287,12 @@ var PlatformClient = class {
1008
1287
  */
1009
1288
  async getApp(appName) {
1010
1289
  try {
1011
- const { app } = await this.request("GET", `/api/apps/${appName}`);
1290
+ const { app } = await this.request(
1291
+ "GET",
1292
+ `/api/apps/${appName}`,
1293
+ void 0,
1294
+ { operation: `Get app '${appName}'` }
1295
+ );
1012
1296
  return app;
1013
1297
  } catch (error) {
1014
1298
  if (error.message.includes("404") || error.message.includes("not found")) {
@@ -1022,18 +1306,23 @@ var PlatformClient = class {
1022
1306
  * Returns the app with auto-generated domainName
1023
1307
  */
1024
1308
  async saveApp(cluster) {
1025
- const { app } = await this.request("POST", "/api/apps", {
1026
- appName: cluster.appName,
1027
- location: cluster.location,
1028
- uncloudContext: cluster.uncloudContext,
1029
- nodes: cluster.nodes.map((node) => ({
1030
- name: node.name,
1031
- id: node.id,
1032
- ipv4: node.ipv4,
1033
- ipv6: node.ipv6,
1034
- isPrimary: node.isPrimary
1035
- }))
1036
- });
1309
+ const { app } = await this.request(
1310
+ "POST",
1311
+ "/api/apps",
1312
+ {
1313
+ appName: cluster.appName,
1314
+ location: cluster.location,
1315
+ uncloudContext: cluster.uncloudContext,
1316
+ nodes: cluster.nodes.map((node) => ({
1317
+ name: node.name,
1318
+ id: node.id,
1319
+ ipv4: node.ipv4,
1320
+ ipv6: node.ipv6,
1321
+ isPrimary: node.isPrimary
1322
+ }))
1323
+ },
1324
+ { operation: `Save app '${cluster.appName}'` }
1325
+ );
1037
1326
  return app;
1038
1327
  }
1039
1328
  /**
@@ -1042,7 +1331,7 @@ var PlatformClient = class {
1042
1331
  async updateLastDeployed(appName) {
1043
1332
  await this.request("PATCH", `/api/apps/${appName}`, {
1044
1333
  lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
1045
- });
1334
+ }, { operation: `Update last deployed for '${appName}'` });
1046
1335
  }
1047
1336
  /**
1048
1337
  * Rename an app (per-user unique)
@@ -1066,7 +1355,7 @@ var PlatformClient = class {
1066
1355
  * Delete an app
1067
1356
  */
1068
1357
  async deleteApp(appName) {
1069
- await this.request("DELETE", `/api/apps/${appName}`);
1358
+ await this.request("DELETE", `/api/apps/${appName}`, void 0, { operation: `Delete app '${appName}'` });
1070
1359
  }
1071
1360
  /**
1072
1361
  * Check if app exists
@@ -1094,7 +1383,7 @@ var PlatformClient = class {
1094
1383
  * Create a new VM
1095
1384
  */
1096
1385
  async createVM(params) {
1097
- const { vm } = await this.request("POST", "/api/vms", params);
1386
+ const { vm } = await this.request("POST", "/api/vms", params, { operation: `Create VM '${params.name}'` });
1098
1387
  return vm;
1099
1388
  }
1100
1389
  /**
@@ -1103,7 +1392,9 @@ var PlatformClient = class {
1103
1392
  async getVM(location, vmName) {
1104
1393
  const { vm } = await this.request(
1105
1394
  "GET",
1106
- `/api/vms/${location}/${vmName}`
1395
+ `/api/vms/${location}/${vmName}`,
1396
+ void 0,
1397
+ { operation: `Get VM '${vmName}'` }
1107
1398
  );
1108
1399
  return vm;
1109
1400
  }
@@ -1111,7 +1402,7 @@ var PlatformClient = class {
1111
1402
  * Delete a VM
1112
1403
  */
1113
1404
  async deleteVM(location, vmName) {
1114
- await this.request("DELETE", `/api/vms/${location}/${vmName}`);
1405
+ await this.request("DELETE", `/api/vms/${location}/${vmName}`, void 0, { operation: `Delete VM '${vmName}'` });
1115
1406
  }
1116
1407
  // ==================== Uncloud Config Management ====================
1117
1408
  /**
@@ -1140,7 +1431,7 @@ var PlatformClient = class {
1140
1431
  */
1141
1432
  async getGateway(location) {
1142
1433
  try {
1143
- const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`);
1434
+ const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
1144
1435
  return gateway;
1145
1436
  } catch (error) {
1146
1437
  if (error.message.includes("404") || error.message.includes("not found")) {
@@ -1158,7 +1449,7 @@ var PlatformClient = class {
1158
1449
  appName,
1159
1450
  backendIpv6,
1160
1451
  backendPort
1161
- });
1452
+ }, { operation: `Register route for '${appName}'` });
1162
1453
  return route;
1163
1454
  }
1164
1455
  /**
@@ -1173,7 +1464,7 @@ var PlatformClient = class {
1173
1464
  * This updates the gateway's reverse proxy config with current routes
1174
1465
  */
1175
1466
  async syncGatewayRoutes(location) {
1176
- const result = await this.request("POST", "/api/gateway/sync", { location });
1467
+ const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
1177
1468
  return { routeCount: result.routeCount };
1178
1469
  }
1179
1470
  // ==================== SSH Certificate Management ====================
@@ -1181,7 +1472,7 @@ var PlatformClient = class {
1181
1472
  * Get platform SSH keys (CA public key + platform public key for VM creation)
1182
1473
  */
1183
1474
  async getPlatformSSHKeys() {
1184
- return this.request("GET", "/api/platform/ssh-keys");
1475
+ return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1185
1476
  }
1186
1477
  /**
1187
1478
  * Initialize platform SSH keys (creates them if they don't exist)
@@ -1194,7 +1485,7 @@ var PlatformClient = class {
1194
1485
  * Returns a short-lived certificate (5 min) signed by the platform CA
1195
1486
  */
1196
1487
  async requestSSHCertificate(appName, publicKey) {
1197
- return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey });
1488
+ return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1198
1489
  }
1199
1490
  // ==================== VM Setup ====================
1200
1491
  /**
@@ -1206,7 +1497,7 @@ var PlatformClient = class {
1206
1497
  vmIp,
1207
1498
  location,
1208
1499
  appName
1209
- });
1500
+ }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
1210
1501
  }
1211
1502
  // ==================== Environment Variables ====================
1212
1503
  /**
@@ -1225,8 +1516,8 @@ var PlatformClient = class {
1225
1516
  * List environment variables (values are masked)
1226
1517
  */
1227
1518
  async listEnvVars(appName) {
1228
- const { vars } = await this.request("GET", `/api/apps/${appName}/env`);
1229
- return vars;
1519
+ const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
1520
+ return envVars;
1230
1521
  }
1231
1522
  /**
1232
1523
  * Remove an environment variable
@@ -1273,6 +1564,13 @@ var PlatformClient = class {
1273
1564
  throw error;
1274
1565
  }
1275
1566
  }
1567
+ /**
1568
+ * Delete current user's GitHub App installation record
1569
+ * Used when the installation becomes stale/invalid
1570
+ */
1571
+ async deleteGitHubInstallation() {
1572
+ await this.request("DELETE", "/api/github/installation");
1573
+ }
1276
1574
  /**
1277
1575
  * List repositories accessible via GitHub App installation
1278
1576
  */
@@ -1335,6 +1633,35 @@ var PlatformClient = class {
1335
1633
  const { events } = await this.request("GET", url);
1336
1634
  return events;
1337
1635
  }
1636
+ // ==================== VPN Management ====================
1637
+ /**
1638
+ * Register VPN peer with the platform
1639
+ * This is idempotent - if already registered, returns existing config
1640
+ */
1641
+ async registerVPNPeer(publicKey, location) {
1642
+ const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
1643
+ return vpnConfig;
1644
+ }
1645
+ /**
1646
+ * Get existing VPN config if registered
1647
+ */
1648
+ async getVPNConfig(location) {
1649
+ try {
1650
+ const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
1651
+ return vpnConfig;
1652
+ } catch (error) {
1653
+ if (error.message.includes("404") || error.message.includes("not found")) {
1654
+ return null;
1655
+ }
1656
+ throw error;
1657
+ }
1658
+ }
1659
+ /**
1660
+ * Unregister VPN peer
1661
+ */
1662
+ async unregisterVPNPeer() {
1663
+ await this.request("DELETE", "/api/vpn/unregister");
1664
+ }
1338
1665
  };
1339
1666
 
1340
1667
  // src/lib/app-config.ts
@@ -1383,9 +1710,330 @@ function linkApp(appName, directory = process.cwd()) {
1383
1710
  }
1384
1711
 
1385
1712
  // src/lib/uncloud-runner.ts
1386
- import { execSync as execSync4, spawnSync as spawnSync2, spawn } from "child_process";
1387
- import * as net2 from "net";
1713
+ import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
1714
+
1715
+ // src/lib/vpn.ts
1716
+ import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
1717
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
1718
+ import { homedir as homedir2, platform as platform3 } from "os";
1719
+ import { join as join4 } from "path";
1388
1720
  import chalk6 from "chalk";
1721
+ function detectOS() {
1722
+ const os = platform3();
1723
+ if (os === "darwin") {
1724
+ return { type: "macos" };
1725
+ }
1726
+ if (os === "win32") {
1727
+ return { type: "windows" };
1728
+ }
1729
+ if (os === "linux") {
1730
+ try {
1731
+ if (existsSync4("/etc/os-release")) {
1732
+ const osRelease = readFileSync4("/etc/os-release", "utf-8");
1733
+ const idMatch = osRelease.match(/^ID=(.*)$/m);
1734
+ if (idMatch) {
1735
+ const distro = idMatch[1].replace(/"/g, "").toLowerCase();
1736
+ return { type: "linux", distro };
1737
+ }
1738
+ }
1739
+ } catch {
1740
+ }
1741
+ return { type: "linux" };
1742
+ }
1743
+ return { type: "unknown" };
1744
+ }
1745
+ function getWireGuardInstallInstructions() {
1746
+ const os = detectOS();
1747
+ switch (os.type) {
1748
+ case "macos":
1749
+ return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
1750
+ case "windows":
1751
+ return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
1752
+ Or using winget: winget install WireGuard.WireGuard`;
1753
+ case "linux":
1754
+ switch (os.distro) {
1755
+ case "arch":
1756
+ case "manjaro":
1757
+ case "endeavouros":
1758
+ case "artix":
1759
+ return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
1760
+ case "ubuntu":
1761
+ case "debian":
1762
+ case "linuxmint":
1763
+ case "pop":
1764
+ case "elementary":
1765
+ case "zorin":
1766
+ return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
1767
+ case "fedora":
1768
+ return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
1769
+ case "rhel":
1770
+ case "centos":
1771
+ case "rocky":
1772
+ case "almalinux":
1773
+ return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
1774
+ (may need EPEL: sudo dnf install epel-release)`;
1775
+ case "opensuse":
1776
+ case "opensuse-leap":
1777
+ case "opensuse-tumbleweed":
1778
+ return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
1779
+ case "gentoo":
1780
+ return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
1781
+ case "void":
1782
+ return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
1783
+ case "alpine":
1784
+ return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
1785
+ case "nixos":
1786
+ return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
1787
+ default:
1788
+ return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
1789
+ Arch: sudo pacman -S wireguard-tools
1790
+ Ubuntu/Debian: sudo apt install wireguard
1791
+ Fedora: sudo dnf install wireguard-tools
1792
+ openSUSE: sudo zypper install wireguard-tools`;
1793
+ }
1794
+ default:
1795
+ return ` Visit https://www.wireguard.com/install/ for installation instructions`;
1796
+ }
1797
+ }
1798
+ var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
1799
+ var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
1800
+ var WG_INTERFACE = "hackerrun";
1801
+ var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
1802
+ function isWireGuardInstalled() {
1803
+ try {
1804
+ execSync4("which wg", { stdio: "ignore" });
1805
+ execSync4("which wg-quick", { stdio: "ignore" });
1806
+ return true;
1807
+ } catch {
1808
+ return false;
1809
+ }
1810
+ }
1811
+ function ensureConfigDir() {
1812
+ if (!existsSync4(CONFIG_DIR)) {
1813
+ mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
1814
+ }
1815
+ }
1816
+ function generateKeyPair() {
1817
+ const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
1818
+ const publicKey = execSync4("wg pubkey", {
1819
+ input: privateKey,
1820
+ encoding: "utf-8"
1821
+ }).trim();
1822
+ return { privateKey, publicKey };
1823
+ }
1824
+ function getOrCreateKeyPair() {
1825
+ ensureConfigDir();
1826
+ if (existsSync4(PRIVATE_KEY_FILE)) {
1827
+ const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1828
+ const publicKey2 = execSync4("wg pubkey", {
1829
+ input: privateKey2,
1830
+ encoding: "utf-8"
1831
+ }).trim();
1832
+ return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
1833
+ }
1834
+ const { privateKey, publicKey } = generateKeyPair();
1835
+ writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
1836
+ return { privateKey, publicKey, isNew: true };
1837
+ }
1838
+ function getPublicKey() {
1839
+ if (!existsSync4(PRIVATE_KEY_FILE)) {
1840
+ return null;
1841
+ }
1842
+ const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1843
+ return execSync4("wg pubkey", {
1844
+ input: privateKey,
1845
+ encoding: "utf-8"
1846
+ }).trim();
1847
+ }
1848
+ function isVPNUp() {
1849
+ try {
1850
+ const result = execSync4(`ip link show ${WG_INTERFACE}`, {
1851
+ encoding: "utf-8",
1852
+ stdio: ["pipe", "pipe", "pipe"]
1853
+ });
1854
+ return result.includes(WG_INTERFACE);
1855
+ } catch {
1856
+ return false;
1857
+ }
1858
+ }
1859
+ function isRoutedViaVPN(ipv6Address) {
1860
+ if (!isVPNUp()) {
1861
+ return false;
1862
+ }
1863
+ try {
1864
+ const result = execSync4(`ip -6 route get ${ipv6Address}`, {
1865
+ encoding: "utf-8",
1866
+ stdio: ["pipe", "pipe", "pipe"]
1867
+ });
1868
+ return result.includes(`dev ${WG_INTERFACE}`);
1869
+ } catch {
1870
+ return false;
1871
+ }
1872
+ }
1873
+ function getVPNStatus() {
1874
+ if (!isVPNUp()) {
1875
+ return { connected: false };
1876
+ }
1877
+ try {
1878
+ const output = execSync4(`sudo wg show ${WG_INTERFACE}`, {
1879
+ encoding: "utf-8",
1880
+ stdio: ["pipe", "pipe", "pipe"]
1881
+ });
1882
+ const lines = output.split("\n");
1883
+ let endpoint;
1884
+ let latestHandshake;
1885
+ let transferRx;
1886
+ let transferTx;
1887
+ for (const line of lines) {
1888
+ if (line.includes("endpoint:")) {
1889
+ endpoint = line.split("endpoint:")[1]?.trim();
1890
+ }
1891
+ if (line.includes("latest handshake:")) {
1892
+ latestHandshake = line.split("latest handshake:")[1]?.trim();
1893
+ }
1894
+ if (line.includes("transfer:")) {
1895
+ const transfer = line.split("transfer:")[1]?.trim();
1896
+ const parts = transfer?.split(",");
1897
+ transferRx = parts?.[0]?.trim();
1898
+ transferTx = parts?.[1]?.trim();
1899
+ }
1900
+ }
1901
+ return {
1902
+ connected: true,
1903
+ interface: WG_INTERFACE,
1904
+ endpoint,
1905
+ latestHandshake,
1906
+ transferRx,
1907
+ transferTx
1908
+ };
1909
+ } catch {
1910
+ return { connected: isVPNUp(), interface: WG_INTERFACE };
1911
+ }
1912
+ }
1913
+ function generateWireGuardConfig(config) {
1914
+ return `# HackerRun VPN - Auto-generated
1915
+ # Do not edit manually
1916
+
1917
+ [Interface]
1918
+ PrivateKey = ${config.privateKey}
1919
+ Address = ${config.address}
1920
+
1921
+ [Peer]
1922
+ PublicKey = ${config.gatewayPublicKey}
1923
+ Endpoint = ${config.gatewayEndpoint}
1924
+ AllowedIPs = ${config.allowedIPs}
1925
+ PersistentKeepalive = 25
1926
+ `;
1927
+ }
1928
+ function writeWireGuardConfig(config) {
1929
+ const configContent = generateWireGuardConfig(config);
1930
+ const tempFile = join4(CONFIG_DIR, "wg-temp.conf");
1931
+ writeFileSync4(tempFile, configContent, { mode: 384 });
1932
+ try {
1933
+ execSync4("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
1934
+ execSync4(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1935
+ execSync4(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1936
+ } catch (error) {
1937
+ if (existsSync4(tempFile)) {
1938
+ unlinkSync(tempFile);
1939
+ }
1940
+ throw error;
1941
+ }
1942
+ }
1943
+ function vpnUp() {
1944
+ if (isVPNUp()) {
1945
+ return;
1946
+ }
1947
+ const result = spawnSync2("sudo", ["wg-quick", "up", WG_INTERFACE], {
1948
+ stdio: "inherit"
1949
+ });
1950
+ if (result.status !== 0) {
1951
+ throw new Error(`Failed to bring up VPN interface (exit code ${result.status})`);
1952
+ }
1953
+ }
1954
+ function vpnDown() {
1955
+ if (!isVPNUp()) {
1956
+ return;
1957
+ }
1958
+ const result = spawnSync2("sudo", ["wg-quick", "down", WG_INTERFACE], {
1959
+ stdio: "inherit"
1960
+ });
1961
+ if (result.status !== 0) {
1962
+ throw new Error(`Failed to bring down VPN interface (exit code ${result.status})`);
1963
+ }
1964
+ }
1965
+ function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
1966
+ try {
1967
+ execSync4(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
1968
+ stdio: "ignore",
1969
+ timeout: (timeoutSeconds + 2) * 1e3
1970
+ });
1971
+ return true;
1972
+ } catch {
1973
+ return false;
1974
+ }
1975
+ }
1976
+ var VPNManager = class {
1977
+ wasConnectedByUs = false;
1978
+ /**
1979
+ * Ensure VPN is connected if IPv6 is not available
1980
+ * Returns true if VPN was established (and should be torn down later)
1981
+ */
1982
+ async ensureConnected(targetIPv6, getVPNConfig) {
1983
+ if (testIPv6Connectivity2(targetIPv6)) {
1984
+ console.log(chalk6.green("\u2713 Direct IPv6 connectivity available"));
1985
+ return false;
1986
+ }
1987
+ console.log(chalk6.yellow("IPv6 not available, checking VPN..."));
1988
+ if (isVPNUp()) {
1989
+ if (testIPv6Connectivity2(targetIPv6)) {
1990
+ console.log(chalk6.green("\u2713 VPN already connected"));
1991
+ return false;
1992
+ }
1993
+ console.log(chalk6.yellow("VPN is up but cannot reach target, reconnecting..."));
1994
+ vpnDown();
1995
+ }
1996
+ if (!isWireGuardInstalled()) {
1997
+ const instructions = getWireGuardInstallInstructions();
1998
+ throw new Error(
1999
+ "WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
2000
+ );
2001
+ }
2002
+ console.log(chalk6.cyan("Establishing VPN tunnel (requires sudo)..."));
2003
+ const { publicKey, isNew } = getOrCreateKeyPair();
2004
+ if (isNew) {
2005
+ console.log(chalk6.dim("Generated new WireGuard keypair"));
2006
+ }
2007
+ const vpnConfig = await getVPNConfig();
2008
+ writeWireGuardConfig(vpnConfig);
2009
+ vpnUp();
2010
+ if (!testIPv6Connectivity2(targetIPv6)) {
2011
+ vpnDown();
2012
+ throw new Error("VPN established but cannot reach target. Please try again.");
2013
+ }
2014
+ console.log(chalk6.green("\u2713 VPN connected"));
2015
+ this.wasConnectedByUs = true;
2016
+ return true;
2017
+ }
2018
+ /**
2019
+ * Disconnect VPN if we established it
2020
+ */
2021
+ disconnect() {
2022
+ if (this.wasConnectedByUs && isVPNUp()) {
2023
+ console.log(chalk6.dim("Disconnecting VPN..."));
2024
+ try {
2025
+ vpnDown();
2026
+ console.log(chalk6.green("\u2713 VPN disconnected"));
2027
+ } catch (error) {
2028
+ console.log(chalk6.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
2029
+ }
2030
+ this.wasConnectedByUs = false;
2031
+ }
2032
+ }
2033
+ };
2034
+
2035
+ // src/lib/uncloud-runner.ts
2036
+ import chalk7 from "chalk";
1389
2037
  var UncloudRunner = class {
1390
2038
  constructor(platformClient) {
1391
2039
  this.platformClient = platformClient;
@@ -1393,11 +2041,13 @@ var UncloudRunner = class {
1393
2041
  }
1394
2042
  certManager;
1395
2043
  gatewayCache = /* @__PURE__ */ new Map();
1396
- activeTunnels = /* @__PURE__ */ new Map();
2044
+ tunnelManager = new TunnelManager();
2045
+ vpnManager = new VPNManager();
1397
2046
  tempConfigPath = null;
2047
+ vpnEstablishedByUs = false;
1398
2048
  /**
1399
2049
  * Get the connection URL for an app's primary VM
1400
- * Handles IPv6 direct connection or gateway proxy fallback
2050
+ * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
1401
2051
  */
1402
2052
  async getConnectionInfo(appName) {
1403
2053
  const app = await this.platformClient.getApp(appName);
@@ -1411,20 +2061,74 @@ var UncloudRunner = class {
1411
2061
  const vmIp = primaryNode.ipv6;
1412
2062
  await this.certManager.getSession(appName, vmIp);
1413
2063
  const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
1414
- const viaGateway = !canConnectDirect;
1415
- if (viaGateway) {
1416
- const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
2064
+ if (canConnectDirect) {
2065
+ const viaVPN = isRoutedViaVPN(vmIp);
1417
2066
  return {
1418
- url: `ssh://root@localhost:${tunnelInfo.localPort}`,
2067
+ url: `ssh://root@${vmIp}`,
1419
2068
  vmIp,
1420
- viaGateway: true,
1421
- localPort: tunnelInfo.localPort
2069
+ viaGateway: false,
2070
+ viaVPN
1422
2071
  };
1423
2072
  }
2073
+ const vpnAvailable = isWireGuardInstalled();
2074
+ if (vpnAvailable) {
2075
+ console.log(chalk7.yellow("Direct IPv6 not available, trying VPN..."));
2076
+ try {
2077
+ if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
2078
+ console.log(chalk7.green("\u2713 VPN already connected"));
2079
+ return {
2080
+ url: `ssh://root@${vmIp}`,
2081
+ vmIp,
2082
+ viaGateway: false,
2083
+ viaVPN: true
2084
+ };
2085
+ }
2086
+ this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
2087
+ vmIp,
2088
+ async () => this.getVPNConfig(app.location)
2089
+ );
2090
+ if (testIPv6Connectivity2(vmIp, 5)) {
2091
+ console.log(chalk7.green("\u2713 Connected via VPN"));
2092
+ return {
2093
+ url: `ssh://root@${vmIp}`,
2094
+ vmIp,
2095
+ viaGateway: false,
2096
+ viaVPN: true
2097
+ };
2098
+ }
2099
+ } catch (error) {
2100
+ console.log(chalk7.yellow(`VPN setup failed: ${error.message}`));
2101
+ console.log(chalk7.dim("Falling back to SSH tunnel..."));
2102
+ }
2103
+ } else {
2104
+ console.log(chalk7.yellow("Direct IPv6 not available on your network."));
2105
+ console.log(chalk7.dim("For faster deploys, install WireGuard:"));
2106
+ console.log(chalk7.dim(getWireGuardInstallInstructions()));
2107
+ console.log();
2108
+ }
2109
+ console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
2110
+ const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
1424
2111
  return {
1425
- url: `ssh://root@${vmIp}`,
2112
+ url: `ssh://root@localhost:${tunnelInfo.localPort}`,
1426
2113
  vmIp,
1427
- viaGateway: false
2114
+ viaGateway: true,
2115
+ viaVPN: false,
2116
+ localPort: tunnelInfo.localPort
2117
+ };
2118
+ }
2119
+ /**
2120
+ * Get VPN configuration from platform
2121
+ */
2122
+ async getVPNConfig(location) {
2123
+ const { privateKey, publicKey } = getOrCreateKeyPair();
2124
+ const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
2125
+ return {
2126
+ privateKey,
2127
+ publicKey,
2128
+ address: vpnConfigResponse.address,
2129
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
2130
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
2131
+ allowedIPs: vpnConfigResponse.allowedIPs
1428
2132
  };
1429
2133
  }
1430
2134
  /**
@@ -1434,7 +2138,7 @@ var UncloudRunner = class {
1434
2138
  preAcceptHostKey(host, port) {
1435
2139
  try {
1436
2140
  const portArg = port ? `-p ${port}` : "";
1437
- execSync4(
2141
+ execSync5(
1438
2142
  `ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
1439
2143
  { stdio: "pipe", timeout: 1e4 }
1440
2144
  );
@@ -1445,81 +2149,11 @@ var UncloudRunner = class {
1445
2149
  * Ensure an SSH tunnel exists for gateway fallback
1446
2150
  */
1447
2151
  async ensureTunnel(appName, vmIp, location) {
1448
- const existing = this.activeTunnels.get(appName);
1449
- if (existing && !existing.process.killed) {
1450
- return existing;
1451
- }
1452
2152
  const gateway = await this.getGateway(location);
1453
2153
  if (!gateway) {
1454
2154
  throw new Error(`No gateway found for location ${location}`);
1455
2155
  }
1456
- const localPort = await this.findAvailablePort();
1457
- const tunnelProcess = spawn("ssh", [
1458
- "-N",
1459
- // No remote command
1460
- "-L",
1461
- `${localPort}:[${vmIp}]:22`,
1462
- // Local port forward
1463
- "-o",
1464
- "StrictHostKeyChecking=no",
1465
- "-o",
1466
- "UserKnownHostsFile=/dev/null",
1467
- "-o",
1468
- "LogLevel=ERROR",
1469
- "-o",
1470
- "ExitOnForwardFailure=yes",
1471
- "-o",
1472
- "ServerAliveInterval=30",
1473
- `root@${gateway.ipv4}`
1474
- ], {
1475
- detached: true,
1476
- stdio: "pipe"
1477
- });
1478
- await this.waitForTunnel(localPort);
1479
- const tunnelInfo = { process: tunnelProcess, localPort };
1480
- this.activeTunnels.set(appName, tunnelInfo);
1481
- return tunnelInfo;
1482
- }
1483
- /**
1484
- * Find an available local port
1485
- */
1486
- async findAvailablePort() {
1487
- return new Promise((resolve, reject) => {
1488
- const server = net2.createServer();
1489
- server.listen(0, () => {
1490
- const port = server.address().port;
1491
- server.close(() => resolve(port));
1492
- });
1493
- server.on("error", reject);
1494
- });
1495
- }
1496
- /**
1497
- * Wait for tunnel to be established
1498
- */
1499
- async waitForTunnel(port, timeoutMs = 1e4) {
1500
- const startTime = Date.now();
1501
- while (Date.now() - startTime < timeoutMs) {
1502
- try {
1503
- await new Promise((resolve, reject) => {
1504
- const socket = net2.createConnection(port, "localhost", () => {
1505
- socket.destroy();
1506
- resolve();
1507
- });
1508
- socket.on("error", () => {
1509
- socket.destroy();
1510
- reject();
1511
- });
1512
- socket.setTimeout(500, () => {
1513
- socket.destroy();
1514
- reject();
1515
- });
1516
- });
1517
- return;
1518
- } catch {
1519
- await new Promise((r) => setTimeout(r, 200));
1520
- }
1521
- }
1522
- throw new Error(`Tunnel failed to establish on port ${port}`);
2156
+ return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
1523
2157
  }
1524
2158
  /**
1525
2159
  * Run an uncloud command on the app's VM
@@ -1527,11 +2161,11 @@ var UncloudRunner = class {
1527
2161
  async run(appName, command, args = [], options = {}) {
1528
2162
  const connInfo = await this.getConnectionInfo(appName);
1529
2163
  if (connInfo.viaGateway) {
1530
- console.log(chalk6.dim(`Connecting via gateway...`));
2164
+ console.log(chalk7.dim(`Connecting via gateway...`));
1531
2165
  }
1532
2166
  const fullArgs = ["--connect", connInfo.url, command, ...args];
1533
2167
  if (options.stdio === "inherit") {
1534
- const result = spawnSync2("uc", fullArgs, {
2168
+ const result = spawnSync3("uc", fullArgs, {
1535
2169
  cwd: options.cwd,
1536
2170
  stdio: "inherit",
1537
2171
  timeout: options.timeout
@@ -1540,7 +2174,7 @@ var UncloudRunner = class {
1540
2174
  throw new Error(`Uncloud command failed with exit code ${result.status}`);
1541
2175
  }
1542
2176
  } else {
1543
- const result = execSync4(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
2177
+ const result = execSync5(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
1544
2178
  cwd: options.cwd,
1545
2179
  encoding: "utf-8",
1546
2180
  timeout: options.timeout
@@ -1582,7 +2216,7 @@ var UncloudRunner = class {
1582
2216
  } else {
1583
2217
  sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
1584
2218
  }
1585
- const token = execSync4(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
2219
+ const token = execSync5(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
1586
2220
  return token;
1587
2221
  }
1588
2222
  /**
@@ -1596,7 +2230,29 @@ var UncloudRunner = class {
1596
2230
  } else {
1597
2231
  sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
1598
2232
  }
1599
- return execSync4(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
2233
+ return execSync5(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
2234
+ }
2235
+ /**
2236
+ * Remove a node from the uncloud cluster
2237
+ * Uses 'uc machine rm' to drain containers and remove from cluster
2238
+ */
2239
+ async removeNode(appName, nodeName) {
2240
+ const connInfo = await this.getConnectionInfo(appName);
2241
+ const result = spawnSync3("uc", ["--connect", connInfo.url, "machine", "rm", nodeName, "--yes"], {
2242
+ stdio: "inherit",
2243
+ timeout: 3e5
2244
+ // 5 minute timeout for container drainage
2245
+ });
2246
+ if (result.status !== 0) {
2247
+ throw new Error(`Failed to remove node '${nodeName}' from cluster (exit code ${result.status})`);
2248
+ }
2249
+ }
2250
+ /**
2251
+ * List machines in the uncloud cluster
2252
+ */
2253
+ async listMachines(appName) {
2254
+ const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
2255
+ return result;
1600
2256
  }
1601
2257
  /**
1602
2258
  * Get gateway info (cached)
@@ -1614,17 +2270,15 @@ var UncloudRunner = class {
1614
2270
  return null;
1615
2271
  }
1616
2272
  /**
1617
- * Clean up all SSH sessions and tunnels
2273
+ * Clean up all SSH sessions, tunnels, and VPN
1618
2274
  */
1619
2275
  cleanup() {
1620
- for (const [appName, tunnel] of this.activeTunnels) {
1621
- try {
1622
- tunnel.process.kill();
1623
- } catch {
1624
- }
1625
- }
1626
- this.activeTunnels.clear();
2276
+ this.tunnelManager.closeAll();
1627
2277
  this.certManager.cleanupAll();
2278
+ if (this.vpnEstablishedByUs) {
2279
+ this.vpnManager.disconnect();
2280
+ this.vpnEstablishedByUs = false;
2281
+ }
1628
2282
  }
1629
2283
  };
1630
2284
 
@@ -1636,11 +2290,12 @@ var DEFAULT_IMAGE = "ubuntu-noble";
1636
2290
  function createDeployCommand() {
1637
2291
  const cmd = new Command2("deploy");
1638
2292
  cmd.description("Deploy your application to hackerrun").option("-n, --name <name>", "App name (defaults to hackerrun.yaml or directory name)").option("--app <app>", "App name (alias for --name, for CI/CD compatibility)").option("-l, --location <location>", "VM location").option("-s, --size <size>", "VM size (default: burstable-1)").option("--storage <gb>", "Storage size in GB (default: 10)").option("-i, --image <image>", "Boot image").option("--build-token <token>", "Build token for CI/CD (bypasses normal auth)").action(async (options) => {
2293
+ const deployStartTime = Date.now();
1639
2294
  try {
1640
2295
  PlatformDetector.ensureSupported();
1641
- await UncloudManager.ensureInstalled();
1642
- let platformToken;
1643
2296
  const isCIBuild = !!options.buildToken;
2297
+ await UncloudManager.ensureInstalled({ nonInteractive: isCIBuild });
2298
+ let platformToken;
1644
2299
  if (isCIBuild) {
1645
2300
  platformToken = options.buildToken;
1646
2301
  } else {
@@ -1654,14 +2309,19 @@ function createDeployCommand() {
1654
2309
  const vmSize = options.size || DEFAULT_SIZE;
1655
2310
  const storageSize = options.storage ? parseInt(options.storage) : DEFAULT_STORAGE_SIZE;
1656
2311
  const bootImage = options.image || DEFAULT_IMAGE;
1657
- console.log(chalk7.cyan(`
2312
+ console.log(chalk8.cyan(`
1658
2313
  Deploying '${appName}' to hackerrun...
1659
2314
  `));
1660
2315
  let cluster = await platformClient.getApp(appName);
1661
2316
  let isFirstDeploy = false;
1662
- if (!cluster) {
2317
+ const needsInfrastructure = !cluster || cluster.nodes.length === 0;
2318
+ if (needsInfrastructure) {
1663
2319
  isFirstDeploy = true;
1664
- console.log(chalk7.yellow("First deployment - creating infrastructure...\n"));
2320
+ if (cluster && cluster.nodes.length === 0) {
2321
+ console.log(chalk8.yellow("App exists but has no infrastructure - creating VMs...\n"));
2322
+ } else {
2323
+ console.log(chalk8.yellow("First deployment - creating infrastructure...\n"));
2324
+ }
1665
2325
  cluster = await clusterManager.initializeCluster({
1666
2326
  appName,
1667
2327
  location,
@@ -1669,67 +2329,105 @@ Deploying '${appName}' to hackerrun...
1669
2329
  storageSize,
1670
2330
  bootImage
1671
2331
  });
1672
- console.log(chalk7.cyan("\nWhat just happened:"));
1673
- console.log(` ${chalk7.green("\u2713")} Created 1 IPv6-only VM (${vmSize})`);
1674
- console.log(` ${chalk7.green("\u2713")} Installed Docker and Uncloud daemon`);
1675
- console.log(` ${chalk7.green("\u2713")} Initialized uncloud cluster`);
1676
- console.log(` ${chalk7.green("\u2713")} Assigned domain: ${cluster.domainName}.hackerrun.app`);
2332
+ console.log(chalk8.cyan("\nWhat just happened:"));
2333
+ console.log(` ${chalk8.green("\u2713")} Created 1 IPv6-only VM (${vmSize})`);
2334
+ console.log(` ${chalk8.green("\u2713")} Installed Docker and Uncloud daemon`);
2335
+ console.log(` ${chalk8.green("\u2713")} Initialized uncloud cluster`);
2336
+ console.log(` ${chalk8.green("\u2713")} Assigned domain: ${cluster.domainName}.hackerrun.app`);
1677
2337
  console.log();
1678
2338
  if (!hasAppConfig()) {
1679
2339
  linkApp(appName);
1680
- console.log(chalk7.dim(`Created hackerrun.yaml for app linking
2340
+ console.log(chalk8.dim(`Created hackerrun.yaml for app linking
1681
2341
  `));
1682
2342
  }
1683
2343
  } else {
1684
- console.log(chalk7.green(`Using existing infrastructure (${cluster.nodes.length} VM(s))
2344
+ console.log(chalk8.green(`Using existing infrastructure (${cluster.nodes.length} VM(s))
1685
2345
  `));
1686
2346
  }
1687
2347
  const primaryNode = await platformClient.getPrimaryNode(appName);
1688
2348
  if (!primaryNode || !primaryNode.ipv6) {
1689
2349
  throw new Error("Primary node not found or has no IPv6 address");
1690
2350
  }
1691
- console.log(chalk7.cyan("\nRunning deployment...\n"));
2351
+ console.log(chalk8.cyan("\nRunning deployment...\n"));
1692
2352
  try {
1693
- await uncloudRunner.deploy(appName, process.cwd());
1694
- console.log(chalk7.green("\nApp deployed successfully!"));
1695
- if (cluster.domainName) {
1696
- const spinner = ora4("Registering route...").start();
2353
+ const maxDeployAttempts = 3;
2354
+ let lastError = null;
2355
+ for (let attempt = 1; attempt <= maxDeployAttempts; attempt++) {
1697
2356
  try {
1698
- const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
1699
- spinner.succeed(chalk7.green(`Route registered: ${route.fullUrl}`));
1700
- spinner.start("Syncing gateway routes...");
1701
- await platformClient.syncGatewayRoutes(cluster.location);
1702
- spinner.succeed(chalk7.green("Gateway routes synced"));
2357
+ await uncloudRunner.deploy(appName, process.cwd());
2358
+ lastError = null;
2359
+ break;
1703
2360
  } catch (error) {
1704
- spinner.warn(chalk7.yellow(`Could not register route: ${error.message}`));
2361
+ lastError = error;
2362
+ const errorMsg = lastError.message.toLowerCase();
2363
+ const isRetryableError2 = errorMsg.includes("connection reset") || errorMsg.includes("connection refused") || errorMsg.includes("broken pipe") || errorMsg.includes("network is unreachable") || errorMsg.includes("context deadline exceeded") || errorMsg.includes("exit code 1");
2364
+ if (isRetryableError2 && attempt < maxDeployAttempts) {
2365
+ console.log(chalk8.yellow(`
2366
+ Deploy failed, retrying (attempt ${attempt + 1}/${maxDeployAttempts})...
2367
+ `));
2368
+ uncloudRunner.cleanup();
2369
+ await new Promise((r) => setTimeout(r, 3e3));
2370
+ } else {
2371
+ throw lastError;
2372
+ }
2373
+ }
2374
+ }
2375
+ console.log(chalk8.green("\nApp deployed successfully!"));
2376
+ if (cluster.domainName) {
2377
+ const spinner = ora4("Registering route...").start();
2378
+ let success = false;
2379
+ for (let attempt = 1; attempt <= 5; attempt++) {
2380
+ try {
2381
+ if (attempt > 1) {
2382
+ spinner.text = `Registering route (attempt ${attempt}/5)...`;
2383
+ await new Promise((r) => setTimeout(r, 2e3));
2384
+ }
2385
+ const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
2386
+ spinner.succeed(chalk8.green(`Route registered: ${route.fullUrl}`));
2387
+ spinner.start("Syncing gateway routes...");
2388
+ await platformClient.syncGatewayRoutes(cluster.location);
2389
+ spinner.succeed(chalk8.green("Gateway routes synced"));
2390
+ success = true;
2391
+ break;
2392
+ } catch (error) {
2393
+ if (attempt === 5) {
2394
+ spinner.warn(chalk8.yellow(`Could not register route: ${error.message}`));
2395
+ console.log(chalk8.dim(` Run 'hackerrun domain --app ${appName} ${cluster.domainName}' to retry`));
2396
+ }
2397
+ }
1705
2398
  }
1706
2399
  }
1707
2400
  await platformClient.updateLastDeployed(appName);
1708
- console.log(chalk7.cyan("\nDeployment Summary:\n"));
1709
- console.log(` App Name: ${chalk7.bold(appName)}`);
1710
- console.log(` Domain: ${chalk7.bold(`${cluster.domainName}.hackerrun.app`)}`);
1711
- console.log(` URL: ${chalk7.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
2401
+ const deployDurationMs = Date.now() - deployStartTime;
2402
+ const deployMinutes = Math.floor(deployDurationMs / 6e4);
2403
+ const deploySeconds = Math.floor(deployDurationMs % 6e4 / 1e3);
2404
+ const deployTimeStr = deployMinutes > 0 ? `${deployMinutes}m ${deploySeconds}s` : `${deploySeconds}s`;
2405
+ console.log(chalk8.cyan("\nDeployment Summary:\n"));
2406
+ console.log(` App Name: ${chalk8.bold(appName)}`);
2407
+ console.log(` Domain: ${chalk8.bold(`${cluster.domainName}.hackerrun.app`)}`);
2408
+ console.log(` URL: ${chalk8.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
1712
2409
  console.log(` Location: ${cluster.location}`);
1713
2410
  console.log(` Nodes: ${cluster.nodes.length}`);
1714
- console.log(chalk7.cyan("\nInfrastructure:\n"));
2411
+ console.log(` Deploy Time: ${chalk8.bold(deployTimeStr)}`);
2412
+ console.log(chalk8.cyan("\nInfrastructure:\n"));
1715
2413
  cluster.nodes.forEach((node, index) => {
1716
- const prefix = node.isPrimary ? chalk7.yellow("(primary)") : " ";
2414
+ const prefix = node.isPrimary ? chalk8.yellow("(primary)") : " ";
1717
2415
  const ip = node.ipv6 || node.ipv4 || "pending";
1718
2416
  console.log(` ${prefix} ${node.name} - ${ip}`);
1719
2417
  });
1720
- console.log(chalk7.cyan("\nNext steps:\n"));
1721
- console.log(` View logs: ${chalk7.bold(`hackerrun logs ${appName}`)}`);
1722
- console.log(` SSH access: ${chalk7.bold(`hackerrun ssh ${appName}`)}`);
1723
- console.log(` Change domain: ${chalk7.bold(`hackerrun domain --app ${appName} <new-name>`)}`);
2418
+ console.log(chalk8.cyan("\nNext steps:\n"));
2419
+ console.log(` View logs: ${chalk8.bold(`hackerrun logs ${appName}`)}`);
2420
+ console.log(` SSH access: ${chalk8.bold(`hackerrun ssh ${appName}`)}`);
2421
+ console.log(` Change domain: ${chalk8.bold(`hackerrun domain --app ${appName} <new-name>`)}`);
1724
2422
  console.log();
1725
2423
  uncloudRunner.cleanup();
1726
2424
  } catch (error) {
1727
2425
  uncloudRunner.cleanup();
1728
- console.log(chalk7.red("\nDeployment failed"));
2426
+ console.log(chalk8.red("\nDeployment failed"));
1729
2427
  throw error;
1730
2428
  }
1731
2429
  } catch (error) {
1732
- console.error(chalk7.red(`
2430
+ console.error(chalk8.red(`
1733
2431
  Error: ${error.message}
1734
2432
  `));
1735
2433
  process.exit(1);
@@ -1740,20 +2438,20 @@ Error: ${error.message}
1740
2438
 
1741
2439
  // src/commands/app.ts
1742
2440
  import { Command as Command3 } from "commander";
1743
- import chalk8 from "chalk";
2441
+ import chalk9 from "chalk";
1744
2442
  import ora5 from "ora";
1745
- import { execSync as execSync5 } from "child_process";
1746
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4 } from "fs";
1747
- import { homedir as homedir2 } from "os";
1748
- import { join as join4 } from "path";
2443
+ import { execSync as execSync6 } from "child_process";
2444
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
2445
+ import { homedir as homedir3 } from "os";
2446
+ import { join as join5 } from "path";
1749
2447
  import YAML from "yaml";
1750
2448
  function removeUncloudContext(contextName) {
1751
- const configPath = join4(homedir2(), ".config", "uncloud", "config.yaml");
1752
- if (!existsSync4(configPath)) {
2449
+ const configPath = join5(homedir3(), ".config", "uncloud", "config.yaml");
2450
+ if (!existsSync5(configPath)) {
1753
2451
  return false;
1754
2452
  }
1755
2453
  try {
1756
- const content = readFileSync4(configPath, "utf-8");
2454
+ const content = readFileSync5(configPath, "utf-8");
1757
2455
  const config = YAML.parse(content);
1758
2456
  if (!config.contexts || !config.contexts[contextName]) {
1759
2457
  return false;
@@ -1763,7 +2461,7 @@ function removeUncloudContext(contextName) {
1763
2461
  const remainingContexts = Object.keys(config.contexts);
1764
2462
  config.current_context = remainingContexts.length > 0 ? remainingContexts[0] : "";
1765
2463
  }
1766
- writeFileSync4(configPath, YAML.stringify(config));
2464
+ writeFileSync5(configPath, YAML.stringify(config));
1767
2465
  return true;
1768
2466
  } catch {
1769
2467
  return false;
@@ -1777,14 +2475,14 @@ function createAppCommands() {
1777
2475
  const platformClient = new PlatformClient(platformToken);
1778
2476
  const apps = await platformClient.listApps();
1779
2477
  if (apps.length === 0) {
1780
- console.log(chalk8.yellow("\nNo apps deployed yet.\n"));
1781
- console.log(`Run ${chalk8.bold("hackerrun deploy")} to deploy your first app!
2478
+ console.log(chalk9.yellow("\nNo apps deployed yet.\n"));
2479
+ console.log(`Run ${chalk9.bold("hackerrun deploy")} to deploy your first app!
1782
2480
  `);
1783
2481
  return;
1784
2482
  }
1785
- console.log(chalk8.cyan("\n Your Apps:\n"));
2483
+ console.log(chalk9.cyan("\n Your Apps:\n"));
1786
2484
  apps.forEach((app) => {
1787
- console.log(chalk8.bold(` ${app.appName}`));
2485
+ console.log(chalk9.bold(` ${app.appName}`));
1788
2486
  console.log(` Domain: ${app.domainName}.hackerrun.app`);
1789
2487
  console.log(` URL: https://${app.domainName}.hackerrun.app`);
1790
2488
  console.log(` Location: ${app.location}`);
@@ -1797,10 +2495,10 @@ function createAppCommands() {
1797
2495
  console.log(` Primary Node: ${primaryNode?.ipv6 || primaryNode?.ipv4 || "N/A"}`);
1798
2496
  console.log();
1799
2497
  });
1800
- console.log(chalk8.green(`Total: ${apps.length} app(s)
2498
+ console.log(chalk9.green(`Total: ${apps.length} app(s)
1801
2499
  `));
1802
2500
  } catch (error) {
1803
- console.error(chalk8.red(`
2501
+ console.error(chalk9.red(`
1804
2502
  Error: ${error.message}
1805
2503
  `));
1806
2504
  process.exit(1);
@@ -1813,17 +2511,17 @@ Error: ${error.message}
1813
2511
  const platformClient = new PlatformClient(platformToken);
1814
2512
  const app = await platformClient.getApp(appName);
1815
2513
  if (!app) {
1816
- console.log(chalk8.red(`
2514
+ console.log(chalk9.red(`
1817
2515
  App '${appName}' not found
1818
2516
  `));
1819
2517
  process.exit(1);
1820
2518
  }
1821
- console.log(chalk8.cyan(`
2519
+ console.log(chalk9.cyan(`
1822
2520
  Nodes in '${appName}':
1823
2521
  `));
1824
2522
  app.nodes.forEach((node, index) => {
1825
- const primaryLabel = node.isPrimary ? chalk8.yellow(" (primary)") : "";
1826
- console.log(` ${index + 1}. ${chalk8.bold(node.name)}${primaryLabel}`);
2523
+ const primaryLabel = node.isPrimary ? chalk9.yellow(" (primary)") : "";
2524
+ console.log(` ${index + 1}. ${chalk9.bold(node.name)}${primaryLabel}`);
1827
2525
  console.log(` IPv6: ${node.ipv6 || "pending"}`);
1828
2526
  if (node.ipv4) {
1829
2527
  console.log(` IPv4: ${node.ipv4}`);
@@ -1831,10 +2529,10 @@ Nodes in '${appName}':
1831
2529
  console.log(` ID: ${node.id}`);
1832
2530
  console.log();
1833
2531
  });
1834
- console.log(chalk8.green(`Total: ${app.nodes.length} node(s)
2532
+ console.log(chalk9.green(`Total: ${app.nodes.length} node(s)
1835
2533
  `));
1836
2534
  } catch (error) {
1837
- console.error(chalk8.red(`
2535
+ console.error(chalk9.red(`
1838
2536
  Error: ${error.message}
1839
2537
  `));
1840
2538
  process.exit(1);
@@ -1848,7 +2546,7 @@ Error: ${error.message}
1848
2546
  try {
1849
2547
  const app = await platformClient.getApp(appName);
1850
2548
  if (!app) {
1851
- console.log(chalk8.red(`
2549
+ console.log(chalk9.red(`
1852
2550
  App '${appName}' not found
1853
2551
  `));
1854
2552
  process.exit(1);
@@ -1862,7 +2560,7 @@ App '${appName}' not found
1862
2560
  targetNode = app.nodes.find((n) => n.name === options.node);
1863
2561
  }
1864
2562
  if (!targetNode) {
1865
- console.log(chalk8.red(`
2563
+ console.log(chalk9.red(`
1866
2564
  Node '${options.node}' not found
1867
2565
  `));
1868
2566
  process.exit(1);
@@ -1872,34 +2570,34 @@ Node '${options.node}' not found
1872
2570
  }
1873
2571
  const nodeIp = targetNode?.ipv6 || targetNode?.ipv4;
1874
2572
  if (!targetNode || !nodeIp) {
1875
- console.log(chalk8.red(`
2573
+ console.log(chalk9.red(`
1876
2574
  Target node not found or has no IP
1877
2575
  `));
1878
2576
  process.exit(1);
1879
2577
  }
1880
2578
  const connInfo = await uncloudRunner.getConnectionInfo(appName);
1881
2579
  if (connInfo.viaGateway) {
1882
- console.log(chalk8.cyan(`
2580
+ console.log(chalk9.cyan(`
1883
2581
  Connecting to ${targetNode.name} via gateway...
1884
2582
  `));
1885
2583
  } else {
1886
- console.log(chalk8.cyan(`
2584
+ console.log(chalk9.cyan(`
1887
2585
  Connecting to ${targetNode.name}...
1888
2586
  `));
1889
2587
  }
1890
2588
  if (connInfo.viaGateway && connInfo.localPort) {
1891
- execSync5(
2589
+ execSync6(
1892
2590
  `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`,
1893
2591
  { stdio: "inherit" }
1894
2592
  );
1895
2593
  } else {
1896
- execSync5(
2594
+ execSync6(
1897
2595
  `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${nodeIp}`,
1898
2596
  { stdio: "inherit" }
1899
2597
  );
1900
2598
  }
1901
2599
  } catch (error) {
1902
- console.error(chalk8.red(`
2600
+ console.error(chalk9.red(`
1903
2601
  Error: ${error.message}
1904
2602
  `));
1905
2603
  process.exit(1);
@@ -1914,18 +2612,18 @@ Error: ${error.message}
1914
2612
  const platformClient = new PlatformClient(platformToken);
1915
2613
  const app = await platformClient.getApp(appName);
1916
2614
  if (!app) {
1917
- console.log(chalk8.red(`
2615
+ console.log(chalk9.red(`
1918
2616
  \u274C App '${appName}' not found
1919
2617
  `));
1920
2618
  process.exit(1);
1921
2619
  }
1922
2620
  if (!options.force) {
1923
- console.log(chalk8.yellow(`
2621
+ console.log(chalk9.yellow(`
1924
2622
  \u26A0\uFE0F You are about to delete '${appName}' and all its infrastructure:`));
1925
2623
  console.log(` - ${app.nodes.length} VM(s) will be deleted`);
1926
2624
  console.log(` - All data will be lost
1927
2625
  `);
1928
- console.log(chalk8.red("Use --force flag to confirm deletion\n"));
2626
+ console.log(chalk9.red("Use --force flag to confirm deletion\n"));
1929
2627
  process.exit(1);
1930
2628
  }
1931
2629
  const spinner = ora5("Deleting infrastructure...").start();
@@ -1935,18 +2633,25 @@ Error: ${error.message}
1935
2633
  await platformClient.deleteVM(app.location, node.name);
1936
2634
  }
1937
2635
  spinner.text = "Removing app from database...";
2636
+ const appLocation = app.location;
1938
2637
  await platformClient.deleteApp(appName);
2638
+ spinner.text = "Syncing gateway routes...";
2639
+ try {
2640
+ await platformClient.syncGatewayRoutes(appLocation);
2641
+ } catch (error) {
2642
+ console.log(chalk9.dim(` Warning: Could not sync gateway: ${error.message}`));
2643
+ }
1939
2644
  spinner.text = "Cleaning up local configuration...";
1940
2645
  const contextName = app.uncloudContext || appName;
1941
2646
  removeUncloudContext(contextName);
1942
- spinner.succeed(chalk8.green(`App '${appName}' destroyed successfully`));
1943
- console.log(chalk8.cyan("\n\u{1F4A1} All infrastructure has been deleted.\n"));
2647
+ spinner.succeed(chalk9.green(`App '${appName}' destroyed successfully`));
2648
+ console.log(chalk9.cyan("\n\u{1F4A1} All infrastructure has been deleted.\n"));
1944
2649
  } catch (error) {
1945
- spinner.fail(chalk8.red("Failed to destroy app"));
2650
+ spinner.fail(chalk9.red("Failed to destroy app"));
1946
2651
  throw error;
1947
2652
  }
1948
2653
  } catch (error) {
1949
- console.error(chalk8.red(`
2654
+ console.error(chalk9.red(`
1950
2655
  \u274C Error: ${error.message}
1951
2656
  `));
1952
2657
  process.exit(1);
@@ -1959,35 +2664,35 @@ Error: ${error.message}
1959
2664
  const platformClient = new PlatformClient(platformToken);
1960
2665
  const app = await platformClient.getApp(appName);
1961
2666
  if (!app) {
1962
- console.log(chalk8.red(`
2667
+ console.log(chalk9.red(`
1963
2668
  App '${appName}' not found
1964
2669
  `));
1965
- console.log(`Run ${chalk8.bold("hackerrun apps")} to see your apps.
2670
+ console.log(`Run ${chalk9.bold("hackerrun apps")} to see your apps.
1966
2671
  `);
1967
2672
  process.exit(1);
1968
2673
  }
1969
2674
  const existingConfig = readAppConfig();
1970
2675
  if (existingConfig) {
1971
2676
  if (existingConfig.appName === appName) {
1972
- console.log(chalk8.yellow(`
2677
+ console.log(chalk9.yellow(`
1973
2678
  This directory is already linked to '${appName}'
1974
2679
  `));
1975
2680
  return;
1976
2681
  }
1977
- console.log(chalk8.yellow(`
2682
+ console.log(chalk9.yellow(`
1978
2683
  This directory is currently linked to '${existingConfig.appName}'`));
1979
2684
  console.log(`Updating link to '${appName}'...
1980
2685
  `);
1981
2686
  }
1982
2687
  linkApp(appName);
1983
- console.log(chalk8.green(`
2688
+ console.log(chalk9.green(`
1984
2689
  Linked to app '${appName}'
1985
2690
  `));
1986
2691
  console.log(` Domain: https://${app.domainName}.hackerrun.app`);
1987
- console.log(` Run ${chalk8.bold("hackerrun deploy")} to deploy changes.
2692
+ console.log(` Run ${chalk9.bold("hackerrun deploy")} to deploy changes.
1988
2693
  `);
1989
2694
  } catch (error) {
1990
- console.error(chalk8.red(`
2695
+ console.error(chalk9.red(`
1991
2696
  Error: ${error.message}
1992
2697
  `));
1993
2698
  process.exit(1);
@@ -2001,7 +2706,7 @@ Error: ${error.message}
2001
2706
  const currentName = options.app;
2002
2707
  const app = await platformClient.getApp(currentName);
2003
2708
  if (!app) {
2004
- console.log(chalk8.red(`
2709
+ console.log(chalk9.red(`
2005
2710
  App '${currentName}' not found
2006
2711
  `));
2007
2712
  process.exit(1);
@@ -2009,7 +2714,7 @@ App '${currentName}' not found
2009
2714
  const spinner = ora5(`Renaming app '${currentName}' to '${newName}'...`).start();
2010
2715
  try {
2011
2716
  const updatedApp = await platformClient.renameApp(currentName, newName);
2012
- spinner.succeed(chalk8.green(`App renamed successfully`));
2717
+ spinner.succeed(chalk9.green(`App renamed successfully`));
2013
2718
  console.log(`
2014
2719
  Old name: ${currentName}`);
2015
2720
  console.log(` New name: ${updatedApp.appName}`);
@@ -2018,15 +2723,15 @@ App '${currentName}' not found
2018
2723
  const existingConfig = readAppConfig();
2019
2724
  if (existingConfig?.appName === currentName) {
2020
2725
  linkApp(newName);
2021
- console.log(chalk8.dim(`Updated local hackerrun.yaml
2726
+ console.log(chalk9.dim(`Updated local hackerrun.yaml
2022
2727
  `));
2023
2728
  }
2024
2729
  } catch (error) {
2025
- spinner.fail(chalk8.red("Failed to rename app"));
2730
+ spinner.fail(chalk9.red("Failed to rename app"));
2026
2731
  throw error;
2027
2732
  }
2028
2733
  } catch (error) {
2029
- console.error(chalk8.red(`
2734
+ console.error(chalk9.red(`
2030
2735
  Error: ${error.message}
2031
2736
  `));
2032
2737
  process.exit(1);
@@ -2040,7 +2745,7 @@ Error: ${error.message}
2040
2745
  const appName = options.app;
2041
2746
  const app = await platformClient.getApp(appName);
2042
2747
  if (!app) {
2043
- console.log(chalk8.red(`
2748
+ console.log(chalk9.red(`
2044
2749
  App '${appName}' not found
2045
2750
  `));
2046
2751
  process.exit(1);
@@ -2048,22 +2753,32 @@ App '${appName}' not found
2048
2753
  const oldDomain = app.domainName;
2049
2754
  const spinner = ora5(`Changing domain from '${oldDomain}' to '${newDomain}'...`).start();
2050
2755
  try {
2051
- const updatedApp = await platformClient.renameDomain(appName, newDomain);
2052
- spinner.succeed(chalk8.green(`Domain changed successfully`));
2756
+ let updatedApp = app;
2757
+ if (oldDomain !== newDomain) {
2758
+ updatedApp = await platformClient.renameDomain(appName, newDomain);
2759
+ spinner.succeed(chalk9.green(`Domain changed successfully`));
2760
+ } else {
2761
+ spinner.succeed(chalk9.green(`Domain already set to '${newDomain}'`));
2762
+ }
2763
+ const primaryNode = app.nodes.find((n) => n.isPrimary);
2764
+ if (primaryNode?.ipv6) {
2765
+ spinner.start("Ensuring route exists...");
2766
+ await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
2767
+ spinner.succeed(chalk9.green("Route registered"));
2768
+ }
2053
2769
  spinner.start("Syncing gateway routes...");
2054
2770
  await platformClient.syncGatewayRoutes(app.location);
2055
- spinner.succeed(chalk8.green("Gateway routes synced"));
2771
+ spinner.succeed(chalk9.green("Gateway routes synced"));
2056
2772
  console.log(`
2057
2773
  App: ${updatedApp.appName}`);
2058
- console.log(` Old domain: https://${oldDomain}.hackerrun.app`);
2059
- console.log(` New domain: https://${updatedApp.domainName}.hackerrun.app
2774
+ console.log(` Domain: https://${updatedApp.domainName}.hackerrun.app
2060
2775
  `);
2061
2776
  } catch (error) {
2062
- spinner.fail(chalk8.red("Failed to change domain"));
2777
+ spinner.fail(chalk9.red("Failed to change domain"));
2063
2778
  throw error;
2064
2779
  }
2065
2780
  } catch (error) {
2066
- console.error(chalk8.red(`
2781
+ console.error(chalk9.red(`
2067
2782
  Error: ${error.message}
2068
2783
  `));
2069
2784
  process.exit(1);
@@ -2074,7 +2789,7 @@ Error: ${error.message}
2074
2789
 
2075
2790
  // src/commands/config.ts
2076
2791
  import { Command as Command4 } from "commander";
2077
- import chalk9 from "chalk";
2792
+ import chalk10 from "chalk";
2078
2793
  function createConfigCommand() {
2079
2794
  const cmd = new Command4("config");
2080
2795
  cmd.description("Manage hackerrun configuration");
@@ -2082,20 +2797,20 @@ function createConfigCommand() {
2082
2797
  try {
2083
2798
  const configManager = new ConfigManager();
2084
2799
  if (key !== "apiToken") {
2085
- console.log(chalk9.red(`
2800
+ console.log(chalk10.red(`
2086
2801
  \u274C Invalid key: ${key}
2087
2802
  `));
2088
- console.log(chalk9.cyan("Valid keys:"));
2803
+ console.log(chalk10.cyan("Valid keys:"));
2089
2804
  console.log(" - apiToken Platform API token\n");
2090
2805
  process.exit(1);
2091
2806
  }
2092
2807
  configManager.set(key, value);
2093
2808
  const displayValue = configManager.getAll(true).apiToken;
2094
- console.log(chalk9.green(`
2809
+ console.log(chalk10.green(`
2095
2810
  \u2713 Set ${key} = ${displayValue}
2096
2811
  `));
2097
2812
  } catch (error) {
2098
- console.error(chalk9.red(`
2813
+ console.error(chalk10.red(`
2099
2814
  \u274C Error: ${error.message}
2100
2815
  `));
2101
2816
  process.exit(1);
@@ -2105,24 +2820,24 @@ function createConfigCommand() {
2105
2820
  try {
2106
2821
  const configManager = new ConfigManager();
2107
2822
  if (key !== "apiToken") {
2108
- console.log(chalk9.red(`
2823
+ console.log(chalk10.red(`
2109
2824
  \u274C Invalid key: ${key}
2110
2825
  `));
2111
2826
  process.exit(1);
2112
2827
  }
2113
2828
  const value = configManager.get(key);
2114
2829
  if (!value) {
2115
- console.log(chalk9.yellow(`
2830
+ console.log(chalk10.yellow(`
2116
2831
  \u26A0\uFE0F ${key} is not set
2117
2832
  `));
2118
2833
  process.exit(1);
2119
2834
  }
2120
2835
  const displayValue = options.showSecrets ? value : configManager.getAll(true).apiToken;
2121
- console.log(chalk9.cyan(`
2836
+ console.log(chalk10.cyan(`
2122
2837
  ${key} = ${displayValue}
2123
2838
  `));
2124
2839
  } catch (error) {
2125
- console.error(chalk9.red(`
2840
+ console.error(chalk10.red(`
2126
2841
  \u274C Error: ${error.message}
2127
2842
  `));
2128
2843
  process.exit(1);
@@ -2133,18 +2848,18 @@ ${key} = ${displayValue}
2133
2848
  const configManager = new ConfigManager();
2134
2849
  const config = configManager.getAll(!options.showSecrets);
2135
2850
  if (Object.keys(config).length === 0) {
2136
- console.log(chalk9.yellow("\n\u26A0\uFE0F No configuration found\n"));
2137
- console.log(chalk9.cyan("Get started by logging in:\n"));
2851
+ console.log(chalk10.yellow("\n\u26A0\uFE0F No configuration found\n"));
2852
+ console.log(chalk10.cyan("Get started by logging in:\n"));
2138
2853
  console.log(" hackerrun login\n");
2139
2854
  return;
2140
2855
  }
2141
- console.log(chalk9.cyan("\n\u{1F4DD} Configuration:\n"));
2142
- console.log(` apiToken: ${config.apiToken || chalk9.red("not set")}`);
2143
- console.log(chalk9.dim(`
2856
+ console.log(chalk10.cyan("\n\u{1F4DD} Configuration:\n"));
2857
+ console.log(` apiToken: ${config.apiToken || chalk10.red("not set")}`);
2858
+ console.log(chalk10.dim(`
2144
2859
  Config file: ${configManager.getConfigPath()}
2145
2860
  `));
2146
2861
  } catch (error) {
2147
- console.error(chalk9.red(`
2862
+ console.error(chalk10.red(`
2148
2863
  \u274C Error: ${error.message}
2149
2864
  `));
2150
2865
  process.exit(1);
@@ -2154,17 +2869,17 @@ Config file: ${configManager.getConfigPath()}
2154
2869
  try {
2155
2870
  const configManager = new ConfigManager();
2156
2871
  if (key !== "apiToken") {
2157
- console.log(chalk9.red(`
2872
+ console.log(chalk10.red(`
2158
2873
  \u274C Invalid key: ${key}
2159
2874
  `));
2160
2875
  process.exit(1);
2161
2876
  }
2162
2877
  configManager.unset(key);
2163
- console.log(chalk9.green(`
2878
+ console.log(chalk10.green(`
2164
2879
  \u2713 Removed ${key}
2165
2880
  `));
2166
2881
  } catch (error) {
2167
- console.error(chalk9.red(`
2882
+ console.error(chalk10.red(`
2168
2883
  \u274C Error: ${error.message}
2169
2884
  `));
2170
2885
  process.exit(1);
@@ -2175,7 +2890,7 @@ Config file: ${configManager.getConfigPath()}
2175
2890
  const configManager = new ConfigManager();
2176
2891
  console.log(configManager.getConfigPath());
2177
2892
  } catch (error) {
2178
- console.error(chalk9.red(`
2893
+ console.error(chalk10.red(`
2179
2894
  \u274C Error: ${error.message}
2180
2895
  `));
2181
2896
  process.exit(1);
@@ -2186,7 +2901,7 @@ Config file: ${configManager.getConfigPath()}
2186
2901
 
2187
2902
  // src/commands/logs.ts
2188
2903
  import { Command as Command5 } from "commander";
2189
- import chalk10 from "chalk";
2904
+ import chalk11 from "chalk";
2190
2905
  function createLogsCommand() {
2191
2906
  const cmd = new Command5("logs");
2192
2907
  cmd.description("View logs for app services").argument("<app>", "App name").argument("[service]", "Service name (optional - lists services if omitted)").option("-f, --follow", "Follow log output").option("--tail <lines>", "Number of lines to show from the end of the logs").action(async (appName, serviceName, options) => {
@@ -2196,13 +2911,13 @@ function createLogsCommand() {
2196
2911
  try {
2197
2912
  const app = await platformClient.getApp(appName);
2198
2913
  if (!app) {
2199
- console.log(chalk10.red(`
2914
+ console.log(chalk11.red(`
2200
2915
  App '${appName}' not found
2201
2916
  `));
2202
- console.log(chalk10.cyan("Available apps:"));
2917
+ console.log(chalk11.cyan("Available apps:"));
2203
2918
  const apps = await platformClient.listApps();
2204
2919
  if (apps.length === 0) {
2205
- console.log(chalk10.yellow(" No apps deployed yet\n"));
2920
+ console.log(chalk11.yellow(" No apps deployed yet\n"));
2206
2921
  } else {
2207
2922
  apps.forEach((a) => console.log(` - ${a.appName}`));
2208
2923
  console.log();
@@ -2225,27 +2940,27 @@ function createLogsCommand() {
2225
2940
  }
2226
2941
  }
2227
2942
  if (services.length === 0) {
2228
- console.log(chalk10.yellow(`
2943
+ console.log(chalk11.yellow(`
2229
2944
  No services found in app '${appName}'
2230
2945
  `));
2231
2946
  return;
2232
2947
  }
2233
- console.log(chalk10.cyan(`
2948
+ console.log(chalk11.cyan(`
2234
2949
  Available services in '${appName}':
2235
2950
  `));
2236
- services.forEach((s) => console.log(` ${chalk10.bold(s)}`));
2237
- console.log(chalk10.dim(`
2951
+ services.forEach((s) => console.log(` ${chalk11.bold(s)}`));
2952
+ console.log(chalk11.dim(`
2238
2953
  Usage: hackerrun logs ${appName} <service>`));
2239
- console.log(chalk10.dim(`Example: hackerrun logs ${appName} ${services[0]}
2954
+ console.log(chalk11.dim(`Example: hackerrun logs ${appName} ${services[0]}
2240
2955
  `));
2241
2956
  return;
2242
2957
  } catch (error) {
2243
- console.error(chalk10.red("\n Failed to list services"));
2244
- console.error(chalk10.yellow("Make sure the app is deployed and running\n"));
2958
+ console.error(chalk11.red("\n Failed to list services"));
2959
+ console.error(chalk11.yellow("Make sure the app is deployed and running\n"));
2245
2960
  process.exit(1);
2246
2961
  }
2247
2962
  }
2248
- console.log(chalk10.cyan(`
2963
+ console.log(chalk11.cyan(`
2249
2964
  Viewing logs for '${serviceName}' in app '${appName}'...
2250
2965
  `));
2251
2966
  try {
@@ -2254,13 +2969,13 @@ Usage: hackerrun logs ${appName} <service>`));
2254
2969
  tail: options.tail ? parseInt(options.tail) : void 0
2255
2970
  });
2256
2971
  } catch (error) {
2257
- console.error(chalk10.red(`
2972
+ console.error(chalk11.red(`
2258
2973
  Failed to fetch logs for service '${serviceName}'`));
2259
- console.error(chalk10.yellow("The service may not exist or may not be running\n"));
2974
+ console.error(chalk11.yellow("The service may not exist or may not be running\n"));
2260
2975
  process.exit(1);
2261
2976
  }
2262
2977
  } catch (error) {
2263
- console.error(chalk10.red(`
2978
+ console.error(chalk11.red(`
2264
2979
  Error: ${error.message}
2265
2980
  `));
2266
2981
  process.exit(1);
@@ -2273,10 +2988,10 @@ Usage: hackerrun logs ${appName} <service>`));
2273
2988
 
2274
2989
  // src/commands/env.ts
2275
2990
  import { Command as Command6 } from "commander";
2276
- import chalk11 from "chalk";
2991
+ import chalk12 from "chalk";
2277
2992
  import ora6 from "ora";
2278
2993
  import { password } from "@inquirer/prompts";
2279
- import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
2994
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2280
2995
  function parseEnvFile(content) {
2281
2996
  const vars = {};
2282
2997
  for (const line of content.split("\n")) {
@@ -2315,14 +3030,14 @@ function createEnvCommand() {
2315
3030
  });
2316
3031
  }
2317
3032
  if (!key) {
2318
- console.error(chalk11.red("\nError: Key cannot be empty\n"));
3033
+ console.error(chalk12.red("\nError: Key cannot be empty\n"));
2319
3034
  process.exit(1);
2320
3035
  }
2321
3036
  const spinner = ora6(`Setting ${key}...`).start();
2322
3037
  await platformClient.setEnvVar(appName, key, value);
2323
3038
  spinner.succeed(`Set ${key}`);
2324
3039
  } catch (error) {
2325
- console.error(chalk11.red(`
3040
+ console.error(chalk12.red(`
2326
3041
  Error: ${error.message}
2327
3042
  `));
2328
3043
  process.exit(1);
@@ -2333,24 +3048,24 @@ Error: ${error.message}
2333
3048
  const appName = options.app || getAppName();
2334
3049
  const platformToken = getPlatformToken();
2335
3050
  const platformClient = new PlatformClient(platformToken);
2336
- if (!existsSync5(filePath)) {
2337
- console.error(chalk11.red(`
3051
+ if (!existsSync6(filePath)) {
3052
+ console.error(chalk12.red(`
2338
3053
  Error: File not found: ${filePath}
2339
3054
  `));
2340
3055
  process.exit(1);
2341
3056
  }
2342
- const content = readFileSync5(filePath, "utf-8");
3057
+ const content = readFileSync6(filePath, "utf-8");
2343
3058
  const vars = parseEnvFile(content);
2344
3059
  const count = Object.keys(vars).length;
2345
3060
  if (count === 0) {
2346
- console.log(chalk11.yellow("\nNo environment variables found in file.\n"));
3061
+ console.log(chalk12.yellow("\nNo environment variables found in file.\n"));
2347
3062
  return;
2348
3063
  }
2349
3064
  const spinner = ora6(`Uploading ${count} variable${count > 1 ? "s" : ""}...`).start();
2350
3065
  await platformClient.setEnvVars(appName, vars);
2351
3066
  spinner.succeed(`Uploaded ${count} environment variable${count > 1 ? "s" : ""}`);
2352
3067
  } catch (error) {
2353
- console.error(chalk11.red(`
3068
+ console.error(chalk12.red(`
2354
3069
  Error: ${error.message}
2355
3070
  `));
2356
3071
  process.exit(1);
@@ -2365,25 +3080,25 @@ Error: ${error.message}
2365
3080
  const vars = await platformClient.listEnvVars(appName);
2366
3081
  spinner.stop();
2367
3082
  if (vars.length === 0) {
2368
- console.log(chalk11.yellow(`
3083
+ console.log(chalk12.yellow(`
2369
3084
  No environment variables set for '${appName}'.
2370
3085
  `));
2371
- console.log(chalk11.cyan("Set variables with:\n"));
3086
+ console.log(chalk12.cyan("Set variables with:\n"));
2372
3087
  console.log(` hackerrun env set KEY=value`);
2373
3088
  console.log(` hackerrun env upload .env
2374
3089
  `);
2375
3090
  return;
2376
3091
  }
2377
- console.log(chalk11.cyan(`
3092
+ console.log(chalk12.cyan(`
2378
3093
  Environment variables for '${appName}':
2379
3094
  `));
2380
3095
  for (const v of vars) {
2381
3096
  const masked = "*".repeat(Math.min(v.valueLength, 20));
2382
- console.log(` ${chalk11.bold(v.key)}=${chalk11.dim(masked)}`);
3097
+ console.log(` ${chalk12.bold(v.key)}=${chalk12.dim(masked)}`);
2383
3098
  }
2384
3099
  console.log();
2385
3100
  } catch (error) {
2386
- console.error(chalk11.red(`
3101
+ console.error(chalk12.red(`
2387
3102
  Error: ${error.message}
2388
3103
  `));
2389
3104
  process.exit(1);
@@ -2398,7 +3113,7 @@ Error: ${error.message}
2398
3113
  await platformClient.unsetEnvVar(appName, key);
2399
3114
  spinner.succeed(`Removed ${key}`);
2400
3115
  } catch (error) {
2401
- console.error(chalk11.red(`
3116
+ console.error(chalk12.red(`
2402
3117
  Error: ${error.message}
2403
3118
  `));
2404
3119
  process.exit(1);
@@ -2409,16 +3124,16 @@ Error: ${error.message}
2409
3124
 
2410
3125
  // src/commands/connect.ts
2411
3126
  import { Command as Command7 } from "commander";
2412
- import chalk12 from "chalk";
3127
+ import chalk13 from "chalk";
2413
3128
  import ora7 from "ora";
2414
3129
  import { select } from "@inquirer/prompts";
2415
- function sleep2(ms) {
3130
+ function sleep3(ms) {
2416
3131
  return new Promise((resolve) => setTimeout(resolve, ms));
2417
3132
  }
2418
3133
  async function pollForInstallation(platformClient, stateToken, spinner, maxAttempts = 60, intervalMs = 5e3) {
2419
3134
  let attempts = 0;
2420
3135
  while (attempts < maxAttempts) {
2421
- await sleep2(intervalMs);
3136
+ await sleep3(intervalMs);
2422
3137
  attempts++;
2423
3138
  const result = await platformClient.pollGitHubConnect(stateToken);
2424
3139
  if (result.status === "complete" && result.installationId) {
@@ -2436,7 +3151,7 @@ function createConnectCommand() {
2436
3151
  const appName = options.app || getAppName();
2437
3152
  const platformToken = getPlatformToken();
2438
3153
  const platformClient = new PlatformClient(platformToken);
2439
- console.log(chalk12.cyan(`
3154
+ console.log(chalk13.cyan(`
2440
3155
  Connecting GitHub repository to '${appName}'
2441
3156
  `));
2442
3157
  let app = await platformClient.getApp(appName);
@@ -2446,35 +3161,42 @@ Connecting GitHub repository to '${appName}'
2446
3161
  spinner.succeed(`Created app '${appName}'`);
2447
3162
  if (!hasAppConfig()) {
2448
3163
  linkApp(appName);
2449
- console.log(chalk12.dim(` Created hackerrun.yaml`));
3164
+ console.log(chalk13.dim(` Created hackerrun.yaml`));
2450
3165
  }
2451
3166
  }
2452
3167
  const existingRepo = await platformClient.getConnectedRepo(appName);
2453
3168
  if (existingRepo) {
2454
- console.log(chalk12.yellow(`
3169
+ console.log(chalk13.yellow(`
2455
3170
  App '${appName}' is already connected to:`));
2456
- console.log(` ${chalk12.bold(existingRepo.repoFullName)} (branch: ${existingRepo.branch})
3171
+ console.log(` ${chalk13.bold(existingRepo.repoFullName)} (branch: ${existingRepo.branch})
2457
3172
  `);
2458
3173
  const action = await select({
2459
3174
  message: "What would you like to do?",
2460
3175
  choices: [
2461
3176
  { name: "Change repository", value: "change" },
2462
- { name: "Keep current connection", value: "keep" }
3177
+ { name: "Keep current connection", value: "keep" },
3178
+ { name: "Reconnect (delete and re-setup)", value: "reconnect" }
2463
3179
  ]
2464
3180
  });
2465
3181
  if (action === "keep") {
2466
- console.log(chalk12.green("\nConnection unchanged.\n"));
3182
+ console.log(chalk13.green("\nConnection unchanged.\n"));
2467
3183
  return;
2468
3184
  }
3185
+ if (action === "reconnect") {
3186
+ const disconnectSpinner = ora7("Disconnecting existing repository...").start();
3187
+ await platformClient.disconnectRepo(appName);
3188
+ disconnectSpinner.succeed("Existing connection removed");
3189
+ console.log(chalk13.cyan("\nReconnecting...\n"));
3190
+ }
2469
3191
  }
2470
3192
  let installation = await platformClient.getGitHubInstallation();
2471
3193
  if (!installation) {
2472
- console.log(chalk12.cyan("First, install the HackerRun GitHub App:\n"));
3194
+ console.log(chalk13.cyan("First, install the HackerRun GitHub App:\n"));
2473
3195
  const spinner = ora7("Initiating GitHub connection...").start();
2474
3196
  const flow = await platformClient.initiateGitHubConnect();
2475
3197
  spinner.succeed("GitHub connection initiated");
2476
- console.log(chalk12.cyan("\nPlease complete the following steps:\n"));
2477
- console.log(` 1. Visit: ${chalk12.bold.blue(flow.authUrl)}`);
3198
+ console.log(chalk13.cyan("\nPlease complete the following steps:\n"));
3199
+ console.log(` 1. Visit: ${chalk13.bold.blue(flow.authUrl)}`);
2478
3200
  console.log(` 2. Install the HackerRun app on your account`);
2479
3201
  console.log(` 3. Select the repositories you want to access
2480
3202
  `);
@@ -2487,23 +3209,56 @@ App '${appName}' is already connected to:`));
2487
3209
  process.exit(1);
2488
3210
  }
2489
3211
  } else {
2490
- console.log(chalk12.green(`\u2713 GitHub App already installed (${installation.accountLogin})
3212
+ console.log(chalk13.green(`\u2713 GitHub App already installed (${installation.accountLogin})
2491
3213
  `));
2492
3214
  }
2493
3215
  const repoSpinner = ora7("Fetching accessible repositories...").start();
2494
- const repos = await platformClient.listAccessibleRepos();
3216
+ let repos;
3217
+ try {
3218
+ repos = await platformClient.listAccessibleRepos();
3219
+ } catch (error) {
3220
+ repoSpinner.stop();
3221
+ const isStaleInstallation = error.message.includes("create-an-installation-access-token") || error.message.includes("Bad credentials") || error.message.includes("installation") && error.message.includes("Not Found");
3222
+ if (isStaleInstallation) {
3223
+ console.log(chalk13.yellow("\nGitHub App installation is stale or was removed from GitHub."));
3224
+ console.log(chalk13.cyan("Clearing stale record and prompting for reinstallation...\n"));
3225
+ await platformClient.deleteGitHubInstallation();
3226
+ console.log(chalk13.cyan("Please reinstall the HackerRun GitHub App:\n"));
3227
+ const spinner = ora7("Initiating GitHub connection...").start();
3228
+ const flow = await platformClient.initiateGitHubConnect();
3229
+ spinner.succeed("GitHub connection initiated");
3230
+ console.log(chalk13.cyan("\nPlease complete the following steps:\n"));
3231
+ console.log(` 1. Visit: ${chalk13.bold.blue(flow.authUrl)}`);
3232
+ console.log(` 2. Install the HackerRun app on your account`);
3233
+ console.log(` 3. Select the repositories you want to access
3234
+ `);
3235
+ const pollSpinner = ora7("Waiting for GitHub authorization...").start();
3236
+ try {
3237
+ await pollForInstallation(platformClient, flow.stateToken, pollSpinner);
3238
+ pollSpinner.succeed("GitHub App installed");
3239
+ } catch (pollError) {
3240
+ pollSpinner.fail(pollError.message);
3241
+ process.exit(1);
3242
+ }
3243
+ const retrySpinner = ora7("Fetching accessible repositories...").start();
3244
+ repos = await platformClient.listAccessibleRepos();
3245
+ retrySpinner.stop();
3246
+ } else {
3247
+ throw error;
3248
+ }
3249
+ }
2495
3250
  repoSpinner.stop();
2496
3251
  if (repos.length === 0) {
2497
- console.log(chalk12.yellow("\nNo repositories found."));
2498
- console.log(chalk12.cyan("Make sure you have given HackerRun access to your repositories.\n"));
2499
- console.log(`Visit ${chalk12.blue("https://github.com/settings/installations")} to manage access.
3252
+ console.log(chalk13.yellow("\nNo repositories found."));
3253
+ console.log(chalk13.cyan("Make sure you have given HackerRun access to your repositories.\n"));
3254
+ console.log(`Visit ${chalk13.blue("https://github.com/settings/installations")} to manage access.
2500
3255
  `);
2501
3256
  process.exit(1);
2502
3257
  }
2503
3258
  const selectedRepo = await select({
2504
3259
  message: "Select repository to connect:",
2505
3260
  choices: repos.map((r) => ({
2506
- name: `${r.fullName}${r.private ? chalk12.dim(" (private)") : ""} [${r.defaultBranch}]`,
3261
+ name: `${r.fullName}${r.private ? chalk13.dim(" (private)") : ""} [${r.defaultBranch}]`,
2507
3262
  value: r.fullName
2508
3263
  }))
2509
3264
  });
@@ -2512,17 +3267,17 @@ App '${appName}' is already connected to:`));
2512
3267
  const connectSpinner = ora7("Connecting repository...").start();
2513
3268
  await platformClient.connectRepo(appName, selectedRepo, branch);
2514
3269
  connectSpinner.succeed(`Connected ${selectedRepo}`);
2515
- console.log(chalk12.green(`
3270
+ console.log(chalk13.green(`
2516
3271
  \u2713 Successfully connected!
2517
3272
  `));
2518
- console.log(chalk12.cyan("Auto-deploy is now enabled:"));
2519
- console.log(` Repository: ${chalk12.bold(selectedRepo)}`);
2520
- console.log(` Branch: ${chalk12.bold(branch)}`);
2521
- console.log(` App: ${chalk12.bold(appName)}
3273
+ console.log(chalk13.cyan("Auto-deploy is now enabled:"));
3274
+ console.log(` Repository: ${chalk13.bold(selectedRepo)}`);
3275
+ console.log(` Branch: ${chalk13.bold(branch)}`);
3276
+ console.log(` App: ${chalk13.bold(appName)}
2522
3277
  `);
2523
- console.log(chalk12.dim("Every push to this branch will trigger a build and deploy.\n"));
3278
+ console.log(chalk13.dim("Every push to this branch will trigger a build and deploy.\n"));
2524
3279
  } catch (error) {
2525
- console.error(chalk12.red(`
3280
+ console.error(chalk13.red(`
2526
3281
  Error: ${error.message}
2527
3282
  `));
2528
3283
  process.exit(1);
@@ -2539,7 +3294,7 @@ function createDisconnectCommand() {
2539
3294
  const platformClient = new PlatformClient(platformToken);
2540
3295
  const existingRepo = await platformClient.getConnectedRepo(appName);
2541
3296
  if (!existingRepo) {
2542
- console.log(chalk12.yellow(`
3297
+ console.log(chalk13.yellow(`
2543
3298
  No repository connected to '${appName}'.
2544
3299
  `));
2545
3300
  return;
@@ -2547,12 +3302,12 @@ No repository connected to '${appName}'.
2547
3302
  const spinner = ora7("Disconnecting repository...").start();
2548
3303
  await platformClient.disconnectRepo(appName);
2549
3304
  spinner.succeed("Repository disconnected");
2550
- console.log(chalk12.green(`
3305
+ console.log(chalk13.green(`
2551
3306
  \u2713 Disconnected ${existingRepo.repoFullName} from '${appName}'
2552
3307
  `));
2553
- console.log(chalk12.dim("Auto-deploy is now disabled. Use `hackerrun connect` to reconnect.\n"));
3308
+ console.log(chalk13.dim("Auto-deploy is now disabled. Use `hackerrun connect` to reconnect.\n"));
2554
3309
  } catch (error) {
2555
- console.error(chalk12.red(`
3310
+ console.error(chalk13.red(`
2556
3311
  Error: ${error.message}
2557
3312
  `));
2558
3313
  process.exit(1);
@@ -2563,9 +3318,9 @@ Error: ${error.message}
2563
3318
 
2564
3319
  // src/commands/builds.ts
2565
3320
  import { Command as Command8 } from "commander";
2566
- import chalk13 from "chalk";
3321
+ import chalk14 from "chalk";
2567
3322
  import ora8 from "ora";
2568
- function sleep3(ms) {
3323
+ function sleep4(ms) {
2569
3324
  return new Promise((resolve) => setTimeout(resolve, ms));
2570
3325
  }
2571
3326
  function formatDuration(startedAt, completedAt) {
@@ -2592,22 +3347,22 @@ function formatRelativeTime(dateStr) {
2592
3347
  function getStatusIcon(status) {
2593
3348
  switch (status) {
2594
3349
  case "success":
2595
- return chalk13.green("\u2713");
3350
+ return chalk14.green("\u2713");
2596
3351
  case "failed":
2597
- return chalk13.red("\u2717");
3352
+ return chalk14.red("\u2717");
2598
3353
  case "building":
2599
- return chalk13.yellow("\u25CF");
3354
+ return chalk14.yellow("\u25CF");
2600
3355
  case "pending":
2601
- return chalk13.dim("\u25CB");
3356
+ return chalk14.dim("\u25CB");
2602
3357
  default:
2603
- return chalk13.dim("?");
3358
+ return chalk14.dim("?");
2604
3359
  }
2605
3360
  }
2606
3361
  function getStageIcon(stage, status) {
2607
- if (status === "completed") return chalk13.green("\u2713");
2608
- if (status === "failed") return chalk13.red("\u2717");
2609
- if (status === "started") return chalk13.yellow("\u25CF");
2610
- return chalk13.dim("\u25CB");
3362
+ if (status === "completed") return chalk14.green("\u2713");
3363
+ if (status === "failed") return chalk14.red("\u2717");
3364
+ if (status === "started") return chalk14.yellow("\u25CF");
3365
+ return chalk14.dim("\u25CB");
2611
3366
  }
2612
3367
  function formatEventTime(startTime, eventTime) {
2613
3368
  const diffMs = eventTime.getTime() - startTime.getTime();
@@ -2633,7 +3388,7 @@ function createBuildsCommand() {
2633
3388
  await listBuilds(platformClient, appName, parseInt(options.limit, 10));
2634
3389
  }
2635
3390
  } catch (error) {
2636
- console.error(chalk13.red(`
3391
+ console.error(chalk14.red(`
2637
3392
  Error: ${error.message}
2638
3393
  `));
2639
3394
  process.exit(1);
@@ -2646,25 +3401,25 @@ async function listBuilds(platformClient, appName, limit) {
2646
3401
  const builds = await platformClient.listBuilds(appName, limit);
2647
3402
  spinner.stop();
2648
3403
  if (builds.length === 0) {
2649
- console.log(chalk13.yellow(`
3404
+ console.log(chalk14.yellow(`
2650
3405
  No builds found for '${appName}'.
2651
3406
  `));
2652
- console.log(chalk13.cyan("Connect a GitHub repo to enable auto-deploy:\n"));
3407
+ console.log(chalk14.cyan("Connect a GitHub repo to enable auto-deploy:\n"));
2653
3408
  console.log(` hackerrun connect
2654
3409
  `);
2655
3410
  return;
2656
3411
  }
2657
- console.log(chalk13.cyan(`
3412
+ console.log(chalk14.cyan(`
2658
3413
  Builds for '${appName}':
2659
3414
  `));
2660
3415
  for (const build of builds) {
2661
3416
  const icon = getStatusIcon(build.status);
2662
3417
  const sha = build.commitSha.substring(0, 7);
2663
- const msg = build.commitMsg ? build.commitMsg.length > 50 ? build.commitMsg.substring(0, 47) + "..." : build.commitMsg : chalk13.dim("No message");
3418
+ const msg = build.commitMsg ? build.commitMsg.length > 50 ? build.commitMsg.substring(0, 47) + "..." : build.commitMsg : chalk14.dim("No message");
2664
3419
  const duration = formatDuration(build.startedAt, build.completedAt);
2665
3420
  const time = formatRelativeTime(build.createdAt);
2666
- console.log(` ${icon} ${chalk13.bold(`#${build.id}`)} ${chalk13.dim(sha)} ${msg}`);
2667
- console.log(` ${chalk13.dim(`${build.branch} \u2022 ${duration} \u2022 ${time}`)}`);
3421
+ console.log(` ${icon} ${chalk14.bold(`#${build.id}`)} ${chalk14.dim(sha)} ${msg}`);
3422
+ console.log(` ${chalk14.dim(`${build.branch} \u2022 ${duration} \u2022 ${time}`)}`);
2668
3423
  console.log();
2669
3424
  }
2670
3425
  }
@@ -2675,16 +3430,16 @@ async function showBuildDetails(platformClient, appName, buildId) {
2675
3430
  spinner.stop();
2676
3431
  const icon = getStatusIcon(build.status);
2677
3432
  const sha = build.commitSha.substring(0, 7);
2678
- console.log(chalk13.cyan(`
3433
+ console.log(chalk14.cyan(`
2679
3434
  Build #${build.id}
2680
3435
  `));
2681
3436
  console.log(` Status: ${icon} ${build.status}`);
2682
- console.log(` Commit: ${chalk13.dim(sha)} ${build.commitMsg || chalk13.dim("No message")}`);
3437
+ console.log(` Commit: ${chalk14.dim(sha)} ${build.commitMsg || chalk14.dim("No message")}`);
2683
3438
  console.log(` Branch: ${build.branch}`);
2684
3439
  console.log(` Duration: ${formatDuration(build.startedAt, build.completedAt)}`);
2685
3440
  console.log(` Created: ${formatRelativeTime(build.createdAt)}`);
2686
3441
  if (events.length > 0) {
2687
- console.log(chalk13.cyan("\nBuild Events:\n"));
3442
+ console.log(chalk14.cyan("\nBuild Events:\n"));
2688
3443
  const startTime = new Date(events[0].timestamp);
2689
3444
  for (const event of events) {
2690
3445
  const eventTime = new Date(event.timestamp);
@@ -2694,29 +3449,29 @@ Build #${build.id}
2694
3449
  }
2695
3450
  }
2696
3451
  if (build.logs) {
2697
- console.log(chalk13.cyan("\nBuild Logs:\n"));
2698
- console.log(chalk13.dim(build.logs));
3452
+ console.log(chalk14.cyan("\nBuild Logs:\n"));
3453
+ console.log(chalk14.dim(build.logs));
2699
3454
  }
2700
3455
  console.log();
2701
3456
  }
2702
3457
  async function streamLatestBuild(platformClient, appName) {
2703
- console.log(chalk13.cyan(`
3458
+ console.log(chalk14.cyan(`
2704
3459
  Watching latest build for '${appName}'...
2705
3460
  `));
2706
- console.log(chalk13.dim("Press Ctrl+C to stop\n"));
3461
+ console.log(chalk14.dim("Press Ctrl+C to stop\n"));
2707
3462
  let currentBuildId = null;
2708
3463
  let lastEventTimestamp = null;
2709
3464
  let buildStartTime = null;
2710
3465
  process.on("SIGINT", () => {
2711
- console.log(chalk13.dim("\n\nStopped watching.\n"));
3466
+ console.log(chalk14.dim("\n\nStopped watching.\n"));
2712
3467
  process.exit(0);
2713
3468
  });
2714
3469
  while (true) {
2715
3470
  try {
2716
3471
  const builds = await platformClient.listBuilds(appName, 1);
2717
3472
  if (builds.length === 0) {
2718
- console.log(chalk13.dim("Waiting for builds..."));
2719
- await sleep3(5e3);
3473
+ console.log(chalk14.dim("Waiting for builds..."));
3474
+ await sleep4(5e3);
2720
3475
  continue;
2721
3476
  }
2722
3477
  const latestBuild = builds[0];
@@ -2724,9 +3479,9 @@ Watching latest build for '${appName}'...
2724
3479
  currentBuildId = latestBuild.id;
2725
3480
  lastEventTimestamp = null;
2726
3481
  buildStartTime = latestBuild.startedAt ? new Date(latestBuild.startedAt) : /* @__PURE__ */ new Date();
2727
- console.log(chalk13.bold(`
2728
- Build #${latestBuild.id}`) + ` - commit ${chalk13.dim(latestBuild.commitSha.substring(0, 7))} "${latestBuild.commitMsg || "No message"}"`);
2729
- console.log(chalk13.dim(`Branch: ${latestBuild.branch} | Started: ${formatRelativeTime(latestBuild.createdAt)}`));
3482
+ console.log(chalk14.bold(`
3483
+ Build #${latestBuild.id}`) + ` - commit ${chalk14.dim(latestBuild.commitSha.substring(0, 7))} "${latestBuild.commitMsg || "No message"}"`);
3484
+ console.log(chalk14.dim(`Branch: ${latestBuild.branch} | Started: ${formatRelativeTime(latestBuild.createdAt)}`));
2730
3485
  console.log();
2731
3486
  }
2732
3487
  const events = await platformClient.getBuildEvents(appName, currentBuildId, lastEventTimestamp || void 0);
@@ -2742,31 +3497,31 @@ Build #${latestBuild.id}`) + ` - commit ${chalk13.dim(latestBuild.commitSha.subs
2742
3497
  const icon = getStatusIcon(latestBuild.status);
2743
3498
  console.log();
2744
3499
  console.log(`${icon} Build #${latestBuild.id} ${latestBuild.status} in ${duration}`);
2745
- console.log(chalk13.dim("\nWaiting for next build...\n"));
3500
+ console.log(chalk14.dim("\nWaiting for next build...\n"));
2746
3501
  currentBuildId = null;
2747
3502
  }
2748
- await sleep3(2e3);
3503
+ await sleep4(2e3);
2749
3504
  } catch (error) {
2750
- await sleep3(5e3);
3505
+ await sleep4(5e3);
2751
3506
  }
2752
3507
  }
2753
3508
  }
2754
3509
  async function watchBuilds(platformClient, appName) {
2755
- console.log(chalk13.cyan(`
3510
+ console.log(chalk14.cyan(`
2756
3511
  Watching builds for '${appName}'...
2757
3512
  `));
2758
- console.log(chalk13.dim("Press Ctrl+C to stop\n"));
3513
+ console.log(chalk14.dim("Press Ctrl+C to stop\n"));
2759
3514
  let lastBuildId = null;
2760
3515
  process.on("SIGINT", () => {
2761
- console.log(chalk13.dim("\n\nStopped watching.\n"));
3516
+ console.log(chalk14.dim("\n\nStopped watching.\n"));
2762
3517
  process.exit(0);
2763
3518
  });
2764
3519
  while (true) {
2765
3520
  try {
2766
3521
  const builds = await platformClient.listBuilds(appName, 5);
2767
3522
  if (builds.length === 0) {
2768
- console.log(chalk13.dim("No builds yet. Waiting..."));
2769
- await sleep3(5e3);
3523
+ console.log(chalk14.dim("No builds yet. Waiting..."));
3524
+ await sleep4(5e3);
2770
3525
  continue;
2771
3526
  }
2772
3527
  const latestBuild = builds[0];
@@ -2774,25 +3529,396 @@ Watching builds for '${appName}'...
2774
3529
  if (lastBuildId !== null) {
2775
3530
  const icon = getStatusIcon(latestBuild.status);
2776
3531
  const sha = latestBuild.commitSha.substring(0, 7);
2777
- console.log(`${icon} New build #${latestBuild.id} ${chalk13.dim(sha)} "${latestBuild.commitMsg || "No message"}" [${latestBuild.status}]`);
3532
+ console.log(`${icon} New build #${latestBuild.id} ${chalk14.dim(sha)} "${latestBuild.commitMsg || "No message"}" [${latestBuild.status}]`);
2778
3533
  }
2779
3534
  lastBuildId = latestBuild.id;
2780
3535
  }
2781
3536
  for (const build of builds) {
2782
3537
  if (build.status === "building") {
2783
3538
  const duration = formatDuration(build.startedAt, null);
2784
- console.log(chalk13.yellow(` \u25CF Build #${build.id} in progress (${duration})...`));
3539
+ console.log(chalk14.yellow(` \u25CF Build #${build.id} in progress (${duration})...`));
2785
3540
  }
2786
3541
  }
2787
- await sleep3(5e3);
3542
+ await sleep4(5e3);
2788
3543
  } catch (error) {
2789
- await sleep3(5e3);
3544
+ await sleep4(5e3);
2790
3545
  }
2791
3546
  }
2792
3547
  }
2793
3548
 
3549
+ // src/commands/vpn.ts
3550
+ import { Command as Command9 } from "commander";
3551
+ import chalk15 from "chalk";
3552
+ import ora9 from "ora";
3553
+ var DEFAULT_LOCATION2 = "eu-central-h1";
3554
+ function createVPNCommands() {
3555
+ const vpnCmd = new Command9("vpn");
3556
+ vpnCmd.description("Manage VPN connection for IPv6 access to your apps");
3557
+ vpnCmd.command("status").description("Show VPN connection status").action(async () => {
3558
+ try {
3559
+ if (!isWireGuardInstalled()) {
3560
+ console.log(chalk15.yellow("\nWireGuard is not installed.\n"));
3561
+ console.log("Install it with:");
3562
+ console.log(" Ubuntu/Debian: sudo apt install wireguard");
3563
+ console.log(" Fedora: sudo dnf install wireguard-tools");
3564
+ console.log(" Arch: sudo pacman -S wireguard-tools");
3565
+ console.log(" macOS: brew install wireguard-tools\n");
3566
+ return;
3567
+ }
3568
+ const status = getVPNStatus();
3569
+ console.log(chalk15.cyan("\nVPN Status\n"));
3570
+ if (status.connected) {
3571
+ console.log(` Status: ${chalk15.green("Connected")}`);
3572
+ console.log(` Interface: ${status.interface}`);
3573
+ if (status.endpoint) {
3574
+ console.log(` Endpoint: ${status.endpoint}`);
3575
+ }
3576
+ if (status.latestHandshake) {
3577
+ console.log(` Handshake: ${status.latestHandshake}`);
3578
+ }
3579
+ if (status.transferRx && status.transferTx) {
3580
+ console.log(` Transfer: ${status.transferRx} received, ${status.transferTx} sent`);
3581
+ }
3582
+ } else {
3583
+ console.log(` Status: ${chalk15.yellow("Disconnected")}`);
3584
+ }
3585
+ const publicKey = getPublicKey();
3586
+ if (publicKey) {
3587
+ console.log(` Public Key: ${chalk15.dim(publicKey.substring(0, 20) + "...")}`);
3588
+ }
3589
+ console.log();
3590
+ } catch (error) {
3591
+ console.error(chalk15.red(`
3592
+ Error: ${error.message}
3593
+ `));
3594
+ process.exit(1);
3595
+ }
3596
+ });
3597
+ vpnCmd.command("connect").description("Establish VPN connection to gateway for IPv6 access").option("-l, --location <location>", "Gateway location", DEFAULT_LOCATION2).action(async (options) => {
3598
+ try {
3599
+ if (!isWireGuardInstalled()) {
3600
+ console.error(chalk15.red("\nWireGuard is not installed.\n"));
3601
+ console.log("Install it with:");
3602
+ console.log(" Ubuntu/Debian: sudo apt install wireguard");
3603
+ console.log(" Fedora: sudo dnf install wireguard-tools");
3604
+ console.log(" Arch: sudo pacman -S wireguard-tools");
3605
+ console.log(" macOS: brew install wireguard-tools\n");
3606
+ process.exit(1);
3607
+ }
3608
+ if (isVPNUp()) {
3609
+ console.log(chalk15.green("\nVPN is already connected.\n"));
3610
+ const status2 = getVPNStatus();
3611
+ if (status2.endpoint) {
3612
+ console.log(` Endpoint: ${status2.endpoint}`);
3613
+ }
3614
+ console.log();
3615
+ return;
3616
+ }
3617
+ const platformToken = getPlatformToken();
3618
+ const platformClient = new PlatformClient(platformToken);
3619
+ console.log(chalk15.cyan("\nEstablishing VPN connection...\n"));
3620
+ const spinner = ora9("Preparing WireGuard keypair...").start();
3621
+ const { publicKey, isNew } = getOrCreateKeyPair();
3622
+ if (isNew) {
3623
+ spinner.succeed("Generated new WireGuard keypair");
3624
+ } else {
3625
+ spinner.succeed("Using existing WireGuard keypair");
3626
+ }
3627
+ spinner.start("Registering with gateway...");
3628
+ const vpnConfigResponse = await platformClient.registerVPNPeer(publicKey, options.location);
3629
+ spinner.succeed("Registered with gateway");
3630
+ const { privateKey } = getOrCreateKeyPair();
3631
+ const vpnConfig = {
3632
+ privateKey,
3633
+ publicKey,
3634
+ address: vpnConfigResponse.address,
3635
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
3636
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
3637
+ allowedIPs: vpnConfigResponse.allowedIPs
3638
+ };
3639
+ spinner.start("Writing WireGuard configuration...");
3640
+ writeWireGuardConfig(vpnConfig);
3641
+ spinner.succeed("WireGuard configuration written");
3642
+ spinner.start("Starting VPN tunnel (requires sudo)...");
3643
+ spinner.stop();
3644
+ console.log(chalk15.dim(" Starting VPN tunnel (requires sudo)..."));
3645
+ vpnUp();
3646
+ console.log(chalk15.green(" \u2713 VPN tunnel started"));
3647
+ spinner.start("Verifying connection...");
3648
+ await new Promise((r) => setTimeout(r, 1e3));
3649
+ const status = getVPNStatus();
3650
+ if (status.connected) {
3651
+ spinner.succeed("VPN connected successfully");
3652
+ } else {
3653
+ spinner.warn("VPN interface is up but connection not verified");
3654
+ }
3655
+ console.log(chalk15.green("\n\u2713 VPN connected!\n"));
3656
+ console.log(` Your IPv6: ${chalk15.bold(vpnConfigResponse.address.split("/")[0])}`);
3657
+ console.log(` Gateway: ${chalk15.bold(vpnConfigResponse.gatewayEndpoint)}`);
3658
+ console.log();
3659
+ console.log(chalk15.dim("You now have IPv6 connectivity to your app VMs."));
3660
+ console.log(chalk15.dim(`Run ${chalk15.bold("hackerrun vpn disconnect")} to disconnect.
3661
+ `));
3662
+ } catch (error) {
3663
+ console.error(chalk15.red(`
3664
+ Error: ${error.message}
3665
+ `));
3666
+ process.exit(1);
3667
+ }
3668
+ });
3669
+ vpnCmd.command("disconnect").description("Disconnect VPN").action(async () => {
3670
+ try {
3671
+ if (!isVPNUp()) {
3672
+ console.log(chalk15.yellow("\nVPN is not connected.\n"));
3673
+ return;
3674
+ }
3675
+ console.log(chalk15.cyan("\nDisconnecting VPN...\n"));
3676
+ console.log(chalk15.dim(" Stopping VPN tunnel (requires sudo)..."));
3677
+ vpnDown();
3678
+ console.log(chalk15.green(" \u2713 VPN tunnel stopped"));
3679
+ console.log(chalk15.green("\n\u2713 VPN disconnected.\n"));
3680
+ } catch (error) {
3681
+ console.error(chalk15.red(`
3682
+ Error: ${error.message}
3683
+ `));
3684
+ process.exit(1);
3685
+ }
3686
+ });
3687
+ vpnCmd.command("test").description("Test IPv6 connectivity to an app VM").argument("<ipv6>", "IPv6 address to test").action(async (ipv6) => {
3688
+ try {
3689
+ console.log(chalk15.cyan(`
3690
+ Testing connectivity to ${ipv6}...
3691
+ `));
3692
+ const spinner = ora9("Testing direct IPv6...").start();
3693
+ const directOk = testIPv6Connectivity2(ipv6, 3);
3694
+ if (directOk) {
3695
+ spinner.succeed("Direct IPv6 connectivity works");
3696
+ console.log(chalk15.green("\n\u2713 You can reach this address directly.\n"));
3697
+ } else {
3698
+ spinner.fail("Direct IPv6 not available");
3699
+ if (isVPNUp()) {
3700
+ spinner.start("VPN is up, testing through VPN...");
3701
+ const vpnOk = testIPv6Connectivity2(ipv6, 5);
3702
+ if (vpnOk) {
3703
+ spinner.succeed("Connectivity works through VPN");
3704
+ console.log(chalk15.green("\n\u2713 You can reach this address via VPN.\n"));
3705
+ } else {
3706
+ spinner.fail("Cannot reach address even through VPN");
3707
+ console.log(chalk15.red("\n\u2717 Cannot reach this address.\n"));
3708
+ console.log("Possible issues:");
3709
+ console.log(" - The target VM may be down");
3710
+ console.log(" - VPN routing may be misconfigured");
3711
+ console.log(" - Firewall may be blocking traffic\n");
3712
+ }
3713
+ } else {
3714
+ console.log(chalk15.yellow("\n\u2717 Cannot reach this address directly.\n"));
3715
+ console.log(`Run ${chalk15.bold("hackerrun vpn connect")} to establish VPN and try again.
3716
+ `);
3717
+ }
3718
+ }
3719
+ } catch (error) {
3720
+ console.error(chalk15.red(`
3721
+ Error: ${error.message}
3722
+ `));
3723
+ process.exit(1);
3724
+ }
3725
+ });
3726
+ return vpnCmd;
3727
+ }
3728
+
3729
+ // src/commands/scale.ts
3730
+ import { Command as Command10 } from "commander";
3731
+ import chalk16 from "chalk";
3732
+ import ora10 from "ora";
3733
+ import { confirm } from "@inquirer/prompts";
3734
+ var DEFAULT_VM_SIZE = "standard-2";
3735
+ var DEFAULT_BOOT_IMAGE = "ubuntu-noble";
3736
+ function createScaleCommand() {
3737
+ const cmd = new Command10("scale");
3738
+ cmd.description("Scale an app by adding or removing nodes").argument("[count]", "Target node count, or +N/-N to add/remove nodes").option("--app <app>", "App name (uses hackerrun.yaml if not specified)").option("--size <size>", `VM size for new nodes (default: ${DEFAULT_VM_SIZE})`).option("--force", "Skip confirmation when removing nodes").action(async (countArg, options) => {
3739
+ let uncloudRunner = null;
3740
+ try {
3741
+ const appName = options.app || getAppName();
3742
+ const platformToken = getPlatformToken();
3743
+ const platformClient = new PlatformClient(platformToken);
3744
+ const clusterManager = new ClusterManager(platformClient);
3745
+ uncloudRunner = new UncloudRunner(platformClient);
3746
+ const app = await platformClient.getApp(appName);
3747
+ if (!app) {
3748
+ console.log(chalk16.red(`
3749
+ App '${appName}' not found
3750
+ `));
3751
+ console.log(`Run ${chalk16.bold("hackerrun deploy")} to create an app first.
3752
+ `);
3753
+ process.exit(1);
3754
+ }
3755
+ const currentNodeCount = app.nodes.length;
3756
+ if (!countArg) {
3757
+ console.log(chalk16.cyan(`
3758
+ App '${appName}' Scale Info:
3759
+ `));
3760
+ console.log(` Current nodes: ${chalk16.bold(currentNodeCount.toString())}`);
3761
+ console.log();
3762
+ app.nodes.forEach((node, index) => {
3763
+ const primaryLabel = node.isPrimary ? chalk16.yellow(" (primary)") : "";
3764
+ console.log(` ${index + 1}. ${chalk16.bold(node.name)}${primaryLabel}`);
3765
+ console.log(` IPv6: ${node.ipv6 || "pending"}`);
3766
+ console.log();
3767
+ });
3768
+ console.log(chalk16.dim("Usage:"));
3769
+ console.log(chalk16.dim(` hackerrun scale 3 # Scale to 3 nodes`));
3770
+ console.log(chalk16.dim(` hackerrun scale +1 # Add 1 node`));
3771
+ console.log(chalk16.dim(` hackerrun scale -1 # Remove 1 node`));
3772
+ console.log();
3773
+ return;
3774
+ }
3775
+ let targetCount;
3776
+ if (countArg.startsWith("+")) {
3777
+ const delta = parseInt(countArg.slice(1), 10);
3778
+ if (isNaN(delta) || delta <= 0) {
3779
+ console.log(chalk16.red(`
3780
+ Invalid count: ${countArg}
3781
+ `));
3782
+ process.exit(1);
3783
+ }
3784
+ targetCount = currentNodeCount + delta;
3785
+ } else if (countArg.startsWith("-")) {
3786
+ const delta = parseInt(countArg.slice(1), 10);
3787
+ if (isNaN(delta) || delta <= 0) {
3788
+ console.log(chalk16.red(`
3789
+ Invalid count: ${countArg}
3790
+ `));
3791
+ process.exit(1);
3792
+ }
3793
+ targetCount = currentNodeCount - delta;
3794
+ } else {
3795
+ targetCount = parseInt(countArg, 10);
3796
+ if (isNaN(targetCount)) {
3797
+ console.log(chalk16.red(`
3798
+ Invalid count: ${countArg}
3799
+ `));
3800
+ process.exit(1);
3801
+ }
3802
+ }
3803
+ if (targetCount < 1) {
3804
+ console.log(chalk16.red(`
3805
+ Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
3806
+ `));
3807
+ process.exit(1);
3808
+ }
3809
+ if (targetCount === currentNodeCount) {
3810
+ console.log(chalk16.yellow(`
3811
+ App already has ${currentNodeCount} node(s). Nothing to do.
3812
+ `));
3813
+ return;
3814
+ }
3815
+ const vmSize = options.size || DEFAULT_VM_SIZE;
3816
+ if (targetCount > currentNodeCount) {
3817
+ const nodesToAdd = targetCount - currentNodeCount;
3818
+ console.log(chalk16.cyan(`
3819
+ Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (+${nodesToAdd})
3820
+ `));
3821
+ for (let i = 0; i < nodesToAdd; i++) {
3822
+ console.log(chalk16.cyan(`
3823
+ Adding node ${i + 1} of ${nodesToAdd}...`));
3824
+ const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
3825
+ console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
3826
+ }
3827
+ const updatedApp = await platformClient.getApp(appName);
3828
+ const syncSpinner = ora10("Syncing gateway routes...").start();
3829
+ try {
3830
+ await platformClient.syncGatewayRoutes(app.location);
3831
+ syncSpinner.succeed("Gateway routes synced");
3832
+ } catch (error) {
3833
+ syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3834
+ }
3835
+ console.log(chalk16.green(`
3836
+ \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3837
+ `));
3838
+ console.log(chalk16.dim("To deploy to specific nodes, use x-machines in your compose file:"));
3839
+ console.log(chalk16.dim(" services:"));
3840
+ console.log(chalk16.dim(" web:"));
3841
+ console.log(chalk16.dim(" x-machines:"));
3842
+ updatedApp?.nodes.forEach((node) => {
3843
+ console.log(chalk16.dim(` - ${node.name}`));
3844
+ });
3845
+ console.log();
3846
+ } else {
3847
+ const nodesToRemove = currentNodeCount - targetCount;
3848
+ const sortedNodes = [...app.nodes].sort((a, b) => {
3849
+ const numA = parseInt(a.name.split("-").pop() || "0", 10);
3850
+ const numB = parseInt(b.name.split("-").pop() || "0", 10);
3851
+ return numB - numA;
3852
+ });
3853
+ const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
3854
+ console.log(chalk16.yellow(`
3855
+ Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (-${nodesToRemove})
3856
+ `));
3857
+ console.log(chalk16.yellow("The following nodes will be removed:"));
3858
+ nodesToRemoveList.forEach((node) => {
3859
+ console.log(` - ${node.name} (${node.ipv6})`);
3860
+ });
3861
+ console.log();
3862
+ if (!options.force) {
3863
+ console.log(chalk16.yellow("Warning: All containers on these nodes will be stopped and the VMs deleted."));
3864
+ console.log(chalk16.yellow("Data on these nodes will be lost.\n"));
3865
+ const confirmed = await confirm({
3866
+ message: "Are you sure you want to remove these nodes?",
3867
+ default: false
3868
+ });
3869
+ if (!confirmed) {
3870
+ console.log(chalk16.dim("\nScale cancelled.\n"));
3871
+ return;
3872
+ }
3873
+ }
3874
+ for (const node of nodesToRemoveList) {
3875
+ console.log(chalk16.cyan(`
3876
+ Removing node '${node.name}'...`));
3877
+ try {
3878
+ const spinner = ora10("Removing from uncloud cluster...").start();
3879
+ await uncloudRunner.removeNode(appName, node.name);
3880
+ spinner.succeed("Removed from cluster");
3881
+ spinner.start("Deleting VM...");
3882
+ await platformClient.deleteVM(app.location, node.name);
3883
+ spinner.succeed("VM deleted");
3884
+ spinner.start("Updating app state...");
3885
+ const updatedNodes = app.nodes.filter((n) => n.name !== node.name);
3886
+ app.nodes = updatedNodes;
3887
+ await platformClient.saveApp(app);
3888
+ spinner.succeed("App state updated");
3889
+ console.log(chalk16.green(` Removed: ${node.name}`));
3890
+ } catch (error) {
3891
+ console.log(chalk16.red(` Failed to remove ${node.name}: ${error.message}`));
3892
+ }
3893
+ }
3894
+ const updatedApp = await platformClient.getApp(appName);
3895
+ const syncSpinner = ora10("Syncing gateway routes...").start();
3896
+ try {
3897
+ await platformClient.syncGatewayRoutes(app.location);
3898
+ syncSpinner.succeed("Gateway routes synced");
3899
+ } catch (error) {
3900
+ syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3901
+ }
3902
+ console.log(chalk16.green(`
3903
+ \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3904
+ `));
3905
+ }
3906
+ } catch (error) {
3907
+ console.error(chalk16.red(`
3908
+ Error: ${error.message}
3909
+ `));
3910
+ process.exit(1);
3911
+ } finally {
3912
+ if (uncloudRunner) {
3913
+ uncloudRunner.cleanup();
3914
+ }
3915
+ }
3916
+ });
3917
+ return cmd;
3918
+ }
3919
+
2794
3920
  // src/index.ts
2795
- var program = new Command9();
3921
+ var program = new Command11();
2796
3922
  program.name("hackerrun").description("Deploy apps with full control over your infrastructure").version("0.1.0");
2797
3923
  program.addCommand(createLoginCommand());
2798
3924
  program.addCommand(createConfigCommand());
@@ -2802,6 +3928,8 @@ program.addCommand(createConnectCommand());
2802
3928
  program.addCommand(createDisconnectCommand());
2803
3929
  program.addCommand(createEnvCommand());
2804
3930
  program.addCommand(createBuildsCommand());
3931
+ program.addCommand(createVPNCommands());
3932
+ program.addCommand(createScaleCommand());
2805
3933
  var { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
2806
3934
  program.addCommand(appsCmd);
2807
3935
  program.addCommand(nodesCmd);