hackerrun 0.1.0 → 0.1.6

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,11 +793,19 @@ 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
 
803
+ # Install jq if not present (needed to merge Docker config)
804
+ if ! command -v jq &> /dev/null; then
805
+ apt-get update -qq
806
+ apt-get install -y -qq jq
807
+ fi
808
+
611
809
  # Step 1: Add IPv6 support to Docker daemon config
612
810
  DAEMON_JSON='/etc/docker/daemon.json'
613
811
  if [ -f "$DAEMON_JSON" ]; then
@@ -666,8 +864,17 @@ systemctl start docker-ipv6-nat64
666
864
 
667
865
  echo "Docker NAT64 configuration complete"
668
866
  `;
867
+ let tunnel = null;
868
+ let sshHost = vmIp;
869
+ let sshPortArgs = "";
669
870
  try {
670
- execSync2(`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${vmIp} 'bash -s' << 'REMOTESCRIPT'
871
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
872
+ if (!canConnectDirect && gatewayIp) {
873
+ tunnel = await createTunnel(vmIp, gatewayIp);
874
+ sshHost = "localhost";
875
+ sshPortArgs = `-p ${tunnel.localPort}`;
876
+ }
877
+ execSync2(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
671
878
  ${setupScript}
672
879
  REMOTESCRIPT`, {
673
880
  stdio: "inherit",
@@ -676,6 +883,10 @@ REMOTESCRIPT`, {
676
883
  });
677
884
  } catch (error) {
678
885
  throw new Error(`Failed to configure Docker for NAT64: ${error.message}`);
886
+ } finally {
887
+ if (tunnel) {
888
+ killTunnel(tunnel);
889
+ }
679
890
  }
680
891
  }
681
892
  /**
@@ -714,7 +925,52 @@ The VM may still be provisioning. You can:
714
925
  );
715
926
  }
716
927
  /**
717
- * Generate a random ID
928
+ * Wait for uncloud services to be ready on the VM
929
+ * Specifically checks that unregistry (Docker registry at 10.210.0.1:5000) is accepting connections
930
+ * This is critical for first deploy - push will fail if unregistry isn't ready
931
+ */
932
+ async waitForUncloudReady(vmIp, gatewayIp) {
933
+ const maxAttempts = 30;
934
+ const checkInterval = 2e3;
935
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
936
+ const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
937
+ const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
938
+ console.log(chalk2.dim(" Waiting for uncloud services..."));
939
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
940
+ try {
941
+ const result = execSync2(
942
+ `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
943
+ { encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
944
+ ).trim();
945
+ if (result.includes("ready")) {
946
+ console.log(chalk2.dim(" Uncloud services ready"));
947
+ return;
948
+ }
949
+ } catch (error) {
950
+ }
951
+ if (attempt % 5 === 0) {
952
+ console.log(chalk2.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
953
+ }
954
+ if (attempt < maxAttempts) {
955
+ await this.sleep(checkInterval);
956
+ }
957
+ }
958
+ console.log(chalk2.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
959
+ }
960
+ /**
961
+ * Generate a sequential node name: appName-1, appName-2, etc.
962
+ * Finds the next available number by checking existing node names
963
+ */
964
+ generateNodeName(appName, existingNodes) {
965
+ const existingNumbers = existingNodes.map((n) => {
966
+ const match = n.name.match(new RegExp(`^${appName}-(\\d+)$`));
967
+ return match ? parseInt(match[1], 10) : 0;
968
+ }).filter((n) => n > 0);
969
+ const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
970
+ return `${appName}-${maxNumber + 1}`;
971
+ }
972
+ /**
973
+ * Generate a random ID (used for legacy naming or fallback)
718
974
  */
719
975
  generateId() {
720
976
  return Math.random().toString(36).substring(2, 9);
@@ -833,11 +1089,18 @@ var UncloudManager = class {
833
1089
  }
834
1090
  /**
835
1091
  * Check if uncloud is installed, offer to install if not
1092
+ * @param options.nonInteractive - If true, fail immediately without prompting (for CI/CD)
836
1093
  */
837
- static async ensureInstalled() {
1094
+ static async ensureInstalled(options) {
838
1095
  if (this.isInstalled()) {
839
1096
  return;
840
1097
  }
1098
+ if (options?.nonInteractive) {
1099
+ console.error(chalk4.red("\n\u274C Uncloud CLI (uc) not found\n"));
1100
+ console.error("The uncloud CLI must be installed on the build VM.");
1101
+ console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
1102
+ process.exit(1);
1103
+ }
841
1104
  console.log(chalk4.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
842
1105
  console.log("Uncloud is required to deploy and manage your apps.");
843
1106
  console.log("Learn more: https://uncloud.run\n");
@@ -951,49 +1214,71 @@ function getPlatformToken() {
951
1214
 
952
1215
  // src/lib/platform-client.ts
953
1216
  var PLATFORM_API_URL2 = process.env.HACKERRUN_API_URL || "http://localhost:3000";
1217
+ var DEFAULT_RETRIES = 3;
1218
+ var RETRY_DELAY_MS = 2e3;
1219
+ function sleep2(ms) {
1220
+ return new Promise((resolve) => setTimeout(resolve, ms));
1221
+ }
1222
+ function isRetryableError(error, status) {
1223
+ 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")) {
1224
+ return true;
1225
+ }
1226
+ if (status && status >= 500) {
1227
+ return true;
1228
+ }
1229
+ return false;
1230
+ }
954
1231
  var PlatformClient = class {
955
1232
  constructor(authToken) {
956
1233
  this.authToken = authToken;
957
1234
  }
958
- async request(method, path, body) {
1235
+ async request(method, path, body, options = {}) {
1236
+ const {
1237
+ retries = DEFAULT_RETRIES,
1238
+ retryDelay = RETRY_DELAY_MS,
1239
+ operation = `${method} ${path}`
1240
+ } = options;
959
1241
  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;
1242
+ let lastError = null;
1243
+ for (let attempt = 1; attempt <= retries; attempt++) {
988
1244
  try {
989
- const errorJson = JSON.parse(errorText);
990
- errorMessage = errorJson.error || response.statusText;
991
- } catch {
992
- errorMessage = errorText || response.statusText;
1245
+ const response = await fetch(url, {
1246
+ method,
1247
+ headers: {
1248
+ Authorization: `Bearer ${this.authToken}`,
1249
+ "Content-Type": "application/json"
1250
+ },
1251
+ body: body ? JSON.stringify(body) : void 0
1252
+ });
1253
+ if (!response.ok) {
1254
+ const errorText = await response.text();
1255
+ let errorMessage;
1256
+ try {
1257
+ const errorJson = JSON.parse(errorText);
1258
+ errorMessage = errorJson.error || response.statusText;
1259
+ } catch {
1260
+ errorMessage = errorText || response.statusText;
1261
+ }
1262
+ const error = new Error(`API error (${response.status}): ${errorMessage}`);
1263
+ if (isRetryableError(error, response.status) && attempt < retries) {
1264
+ lastError = error;
1265
+ await sleep2(retryDelay * attempt);
1266
+ continue;
1267
+ }
1268
+ throw error;
1269
+ }
1270
+ return response.json();
1271
+ } catch (error) {
1272
+ lastError = error;
1273
+ if (isRetryableError(error) && attempt < retries) {
1274
+ await sleep2(retryDelay * attempt);
1275
+ continue;
1276
+ }
1277
+ const errorMessage = error.message || "Unknown error";
1278
+ throw new Error(`${operation} failed: ${errorMessage}`);
993
1279
  }
994
- throw new Error(`API error (${response.status}): ${errorMessage}`);
995
1280
  }
996
- return response.json();
1281
+ throw lastError || new Error(`${operation} failed after ${retries} attempts`);
997
1282
  }
998
1283
  // ==================== App State Management ====================
999
1284
  /**
@@ -1008,7 +1293,12 @@ var PlatformClient = class {
1008
1293
  */
1009
1294
  async getApp(appName) {
1010
1295
  try {
1011
- const { app } = await this.request("GET", `/api/apps/${appName}`);
1296
+ const { app } = await this.request(
1297
+ "GET",
1298
+ `/api/apps/${appName}`,
1299
+ void 0,
1300
+ { operation: `Get app '${appName}'` }
1301
+ );
1012
1302
  return app;
1013
1303
  } catch (error) {
1014
1304
  if (error.message.includes("404") || error.message.includes("not found")) {
@@ -1022,18 +1312,23 @@ var PlatformClient = class {
1022
1312
  * Returns the app with auto-generated domainName
1023
1313
  */
1024
1314
  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
- });
1315
+ const { app } = await this.request(
1316
+ "POST",
1317
+ "/api/apps",
1318
+ {
1319
+ appName: cluster.appName,
1320
+ location: cluster.location,
1321
+ uncloudContext: cluster.uncloudContext,
1322
+ nodes: cluster.nodes.map((node) => ({
1323
+ name: node.name,
1324
+ id: node.id,
1325
+ ipv4: node.ipv4,
1326
+ ipv6: node.ipv6,
1327
+ isPrimary: node.isPrimary
1328
+ }))
1329
+ },
1330
+ { operation: `Save app '${cluster.appName}'` }
1331
+ );
1037
1332
  return app;
1038
1333
  }
1039
1334
  /**
@@ -1042,7 +1337,7 @@ var PlatformClient = class {
1042
1337
  async updateLastDeployed(appName) {
1043
1338
  await this.request("PATCH", `/api/apps/${appName}`, {
1044
1339
  lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
1045
- });
1340
+ }, { operation: `Update last deployed for '${appName}'` });
1046
1341
  }
1047
1342
  /**
1048
1343
  * Rename an app (per-user unique)
@@ -1066,7 +1361,7 @@ var PlatformClient = class {
1066
1361
  * Delete an app
1067
1362
  */
1068
1363
  async deleteApp(appName) {
1069
- await this.request("DELETE", `/api/apps/${appName}`);
1364
+ await this.request("DELETE", `/api/apps/${appName}`, void 0, { operation: `Delete app '${appName}'` });
1070
1365
  }
1071
1366
  /**
1072
1367
  * Check if app exists
@@ -1094,7 +1389,7 @@ var PlatformClient = class {
1094
1389
  * Create a new VM
1095
1390
  */
1096
1391
  async createVM(params) {
1097
- const { vm } = await this.request("POST", "/api/vms", params);
1392
+ const { vm } = await this.request("POST", "/api/vms", params, { operation: `Create VM '${params.name}'` });
1098
1393
  return vm;
1099
1394
  }
1100
1395
  /**
@@ -1103,7 +1398,9 @@ var PlatformClient = class {
1103
1398
  async getVM(location, vmName) {
1104
1399
  const { vm } = await this.request(
1105
1400
  "GET",
1106
- `/api/vms/${location}/${vmName}`
1401
+ `/api/vms/${location}/${vmName}`,
1402
+ void 0,
1403
+ { operation: `Get VM '${vmName}'` }
1107
1404
  );
1108
1405
  return vm;
1109
1406
  }
@@ -1111,7 +1408,7 @@ var PlatformClient = class {
1111
1408
  * Delete a VM
1112
1409
  */
1113
1410
  async deleteVM(location, vmName) {
1114
- await this.request("DELETE", `/api/vms/${location}/${vmName}`);
1411
+ await this.request("DELETE", `/api/vms/${location}/${vmName}`, void 0, { operation: `Delete VM '${vmName}'` });
1115
1412
  }
1116
1413
  // ==================== Uncloud Config Management ====================
1117
1414
  /**
@@ -1140,7 +1437,7 @@ var PlatformClient = class {
1140
1437
  */
1141
1438
  async getGateway(location) {
1142
1439
  try {
1143
- const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`);
1440
+ const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
1144
1441
  return gateway;
1145
1442
  } catch (error) {
1146
1443
  if (error.message.includes("404") || error.message.includes("not found")) {
@@ -1158,7 +1455,7 @@ var PlatformClient = class {
1158
1455
  appName,
1159
1456
  backendIpv6,
1160
1457
  backendPort
1161
- });
1458
+ }, { operation: `Register route for '${appName}'` });
1162
1459
  return route;
1163
1460
  }
1164
1461
  /**
@@ -1173,7 +1470,7 @@ var PlatformClient = class {
1173
1470
  * This updates the gateway's reverse proxy config with current routes
1174
1471
  */
1175
1472
  async syncGatewayRoutes(location) {
1176
- const result = await this.request("POST", "/api/gateway/sync", { location });
1473
+ const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
1177
1474
  return { routeCount: result.routeCount };
1178
1475
  }
1179
1476
  // ==================== SSH Certificate Management ====================
@@ -1181,7 +1478,7 @@ var PlatformClient = class {
1181
1478
  * Get platform SSH keys (CA public key + platform public key for VM creation)
1182
1479
  */
1183
1480
  async getPlatformSSHKeys() {
1184
- return this.request("GET", "/api/platform/ssh-keys");
1481
+ return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1185
1482
  }
1186
1483
  /**
1187
1484
  * Initialize platform SSH keys (creates them if they don't exist)
@@ -1194,7 +1491,7 @@ var PlatformClient = class {
1194
1491
  * Returns a short-lived certificate (5 min) signed by the platform CA
1195
1492
  */
1196
1493
  async requestSSHCertificate(appName, publicKey) {
1197
- return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey });
1494
+ return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1198
1495
  }
1199
1496
  // ==================== VM Setup ====================
1200
1497
  /**
@@ -1206,7 +1503,7 @@ var PlatformClient = class {
1206
1503
  vmIp,
1207
1504
  location,
1208
1505
  appName
1209
- });
1506
+ }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
1210
1507
  }
1211
1508
  // ==================== Environment Variables ====================
1212
1509
  /**
@@ -1225,8 +1522,8 @@ var PlatformClient = class {
1225
1522
  * List environment variables (values are masked)
1226
1523
  */
1227
1524
  async listEnvVars(appName) {
1228
- const { vars } = await this.request("GET", `/api/apps/${appName}/env`);
1229
- return vars;
1525
+ const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
1526
+ return envVars;
1230
1527
  }
1231
1528
  /**
1232
1529
  * Remove an environment variable
@@ -1273,6 +1570,13 @@ var PlatformClient = class {
1273
1570
  throw error;
1274
1571
  }
1275
1572
  }
1573
+ /**
1574
+ * Delete current user's GitHub App installation record
1575
+ * Used when the installation becomes stale/invalid
1576
+ */
1577
+ async deleteGitHubInstallation() {
1578
+ await this.request("DELETE", "/api/github/installation");
1579
+ }
1276
1580
  /**
1277
1581
  * List repositories accessible via GitHub App installation
1278
1582
  */
@@ -1335,6 +1639,35 @@ var PlatformClient = class {
1335
1639
  const { events } = await this.request("GET", url);
1336
1640
  return events;
1337
1641
  }
1642
+ // ==================== VPN Management ====================
1643
+ /**
1644
+ * Register VPN peer with the platform
1645
+ * This is idempotent - if already registered, returns existing config
1646
+ */
1647
+ async registerVPNPeer(publicKey, location) {
1648
+ const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
1649
+ return vpnConfig;
1650
+ }
1651
+ /**
1652
+ * Get existing VPN config if registered
1653
+ */
1654
+ async getVPNConfig(location) {
1655
+ try {
1656
+ const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
1657
+ return vpnConfig;
1658
+ } catch (error) {
1659
+ if (error.message.includes("404") || error.message.includes("not found")) {
1660
+ return null;
1661
+ }
1662
+ throw error;
1663
+ }
1664
+ }
1665
+ /**
1666
+ * Unregister VPN peer
1667
+ */
1668
+ async unregisterVPNPeer() {
1669
+ await this.request("DELETE", "/api/vpn/unregister");
1670
+ }
1338
1671
  };
1339
1672
 
1340
1673
  // src/lib/app-config.ts
@@ -1383,9 +1716,330 @@ function linkApp(appName, directory = process.cwd()) {
1383
1716
  }
1384
1717
 
1385
1718
  // src/lib/uncloud-runner.ts
1386
- import { execSync as execSync4, spawnSync as spawnSync2, spawn } from "child_process";
1387
- import * as net2 from "net";
1719
+ import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
1720
+
1721
+ // src/lib/vpn.ts
1722
+ import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
1723
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
1724
+ import { homedir as homedir2, platform as platform3 } from "os";
1725
+ import { join as join4 } from "path";
1388
1726
  import chalk6 from "chalk";
1727
+ function detectOS() {
1728
+ const os = platform3();
1729
+ if (os === "darwin") {
1730
+ return { type: "macos" };
1731
+ }
1732
+ if (os === "win32") {
1733
+ return { type: "windows" };
1734
+ }
1735
+ if (os === "linux") {
1736
+ try {
1737
+ if (existsSync4("/etc/os-release")) {
1738
+ const osRelease = readFileSync4("/etc/os-release", "utf-8");
1739
+ const idMatch = osRelease.match(/^ID=(.*)$/m);
1740
+ if (idMatch) {
1741
+ const distro = idMatch[1].replace(/"/g, "").toLowerCase();
1742
+ return { type: "linux", distro };
1743
+ }
1744
+ }
1745
+ } catch {
1746
+ }
1747
+ return { type: "linux" };
1748
+ }
1749
+ return { type: "unknown" };
1750
+ }
1751
+ function getWireGuardInstallInstructions() {
1752
+ const os = detectOS();
1753
+ switch (os.type) {
1754
+ case "macos":
1755
+ return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
1756
+ case "windows":
1757
+ return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
1758
+ Or using winget: winget install WireGuard.WireGuard`;
1759
+ case "linux":
1760
+ switch (os.distro) {
1761
+ case "arch":
1762
+ case "manjaro":
1763
+ case "endeavouros":
1764
+ case "artix":
1765
+ return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
1766
+ case "ubuntu":
1767
+ case "debian":
1768
+ case "linuxmint":
1769
+ case "pop":
1770
+ case "elementary":
1771
+ case "zorin":
1772
+ return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
1773
+ case "fedora":
1774
+ return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
1775
+ case "rhel":
1776
+ case "centos":
1777
+ case "rocky":
1778
+ case "almalinux":
1779
+ return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
1780
+ (may need EPEL: sudo dnf install epel-release)`;
1781
+ case "opensuse":
1782
+ case "opensuse-leap":
1783
+ case "opensuse-tumbleweed":
1784
+ return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
1785
+ case "gentoo":
1786
+ return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
1787
+ case "void":
1788
+ return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
1789
+ case "alpine":
1790
+ return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
1791
+ case "nixos":
1792
+ return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
1793
+ default:
1794
+ return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
1795
+ Arch: sudo pacman -S wireguard-tools
1796
+ Ubuntu/Debian: sudo apt install wireguard
1797
+ Fedora: sudo dnf install wireguard-tools
1798
+ openSUSE: sudo zypper install wireguard-tools`;
1799
+ }
1800
+ default:
1801
+ return ` Visit https://www.wireguard.com/install/ for installation instructions`;
1802
+ }
1803
+ }
1804
+ var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
1805
+ var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
1806
+ var WG_INTERFACE = "hackerrun";
1807
+ var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
1808
+ function isWireGuardInstalled() {
1809
+ try {
1810
+ execSync4("which wg", { stdio: "ignore" });
1811
+ execSync4("which wg-quick", { stdio: "ignore" });
1812
+ return true;
1813
+ } catch {
1814
+ return false;
1815
+ }
1816
+ }
1817
+ function ensureConfigDir() {
1818
+ if (!existsSync4(CONFIG_DIR)) {
1819
+ mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
1820
+ }
1821
+ }
1822
+ function generateKeyPair() {
1823
+ const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
1824
+ const publicKey = execSync4("wg pubkey", {
1825
+ input: privateKey,
1826
+ encoding: "utf-8"
1827
+ }).trim();
1828
+ return { privateKey, publicKey };
1829
+ }
1830
+ function getOrCreateKeyPair() {
1831
+ ensureConfigDir();
1832
+ if (existsSync4(PRIVATE_KEY_FILE)) {
1833
+ const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1834
+ const publicKey2 = execSync4("wg pubkey", {
1835
+ input: privateKey2,
1836
+ encoding: "utf-8"
1837
+ }).trim();
1838
+ return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
1839
+ }
1840
+ const { privateKey, publicKey } = generateKeyPair();
1841
+ writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
1842
+ return { privateKey, publicKey, isNew: true };
1843
+ }
1844
+ function getPublicKey() {
1845
+ if (!existsSync4(PRIVATE_KEY_FILE)) {
1846
+ return null;
1847
+ }
1848
+ const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1849
+ return execSync4("wg pubkey", {
1850
+ input: privateKey,
1851
+ encoding: "utf-8"
1852
+ }).trim();
1853
+ }
1854
+ function isVPNUp() {
1855
+ try {
1856
+ const result = execSync4(`ip link show ${WG_INTERFACE}`, {
1857
+ encoding: "utf-8",
1858
+ stdio: ["pipe", "pipe", "pipe"]
1859
+ });
1860
+ return result.includes(WG_INTERFACE);
1861
+ } catch {
1862
+ return false;
1863
+ }
1864
+ }
1865
+ function isRoutedViaVPN(ipv6Address) {
1866
+ if (!isVPNUp()) {
1867
+ return false;
1868
+ }
1869
+ try {
1870
+ const result = execSync4(`ip -6 route get ${ipv6Address}`, {
1871
+ encoding: "utf-8",
1872
+ stdio: ["pipe", "pipe", "pipe"]
1873
+ });
1874
+ return result.includes(`dev ${WG_INTERFACE}`);
1875
+ } catch {
1876
+ return false;
1877
+ }
1878
+ }
1879
+ function getVPNStatus() {
1880
+ if (!isVPNUp()) {
1881
+ return { connected: false };
1882
+ }
1883
+ try {
1884
+ const output = execSync4(`sudo wg show ${WG_INTERFACE}`, {
1885
+ encoding: "utf-8",
1886
+ stdio: ["pipe", "pipe", "pipe"]
1887
+ });
1888
+ const lines = output.split("\n");
1889
+ let endpoint;
1890
+ let latestHandshake;
1891
+ let transferRx;
1892
+ let transferTx;
1893
+ for (const line of lines) {
1894
+ if (line.includes("endpoint:")) {
1895
+ endpoint = line.split("endpoint:")[1]?.trim();
1896
+ }
1897
+ if (line.includes("latest handshake:")) {
1898
+ latestHandshake = line.split("latest handshake:")[1]?.trim();
1899
+ }
1900
+ if (line.includes("transfer:")) {
1901
+ const transfer = line.split("transfer:")[1]?.trim();
1902
+ const parts = transfer?.split(",");
1903
+ transferRx = parts?.[0]?.trim();
1904
+ transferTx = parts?.[1]?.trim();
1905
+ }
1906
+ }
1907
+ return {
1908
+ connected: true,
1909
+ interface: WG_INTERFACE,
1910
+ endpoint,
1911
+ latestHandshake,
1912
+ transferRx,
1913
+ transferTx
1914
+ };
1915
+ } catch {
1916
+ return { connected: isVPNUp(), interface: WG_INTERFACE };
1917
+ }
1918
+ }
1919
+ function generateWireGuardConfig(config) {
1920
+ return `# HackerRun VPN - Auto-generated
1921
+ # Do not edit manually
1922
+
1923
+ [Interface]
1924
+ PrivateKey = ${config.privateKey}
1925
+ Address = ${config.address}
1926
+
1927
+ [Peer]
1928
+ PublicKey = ${config.gatewayPublicKey}
1929
+ Endpoint = ${config.gatewayEndpoint}
1930
+ AllowedIPs = ${config.allowedIPs}
1931
+ PersistentKeepalive = 25
1932
+ `;
1933
+ }
1934
+ function writeWireGuardConfig(config) {
1935
+ const configContent = generateWireGuardConfig(config);
1936
+ const tempFile = join4(CONFIG_DIR, "wg-temp.conf");
1937
+ writeFileSync4(tempFile, configContent, { mode: 384 });
1938
+ try {
1939
+ execSync4("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
1940
+ execSync4(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1941
+ execSync4(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1942
+ } catch (error) {
1943
+ if (existsSync4(tempFile)) {
1944
+ unlinkSync(tempFile);
1945
+ }
1946
+ throw error;
1947
+ }
1948
+ }
1949
+ function vpnUp() {
1950
+ if (isVPNUp()) {
1951
+ return;
1952
+ }
1953
+ const result = spawnSync2("sudo", ["wg-quick", "up", WG_INTERFACE], {
1954
+ stdio: "inherit"
1955
+ });
1956
+ if (result.status !== 0) {
1957
+ throw new Error(`Failed to bring up VPN interface (exit code ${result.status})`);
1958
+ }
1959
+ }
1960
+ function vpnDown() {
1961
+ if (!isVPNUp()) {
1962
+ return;
1963
+ }
1964
+ const result = spawnSync2("sudo", ["wg-quick", "down", WG_INTERFACE], {
1965
+ stdio: "inherit"
1966
+ });
1967
+ if (result.status !== 0) {
1968
+ throw new Error(`Failed to bring down VPN interface (exit code ${result.status})`);
1969
+ }
1970
+ }
1971
+ function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
1972
+ try {
1973
+ execSync4(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
1974
+ stdio: "ignore",
1975
+ timeout: (timeoutSeconds + 2) * 1e3
1976
+ });
1977
+ return true;
1978
+ } catch {
1979
+ return false;
1980
+ }
1981
+ }
1982
+ var VPNManager = class {
1983
+ wasConnectedByUs = false;
1984
+ /**
1985
+ * Ensure VPN is connected if IPv6 is not available
1986
+ * Returns true if VPN was established (and should be torn down later)
1987
+ */
1988
+ async ensureConnected(targetIPv6, getVPNConfig) {
1989
+ if (testIPv6Connectivity2(targetIPv6)) {
1990
+ console.log(chalk6.green("\u2713 Direct IPv6 connectivity available"));
1991
+ return false;
1992
+ }
1993
+ console.log(chalk6.yellow("IPv6 not available, checking VPN..."));
1994
+ if (isVPNUp()) {
1995
+ if (testIPv6Connectivity2(targetIPv6)) {
1996
+ console.log(chalk6.green("\u2713 VPN already connected"));
1997
+ return false;
1998
+ }
1999
+ console.log(chalk6.yellow("VPN is up but cannot reach target, reconnecting..."));
2000
+ vpnDown();
2001
+ }
2002
+ if (!isWireGuardInstalled()) {
2003
+ const instructions = getWireGuardInstallInstructions();
2004
+ throw new Error(
2005
+ "WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
2006
+ );
2007
+ }
2008
+ console.log(chalk6.cyan("Establishing VPN tunnel (requires sudo)..."));
2009
+ const { publicKey, isNew } = getOrCreateKeyPair();
2010
+ if (isNew) {
2011
+ console.log(chalk6.dim("Generated new WireGuard keypair"));
2012
+ }
2013
+ const vpnConfig = await getVPNConfig();
2014
+ writeWireGuardConfig(vpnConfig);
2015
+ vpnUp();
2016
+ if (!testIPv6Connectivity2(targetIPv6)) {
2017
+ vpnDown();
2018
+ throw new Error("VPN established but cannot reach target. Please try again.");
2019
+ }
2020
+ console.log(chalk6.green("\u2713 VPN connected"));
2021
+ this.wasConnectedByUs = true;
2022
+ return true;
2023
+ }
2024
+ /**
2025
+ * Disconnect VPN if we established it
2026
+ */
2027
+ disconnect() {
2028
+ if (this.wasConnectedByUs && isVPNUp()) {
2029
+ console.log(chalk6.dim("Disconnecting VPN..."));
2030
+ try {
2031
+ vpnDown();
2032
+ console.log(chalk6.green("\u2713 VPN disconnected"));
2033
+ } catch (error) {
2034
+ console.log(chalk6.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
2035
+ }
2036
+ this.wasConnectedByUs = false;
2037
+ }
2038
+ }
2039
+ };
2040
+
2041
+ // src/lib/uncloud-runner.ts
2042
+ import chalk7 from "chalk";
1389
2043
  var UncloudRunner = class {
1390
2044
  constructor(platformClient) {
1391
2045
  this.platformClient = platformClient;
@@ -1393,11 +2047,13 @@ var UncloudRunner = class {
1393
2047
  }
1394
2048
  certManager;
1395
2049
  gatewayCache = /* @__PURE__ */ new Map();
1396
- activeTunnels = /* @__PURE__ */ new Map();
2050
+ tunnelManager = new TunnelManager();
2051
+ vpnManager = new VPNManager();
1397
2052
  tempConfigPath = null;
2053
+ vpnEstablishedByUs = false;
1398
2054
  /**
1399
2055
  * Get the connection URL for an app's primary VM
1400
- * Handles IPv6 direct connection or gateway proxy fallback
2056
+ * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
1401
2057
  */
1402
2058
  async getConnectionInfo(appName) {
1403
2059
  const app = await this.platformClient.getApp(appName);
@@ -1411,30 +2067,84 @@ var UncloudRunner = class {
1411
2067
  const vmIp = primaryNode.ipv6;
1412
2068
  await this.certManager.getSession(appName, vmIp);
1413
2069
  const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
1414
- const viaGateway = !canConnectDirect;
1415
- if (viaGateway) {
1416
- const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
2070
+ if (canConnectDirect) {
2071
+ const viaVPN = isRoutedViaVPN(vmIp);
1417
2072
  return {
1418
- url: `ssh://root@localhost:${tunnelInfo.localPort}`,
2073
+ url: `ssh://root@${vmIp}`,
1419
2074
  vmIp,
1420
- viaGateway: true,
1421
- localPort: tunnelInfo.localPort
2075
+ viaGateway: false,
2076
+ viaVPN
1422
2077
  };
1423
2078
  }
2079
+ const vpnAvailable = isWireGuardInstalled();
2080
+ if (vpnAvailable) {
2081
+ console.log(chalk7.yellow("Direct IPv6 not available, trying VPN..."));
2082
+ try {
2083
+ if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
2084
+ console.log(chalk7.green("\u2713 VPN already connected"));
2085
+ return {
2086
+ url: `ssh://root@${vmIp}`,
2087
+ vmIp,
2088
+ viaGateway: false,
2089
+ viaVPN: true
2090
+ };
2091
+ }
2092
+ this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
2093
+ vmIp,
2094
+ async () => this.getVPNConfig(app.location)
2095
+ );
2096
+ if (testIPv6Connectivity2(vmIp, 5)) {
2097
+ console.log(chalk7.green("\u2713 Connected via VPN"));
2098
+ return {
2099
+ url: `ssh://root@${vmIp}`,
2100
+ vmIp,
2101
+ viaGateway: false,
2102
+ viaVPN: true
2103
+ };
2104
+ }
2105
+ } catch (error) {
2106
+ console.log(chalk7.yellow(`VPN setup failed: ${error.message}`));
2107
+ console.log(chalk7.dim("Falling back to SSH tunnel..."));
2108
+ }
2109
+ } else {
2110
+ console.log(chalk7.yellow("Direct IPv6 not available on your network."));
2111
+ console.log(chalk7.dim("For faster deploys, install WireGuard:"));
2112
+ console.log(chalk7.dim(getWireGuardInstallInstructions()));
2113
+ console.log();
2114
+ }
2115
+ console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
2116
+ const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
1424
2117
  return {
1425
- url: `ssh://root@${vmIp}`,
2118
+ url: `ssh://root@localhost:${tunnelInfo.localPort}`,
1426
2119
  vmIp,
1427
- viaGateway: false
2120
+ viaGateway: true,
2121
+ viaVPN: false,
2122
+ localPort: tunnelInfo.localPort
1428
2123
  };
1429
2124
  }
1430
2125
  /**
1431
- * Pre-accept a host's SSH key by running ssh-keyscan
1432
- * This prevents "Host key verification failed" errors from uncloud
2126
+ * Get VPN configuration from platform
1433
2127
  */
1434
- preAcceptHostKey(host, port) {
2128
+ async getVPNConfig(location) {
2129
+ const { privateKey, publicKey } = getOrCreateKeyPair();
2130
+ const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
2131
+ return {
2132
+ privateKey,
2133
+ publicKey,
2134
+ address: vpnConfigResponse.address,
2135
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
2136
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
2137
+ allowedIPs: vpnConfigResponse.allowedIPs
2138
+ };
2139
+ }
2140
+ /**
2141
+ * Pre-accept a host's SSH key by running ssh-keyscan
2142
+ * This prevents "Host key verification failed" errors from uncloud
2143
+ */
2144
+ preAcceptHostKey(host, port) {
1435
2145
  try {
1436
2146
  const portArg = port ? `-p ${port}` : "";
1437
- execSync4(
2147
+ execSync5(
1438
2148
  `ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
1439
2149
  { stdio: "pipe", timeout: 1e4 }
1440
2150
  );
@@ -1445,81 +2155,11 @@ var UncloudRunner = class {
1445
2155
  * Ensure an SSH tunnel exists for gateway fallback
1446
2156
  */
1447
2157
  async ensureTunnel(appName, vmIp, location) {
1448
- const existing = this.activeTunnels.get(appName);
1449
- if (existing && !existing.process.killed) {
1450
- return existing;
1451
- }
1452
2158
  const gateway = await this.getGateway(location);
1453
2159
  if (!gateway) {
1454
2160
  throw new Error(`No gateway found for location ${location}`);
1455
2161
  }
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}`);
2162
+ return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
1523
2163
  }
1524
2164
  /**
1525
2165
  * Run an uncloud command on the app's VM
@@ -1527,11 +2167,11 @@ var UncloudRunner = class {
1527
2167
  async run(appName, command, args = [], options = {}) {
1528
2168
  const connInfo = await this.getConnectionInfo(appName);
1529
2169
  if (connInfo.viaGateway) {
1530
- console.log(chalk6.dim(`Connecting via gateway...`));
2170
+ console.log(chalk7.dim(`Connecting via gateway...`));
1531
2171
  }
1532
2172
  const fullArgs = ["--connect", connInfo.url, command, ...args];
1533
2173
  if (options.stdio === "inherit") {
1534
- const result = spawnSync2("uc", fullArgs, {
2174
+ const result = spawnSync3("uc", fullArgs, {
1535
2175
  cwd: options.cwd,
1536
2176
  stdio: "inherit",
1537
2177
  timeout: options.timeout
@@ -1540,7 +2180,7 @@ var UncloudRunner = class {
1540
2180
  throw new Error(`Uncloud command failed with exit code ${result.status}`);
1541
2181
  }
1542
2182
  } else {
1543
- const result = execSync4(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
2183
+ const result = execSync5(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
1544
2184
  cwd: options.cwd,
1545
2185
  encoding: "utf-8",
1546
2186
  timeout: options.timeout
@@ -1582,7 +2222,7 @@ var UncloudRunner = class {
1582
2222
  } else {
1583
2223
  sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
1584
2224
  }
1585
- const token = execSync4(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
2225
+ const token = execSync5(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
1586
2226
  return token;
1587
2227
  }
1588
2228
  /**
@@ -1596,7 +2236,29 @@ var UncloudRunner = class {
1596
2236
  } else {
1597
2237
  sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
1598
2238
  }
1599
- return execSync4(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
2239
+ return execSync5(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
2240
+ }
2241
+ /**
2242
+ * Remove a node from the uncloud cluster
2243
+ * Uses 'uc machine rm' to drain containers and remove from cluster
2244
+ */
2245
+ async removeNode(appName, nodeName) {
2246
+ const connInfo = await this.getConnectionInfo(appName);
2247
+ const result = spawnSync3("uc", ["--connect", connInfo.url, "machine", "rm", nodeName, "--yes"], {
2248
+ stdio: "inherit",
2249
+ timeout: 3e5
2250
+ // 5 minute timeout for container drainage
2251
+ });
2252
+ if (result.status !== 0) {
2253
+ throw new Error(`Failed to remove node '${nodeName}' from cluster (exit code ${result.status})`);
2254
+ }
2255
+ }
2256
+ /**
2257
+ * List machines in the uncloud cluster
2258
+ */
2259
+ async listMachines(appName) {
2260
+ const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
2261
+ return result;
1600
2262
  }
1601
2263
  /**
1602
2264
  * Get gateway info (cached)
@@ -1614,17 +2276,15 @@ var UncloudRunner = class {
1614
2276
  return null;
1615
2277
  }
1616
2278
  /**
1617
- * Clean up all SSH sessions and tunnels
2279
+ * Clean up all SSH sessions, tunnels, and VPN
1618
2280
  */
1619
2281
  cleanup() {
1620
- for (const [appName, tunnel] of this.activeTunnels) {
1621
- try {
1622
- tunnel.process.kill();
1623
- } catch {
1624
- }
1625
- }
1626
- this.activeTunnels.clear();
2282
+ this.tunnelManager.closeAll();
1627
2283
  this.certManager.cleanupAll();
2284
+ if (this.vpnEstablishedByUs) {
2285
+ this.vpnManager.disconnect();
2286
+ this.vpnEstablishedByUs = false;
2287
+ }
1628
2288
  }
1629
2289
  };
1630
2290
 
@@ -1636,11 +2296,12 @@ var DEFAULT_IMAGE = "ubuntu-noble";
1636
2296
  function createDeployCommand() {
1637
2297
  const cmd = new Command2("deploy");
1638
2298
  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) => {
2299
+ const deployStartTime = Date.now();
1639
2300
  try {
1640
2301
  PlatformDetector.ensureSupported();
1641
- await UncloudManager.ensureInstalled();
1642
- let platformToken;
1643
2302
  const isCIBuild = !!options.buildToken;
2303
+ await UncloudManager.ensureInstalled({ nonInteractive: isCIBuild });
2304
+ let platformToken;
1644
2305
  if (isCIBuild) {
1645
2306
  platformToken = options.buildToken;
1646
2307
  } else {
@@ -1654,14 +2315,19 @@ function createDeployCommand() {
1654
2315
  const vmSize = options.size || DEFAULT_SIZE;
1655
2316
  const storageSize = options.storage ? parseInt(options.storage) : DEFAULT_STORAGE_SIZE;
1656
2317
  const bootImage = options.image || DEFAULT_IMAGE;
1657
- console.log(chalk7.cyan(`
2318
+ console.log(chalk8.cyan(`
1658
2319
  Deploying '${appName}' to hackerrun...
1659
2320
  `));
1660
2321
  let cluster = await platformClient.getApp(appName);
1661
2322
  let isFirstDeploy = false;
1662
- if (!cluster) {
2323
+ const needsInfrastructure = !cluster || cluster.nodes.length === 0;
2324
+ if (needsInfrastructure) {
1663
2325
  isFirstDeploy = true;
1664
- console.log(chalk7.yellow("First deployment - creating infrastructure...\n"));
2326
+ if (cluster && cluster.nodes.length === 0) {
2327
+ console.log(chalk8.yellow("App exists but has no infrastructure - creating VMs...\n"));
2328
+ } else {
2329
+ console.log(chalk8.yellow("First deployment - creating infrastructure...\n"));
2330
+ }
1665
2331
  cluster = await clusterManager.initializeCluster({
1666
2332
  appName,
1667
2333
  location,
@@ -1669,67 +2335,105 @@ Deploying '${appName}' to hackerrun...
1669
2335
  storageSize,
1670
2336
  bootImage
1671
2337
  });
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`);
2338
+ console.log(chalk8.cyan("\nWhat just happened:"));
2339
+ console.log(` ${chalk8.green("\u2713")} Created 1 IPv6-only VM (${vmSize})`);
2340
+ console.log(` ${chalk8.green("\u2713")} Installed Docker and Uncloud daemon`);
2341
+ console.log(` ${chalk8.green("\u2713")} Initialized uncloud cluster`);
2342
+ console.log(` ${chalk8.green("\u2713")} Assigned domain: ${cluster.domainName}.hackerrun.app`);
1677
2343
  console.log();
1678
2344
  if (!hasAppConfig()) {
1679
2345
  linkApp(appName);
1680
- console.log(chalk7.dim(`Created hackerrun.yaml for app linking
2346
+ console.log(chalk8.dim(`Created hackerrun.yaml for app linking
1681
2347
  `));
1682
2348
  }
1683
2349
  } else {
1684
- console.log(chalk7.green(`Using existing infrastructure (${cluster.nodes.length} VM(s))
2350
+ console.log(chalk8.green(`Using existing infrastructure (${cluster.nodes.length} VM(s))
1685
2351
  `));
1686
2352
  }
1687
2353
  const primaryNode = await platformClient.getPrimaryNode(appName);
1688
2354
  if (!primaryNode || !primaryNode.ipv6) {
1689
2355
  throw new Error("Primary node not found or has no IPv6 address");
1690
2356
  }
1691
- console.log(chalk7.cyan("\nRunning deployment...\n"));
2357
+ console.log(chalk8.cyan("\nRunning deployment...\n"));
1692
2358
  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();
2359
+ const maxDeployAttempts = 3;
2360
+ let lastError = null;
2361
+ for (let attempt = 1; attempt <= maxDeployAttempts; attempt++) {
1697
2362
  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"));
2363
+ await uncloudRunner.deploy(appName, process.cwd());
2364
+ lastError = null;
2365
+ break;
1703
2366
  } catch (error) {
1704
- spinner.warn(chalk7.yellow(`Could not register route: ${error.message}`));
2367
+ lastError = error;
2368
+ const errorMsg = lastError.message.toLowerCase();
2369
+ 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");
2370
+ if (isRetryableError2 && attempt < maxDeployAttempts) {
2371
+ console.log(chalk8.yellow(`
2372
+ Deploy failed, retrying (attempt ${attempt + 1}/${maxDeployAttempts})...
2373
+ `));
2374
+ uncloudRunner.cleanup();
2375
+ await new Promise((r) => setTimeout(r, 3e3));
2376
+ } else {
2377
+ throw lastError;
2378
+ }
2379
+ }
2380
+ }
2381
+ console.log(chalk8.green("\nApp deployed successfully!"));
2382
+ if (cluster.domainName) {
2383
+ const spinner = ora4("Registering route...").start();
2384
+ let success = false;
2385
+ for (let attempt = 1; attempt <= 5; attempt++) {
2386
+ try {
2387
+ if (attempt > 1) {
2388
+ spinner.text = `Registering route (attempt ${attempt}/5)...`;
2389
+ await new Promise((r) => setTimeout(r, 2e3));
2390
+ }
2391
+ const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
2392
+ spinner.succeed(chalk8.green(`Route registered: ${route.fullUrl}`));
2393
+ spinner.start("Syncing gateway routes...");
2394
+ await platformClient.syncGatewayRoutes(cluster.location);
2395
+ spinner.succeed(chalk8.green("Gateway routes synced"));
2396
+ success = true;
2397
+ break;
2398
+ } catch (error) {
2399
+ if (attempt === 5) {
2400
+ spinner.warn(chalk8.yellow(`Could not register route: ${error.message}`));
2401
+ console.log(chalk8.dim(` Run 'hackerrun domain --app ${appName} ${cluster.domainName}' to retry`));
2402
+ }
2403
+ }
1705
2404
  }
1706
2405
  }
1707
2406
  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`)}`);
2407
+ const deployDurationMs = Date.now() - deployStartTime;
2408
+ const deployMinutes = Math.floor(deployDurationMs / 6e4);
2409
+ const deploySeconds = Math.floor(deployDurationMs % 6e4 / 1e3);
2410
+ const deployTimeStr = deployMinutes > 0 ? `${deployMinutes}m ${deploySeconds}s` : `${deploySeconds}s`;
2411
+ console.log(chalk8.cyan("\nDeployment Summary:\n"));
2412
+ console.log(` App Name: ${chalk8.bold(appName)}`);
2413
+ console.log(` Domain: ${chalk8.bold(`${cluster.domainName}.hackerrun.app`)}`);
2414
+ console.log(` URL: ${chalk8.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
1712
2415
  console.log(` Location: ${cluster.location}`);
1713
2416
  console.log(` Nodes: ${cluster.nodes.length}`);
1714
- console.log(chalk7.cyan("\nInfrastructure:\n"));
2417
+ console.log(` Deploy Time: ${chalk8.bold(deployTimeStr)}`);
2418
+ console.log(chalk8.cyan("\nInfrastructure:\n"));
1715
2419
  cluster.nodes.forEach((node, index) => {
1716
- const prefix = node.isPrimary ? chalk7.yellow("(primary)") : " ";
2420
+ const prefix = node.isPrimary ? chalk8.yellow("(primary)") : " ";
1717
2421
  const ip = node.ipv6 || node.ipv4 || "pending";
1718
2422
  console.log(` ${prefix} ${node.name} - ${ip}`);
1719
2423
  });
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>`)}`);
2424
+ console.log(chalk8.cyan("\nNext steps:\n"));
2425
+ console.log(` View logs: ${chalk8.bold(`hackerrun logs ${appName}`)}`);
2426
+ console.log(` SSH access: ${chalk8.bold(`hackerrun ssh ${appName}`)}`);
2427
+ console.log(` Change domain: ${chalk8.bold(`hackerrun domain --app ${appName} <new-name>`)}`);
1724
2428
  console.log();
1725
2429
  uncloudRunner.cleanup();
1726
2430
  } catch (error) {
1727
2431
  uncloudRunner.cleanup();
1728
- console.log(chalk7.red("\nDeployment failed"));
2432
+ console.log(chalk8.red("\nDeployment failed"));
1729
2433
  throw error;
1730
2434
  }
1731
2435
  } catch (error) {
1732
- console.error(chalk7.red(`
2436
+ console.error(chalk8.red(`
1733
2437
  Error: ${error.message}
1734
2438
  `));
1735
2439
  process.exit(1);
@@ -1740,20 +2444,20 @@ Error: ${error.message}
1740
2444
 
1741
2445
  // src/commands/app.ts
1742
2446
  import { Command as Command3 } from "commander";
1743
- import chalk8 from "chalk";
2447
+ import chalk9 from "chalk";
1744
2448
  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";
2449
+ import { execSync as execSync6 } from "child_process";
2450
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
2451
+ import { homedir as homedir3 } from "os";
2452
+ import { join as join5 } from "path";
1749
2453
  import YAML from "yaml";
1750
2454
  function removeUncloudContext(contextName) {
1751
- const configPath = join4(homedir2(), ".config", "uncloud", "config.yaml");
1752
- if (!existsSync4(configPath)) {
2455
+ const configPath = join5(homedir3(), ".config", "uncloud", "config.yaml");
2456
+ if (!existsSync5(configPath)) {
1753
2457
  return false;
1754
2458
  }
1755
2459
  try {
1756
- const content = readFileSync4(configPath, "utf-8");
2460
+ const content = readFileSync5(configPath, "utf-8");
1757
2461
  const config = YAML.parse(content);
1758
2462
  if (!config.contexts || !config.contexts[contextName]) {
1759
2463
  return false;
@@ -1763,7 +2467,7 @@ function removeUncloudContext(contextName) {
1763
2467
  const remainingContexts = Object.keys(config.contexts);
1764
2468
  config.current_context = remainingContexts.length > 0 ? remainingContexts[0] : "";
1765
2469
  }
1766
- writeFileSync4(configPath, YAML.stringify(config));
2470
+ writeFileSync5(configPath, YAML.stringify(config));
1767
2471
  return true;
1768
2472
  } catch {
1769
2473
  return false;
@@ -1777,14 +2481,14 @@ function createAppCommands() {
1777
2481
  const platformClient = new PlatformClient(platformToken);
1778
2482
  const apps = await platformClient.listApps();
1779
2483
  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!
2484
+ console.log(chalk9.yellow("\nNo apps deployed yet.\n"));
2485
+ console.log(`Run ${chalk9.bold("hackerrun deploy")} to deploy your first app!
1782
2486
  `);
1783
2487
  return;
1784
2488
  }
1785
- console.log(chalk8.cyan("\n Your Apps:\n"));
2489
+ console.log(chalk9.cyan("\n Your Apps:\n"));
1786
2490
  apps.forEach((app) => {
1787
- console.log(chalk8.bold(` ${app.appName}`));
2491
+ console.log(chalk9.bold(` ${app.appName}`));
1788
2492
  console.log(` Domain: ${app.domainName}.hackerrun.app`);
1789
2493
  console.log(` URL: https://${app.domainName}.hackerrun.app`);
1790
2494
  console.log(` Location: ${app.location}`);
@@ -1797,10 +2501,10 @@ function createAppCommands() {
1797
2501
  console.log(` Primary Node: ${primaryNode?.ipv6 || primaryNode?.ipv4 || "N/A"}`);
1798
2502
  console.log();
1799
2503
  });
1800
- console.log(chalk8.green(`Total: ${apps.length} app(s)
2504
+ console.log(chalk9.green(`Total: ${apps.length} app(s)
1801
2505
  `));
1802
2506
  } catch (error) {
1803
- console.error(chalk8.red(`
2507
+ console.error(chalk9.red(`
1804
2508
  Error: ${error.message}
1805
2509
  `));
1806
2510
  process.exit(1);
@@ -1813,17 +2517,17 @@ Error: ${error.message}
1813
2517
  const platformClient = new PlatformClient(platformToken);
1814
2518
  const app = await platformClient.getApp(appName);
1815
2519
  if (!app) {
1816
- console.log(chalk8.red(`
2520
+ console.log(chalk9.red(`
1817
2521
  App '${appName}' not found
1818
2522
  `));
1819
2523
  process.exit(1);
1820
2524
  }
1821
- console.log(chalk8.cyan(`
2525
+ console.log(chalk9.cyan(`
1822
2526
  Nodes in '${appName}':
1823
2527
  `));
1824
2528
  app.nodes.forEach((node, index) => {
1825
- const primaryLabel = node.isPrimary ? chalk8.yellow(" (primary)") : "";
1826
- console.log(` ${index + 1}. ${chalk8.bold(node.name)}${primaryLabel}`);
2529
+ const primaryLabel = node.isPrimary ? chalk9.yellow(" (primary)") : "";
2530
+ console.log(` ${index + 1}. ${chalk9.bold(node.name)}${primaryLabel}`);
1827
2531
  console.log(` IPv6: ${node.ipv6 || "pending"}`);
1828
2532
  if (node.ipv4) {
1829
2533
  console.log(` IPv4: ${node.ipv4}`);
@@ -1831,10 +2535,10 @@ Nodes in '${appName}':
1831
2535
  console.log(` ID: ${node.id}`);
1832
2536
  console.log();
1833
2537
  });
1834
- console.log(chalk8.green(`Total: ${app.nodes.length} node(s)
2538
+ console.log(chalk9.green(`Total: ${app.nodes.length} node(s)
1835
2539
  `));
1836
2540
  } catch (error) {
1837
- console.error(chalk8.red(`
2541
+ console.error(chalk9.red(`
1838
2542
  Error: ${error.message}
1839
2543
  `));
1840
2544
  process.exit(1);
@@ -1848,7 +2552,7 @@ Error: ${error.message}
1848
2552
  try {
1849
2553
  const app = await platformClient.getApp(appName);
1850
2554
  if (!app) {
1851
- console.log(chalk8.red(`
2555
+ console.log(chalk9.red(`
1852
2556
  App '${appName}' not found
1853
2557
  `));
1854
2558
  process.exit(1);
@@ -1862,7 +2566,7 @@ App '${appName}' not found
1862
2566
  targetNode = app.nodes.find((n) => n.name === options.node);
1863
2567
  }
1864
2568
  if (!targetNode) {
1865
- console.log(chalk8.red(`
2569
+ console.log(chalk9.red(`
1866
2570
  Node '${options.node}' not found
1867
2571
  `));
1868
2572
  process.exit(1);
@@ -1872,34 +2576,34 @@ Node '${options.node}' not found
1872
2576
  }
1873
2577
  const nodeIp = targetNode?.ipv6 || targetNode?.ipv4;
1874
2578
  if (!targetNode || !nodeIp) {
1875
- console.log(chalk8.red(`
2579
+ console.log(chalk9.red(`
1876
2580
  Target node not found or has no IP
1877
2581
  `));
1878
2582
  process.exit(1);
1879
2583
  }
1880
2584
  const connInfo = await uncloudRunner.getConnectionInfo(appName);
1881
2585
  if (connInfo.viaGateway) {
1882
- console.log(chalk8.cyan(`
2586
+ console.log(chalk9.cyan(`
1883
2587
  Connecting to ${targetNode.name} via gateway...
1884
2588
  `));
1885
2589
  } else {
1886
- console.log(chalk8.cyan(`
2590
+ console.log(chalk9.cyan(`
1887
2591
  Connecting to ${targetNode.name}...
1888
2592
  `));
1889
2593
  }
1890
2594
  if (connInfo.viaGateway && connInfo.localPort) {
1891
- execSync5(
2595
+ execSync6(
1892
2596
  `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`,
1893
2597
  { stdio: "inherit" }
1894
2598
  );
1895
2599
  } else {
1896
- execSync5(
2600
+ execSync6(
1897
2601
  `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${nodeIp}`,
1898
2602
  { stdio: "inherit" }
1899
2603
  );
1900
2604
  }
1901
2605
  } catch (error) {
1902
- console.error(chalk8.red(`
2606
+ console.error(chalk9.red(`
1903
2607
  Error: ${error.message}
1904
2608
  `));
1905
2609
  process.exit(1);
@@ -1914,18 +2618,18 @@ Error: ${error.message}
1914
2618
  const platformClient = new PlatformClient(platformToken);
1915
2619
  const app = await platformClient.getApp(appName);
1916
2620
  if (!app) {
1917
- console.log(chalk8.red(`
2621
+ console.log(chalk9.red(`
1918
2622
  \u274C App '${appName}' not found
1919
2623
  `));
1920
2624
  process.exit(1);
1921
2625
  }
1922
2626
  if (!options.force) {
1923
- console.log(chalk8.yellow(`
2627
+ console.log(chalk9.yellow(`
1924
2628
  \u26A0\uFE0F You are about to delete '${appName}' and all its infrastructure:`));
1925
2629
  console.log(` - ${app.nodes.length} VM(s) will be deleted`);
1926
2630
  console.log(` - All data will be lost
1927
2631
  `);
1928
- console.log(chalk8.red("Use --force flag to confirm deletion\n"));
2632
+ console.log(chalk9.red("Use --force flag to confirm deletion\n"));
1929
2633
  process.exit(1);
1930
2634
  }
1931
2635
  const spinner = ora5("Deleting infrastructure...").start();
@@ -1935,18 +2639,25 @@ Error: ${error.message}
1935
2639
  await platformClient.deleteVM(app.location, node.name);
1936
2640
  }
1937
2641
  spinner.text = "Removing app from database...";
2642
+ const appLocation = app.location;
1938
2643
  await platformClient.deleteApp(appName);
2644
+ spinner.text = "Syncing gateway routes...";
2645
+ try {
2646
+ await platformClient.syncGatewayRoutes(appLocation);
2647
+ } catch (error) {
2648
+ console.log(chalk9.dim(` Warning: Could not sync gateway: ${error.message}`));
2649
+ }
1939
2650
  spinner.text = "Cleaning up local configuration...";
1940
2651
  const contextName = app.uncloudContext || appName;
1941
2652
  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"));
2653
+ spinner.succeed(chalk9.green(`App '${appName}' destroyed successfully`));
2654
+ console.log(chalk9.cyan("\n\u{1F4A1} All infrastructure has been deleted.\n"));
1944
2655
  } catch (error) {
1945
- spinner.fail(chalk8.red("Failed to destroy app"));
2656
+ spinner.fail(chalk9.red("Failed to destroy app"));
1946
2657
  throw error;
1947
2658
  }
1948
2659
  } catch (error) {
1949
- console.error(chalk8.red(`
2660
+ console.error(chalk9.red(`
1950
2661
  \u274C Error: ${error.message}
1951
2662
  `));
1952
2663
  process.exit(1);
@@ -1959,35 +2670,35 @@ Error: ${error.message}
1959
2670
  const platformClient = new PlatformClient(platformToken);
1960
2671
  const app = await platformClient.getApp(appName);
1961
2672
  if (!app) {
1962
- console.log(chalk8.red(`
2673
+ console.log(chalk9.red(`
1963
2674
  App '${appName}' not found
1964
2675
  `));
1965
- console.log(`Run ${chalk8.bold("hackerrun apps")} to see your apps.
2676
+ console.log(`Run ${chalk9.bold("hackerrun apps")} to see your apps.
1966
2677
  `);
1967
2678
  process.exit(1);
1968
2679
  }
1969
2680
  const existingConfig = readAppConfig();
1970
2681
  if (existingConfig) {
1971
2682
  if (existingConfig.appName === appName) {
1972
- console.log(chalk8.yellow(`
2683
+ console.log(chalk9.yellow(`
1973
2684
  This directory is already linked to '${appName}'
1974
2685
  `));
1975
2686
  return;
1976
2687
  }
1977
- console.log(chalk8.yellow(`
2688
+ console.log(chalk9.yellow(`
1978
2689
  This directory is currently linked to '${existingConfig.appName}'`));
1979
2690
  console.log(`Updating link to '${appName}'...
1980
2691
  `);
1981
2692
  }
1982
2693
  linkApp(appName);
1983
- console.log(chalk8.green(`
2694
+ console.log(chalk9.green(`
1984
2695
  Linked to app '${appName}'
1985
2696
  `));
1986
2697
  console.log(` Domain: https://${app.domainName}.hackerrun.app`);
1987
- console.log(` Run ${chalk8.bold("hackerrun deploy")} to deploy changes.
2698
+ console.log(` Run ${chalk9.bold("hackerrun deploy")} to deploy changes.
1988
2699
  `);
1989
2700
  } catch (error) {
1990
- console.error(chalk8.red(`
2701
+ console.error(chalk9.red(`
1991
2702
  Error: ${error.message}
1992
2703
  `));
1993
2704
  process.exit(1);
@@ -2001,7 +2712,7 @@ Error: ${error.message}
2001
2712
  const currentName = options.app;
2002
2713
  const app = await platformClient.getApp(currentName);
2003
2714
  if (!app) {
2004
- console.log(chalk8.red(`
2715
+ console.log(chalk9.red(`
2005
2716
  App '${currentName}' not found
2006
2717
  `));
2007
2718
  process.exit(1);
@@ -2009,7 +2720,7 @@ App '${currentName}' not found
2009
2720
  const spinner = ora5(`Renaming app '${currentName}' to '${newName}'...`).start();
2010
2721
  try {
2011
2722
  const updatedApp = await platformClient.renameApp(currentName, newName);
2012
- spinner.succeed(chalk8.green(`App renamed successfully`));
2723
+ spinner.succeed(chalk9.green(`App renamed successfully`));
2013
2724
  console.log(`
2014
2725
  Old name: ${currentName}`);
2015
2726
  console.log(` New name: ${updatedApp.appName}`);
@@ -2018,15 +2729,15 @@ App '${currentName}' not found
2018
2729
  const existingConfig = readAppConfig();
2019
2730
  if (existingConfig?.appName === currentName) {
2020
2731
  linkApp(newName);
2021
- console.log(chalk8.dim(`Updated local hackerrun.yaml
2732
+ console.log(chalk9.dim(`Updated local hackerrun.yaml
2022
2733
  `));
2023
2734
  }
2024
2735
  } catch (error) {
2025
- spinner.fail(chalk8.red("Failed to rename app"));
2736
+ spinner.fail(chalk9.red("Failed to rename app"));
2026
2737
  throw error;
2027
2738
  }
2028
2739
  } catch (error) {
2029
- console.error(chalk8.red(`
2740
+ console.error(chalk9.red(`
2030
2741
  Error: ${error.message}
2031
2742
  `));
2032
2743
  process.exit(1);
@@ -2040,7 +2751,7 @@ Error: ${error.message}
2040
2751
  const appName = options.app;
2041
2752
  const app = await platformClient.getApp(appName);
2042
2753
  if (!app) {
2043
- console.log(chalk8.red(`
2754
+ console.log(chalk9.red(`
2044
2755
  App '${appName}' not found
2045
2756
  `));
2046
2757
  process.exit(1);
@@ -2048,22 +2759,32 @@ App '${appName}' not found
2048
2759
  const oldDomain = app.domainName;
2049
2760
  const spinner = ora5(`Changing domain from '${oldDomain}' to '${newDomain}'...`).start();
2050
2761
  try {
2051
- const updatedApp = await platformClient.renameDomain(appName, newDomain);
2052
- spinner.succeed(chalk8.green(`Domain changed successfully`));
2762
+ let updatedApp = app;
2763
+ if (oldDomain !== newDomain) {
2764
+ updatedApp = await platformClient.renameDomain(appName, newDomain);
2765
+ spinner.succeed(chalk9.green(`Domain changed successfully`));
2766
+ } else {
2767
+ spinner.succeed(chalk9.green(`Domain already set to '${newDomain}'`));
2768
+ }
2769
+ const primaryNode = app.nodes.find((n) => n.isPrimary);
2770
+ if (primaryNode?.ipv6) {
2771
+ spinner.start("Ensuring route exists...");
2772
+ await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
2773
+ spinner.succeed(chalk9.green("Route registered"));
2774
+ }
2053
2775
  spinner.start("Syncing gateway routes...");
2054
2776
  await platformClient.syncGatewayRoutes(app.location);
2055
- spinner.succeed(chalk8.green("Gateway routes synced"));
2777
+ spinner.succeed(chalk9.green("Gateway routes synced"));
2056
2778
  console.log(`
2057
2779
  App: ${updatedApp.appName}`);
2058
- console.log(` Old domain: https://${oldDomain}.hackerrun.app`);
2059
- console.log(` New domain: https://${updatedApp.domainName}.hackerrun.app
2780
+ console.log(` Domain: https://${updatedApp.domainName}.hackerrun.app
2060
2781
  `);
2061
2782
  } catch (error) {
2062
- spinner.fail(chalk8.red("Failed to change domain"));
2783
+ spinner.fail(chalk9.red("Failed to change domain"));
2063
2784
  throw error;
2064
2785
  }
2065
2786
  } catch (error) {
2066
- console.error(chalk8.red(`
2787
+ console.error(chalk9.red(`
2067
2788
  Error: ${error.message}
2068
2789
  `));
2069
2790
  process.exit(1);
@@ -2074,7 +2795,7 @@ Error: ${error.message}
2074
2795
 
2075
2796
  // src/commands/config.ts
2076
2797
  import { Command as Command4 } from "commander";
2077
- import chalk9 from "chalk";
2798
+ import chalk10 from "chalk";
2078
2799
  function createConfigCommand() {
2079
2800
  const cmd = new Command4("config");
2080
2801
  cmd.description("Manage hackerrun configuration");
@@ -2082,20 +2803,20 @@ function createConfigCommand() {
2082
2803
  try {
2083
2804
  const configManager = new ConfigManager();
2084
2805
  if (key !== "apiToken") {
2085
- console.log(chalk9.red(`
2806
+ console.log(chalk10.red(`
2086
2807
  \u274C Invalid key: ${key}
2087
2808
  `));
2088
- console.log(chalk9.cyan("Valid keys:"));
2809
+ console.log(chalk10.cyan("Valid keys:"));
2089
2810
  console.log(" - apiToken Platform API token\n");
2090
2811
  process.exit(1);
2091
2812
  }
2092
2813
  configManager.set(key, value);
2093
2814
  const displayValue = configManager.getAll(true).apiToken;
2094
- console.log(chalk9.green(`
2815
+ console.log(chalk10.green(`
2095
2816
  \u2713 Set ${key} = ${displayValue}
2096
2817
  `));
2097
2818
  } catch (error) {
2098
- console.error(chalk9.red(`
2819
+ console.error(chalk10.red(`
2099
2820
  \u274C Error: ${error.message}
2100
2821
  `));
2101
2822
  process.exit(1);
@@ -2105,24 +2826,24 @@ function createConfigCommand() {
2105
2826
  try {
2106
2827
  const configManager = new ConfigManager();
2107
2828
  if (key !== "apiToken") {
2108
- console.log(chalk9.red(`
2829
+ console.log(chalk10.red(`
2109
2830
  \u274C Invalid key: ${key}
2110
2831
  `));
2111
2832
  process.exit(1);
2112
2833
  }
2113
2834
  const value = configManager.get(key);
2114
2835
  if (!value) {
2115
- console.log(chalk9.yellow(`
2836
+ console.log(chalk10.yellow(`
2116
2837
  \u26A0\uFE0F ${key} is not set
2117
2838
  `));
2118
2839
  process.exit(1);
2119
2840
  }
2120
2841
  const displayValue = options.showSecrets ? value : configManager.getAll(true).apiToken;
2121
- console.log(chalk9.cyan(`
2842
+ console.log(chalk10.cyan(`
2122
2843
  ${key} = ${displayValue}
2123
2844
  `));
2124
2845
  } catch (error) {
2125
- console.error(chalk9.red(`
2846
+ console.error(chalk10.red(`
2126
2847
  \u274C Error: ${error.message}
2127
2848
  `));
2128
2849
  process.exit(1);
@@ -2133,18 +2854,18 @@ ${key} = ${displayValue}
2133
2854
  const configManager = new ConfigManager();
2134
2855
  const config = configManager.getAll(!options.showSecrets);
2135
2856
  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"));
2857
+ console.log(chalk10.yellow("\n\u26A0\uFE0F No configuration found\n"));
2858
+ console.log(chalk10.cyan("Get started by logging in:\n"));
2138
2859
  console.log(" hackerrun login\n");
2139
2860
  return;
2140
2861
  }
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(`
2862
+ console.log(chalk10.cyan("\n\u{1F4DD} Configuration:\n"));
2863
+ console.log(` apiToken: ${config.apiToken || chalk10.red("not set")}`);
2864
+ console.log(chalk10.dim(`
2144
2865
  Config file: ${configManager.getConfigPath()}
2145
2866
  `));
2146
2867
  } catch (error) {
2147
- console.error(chalk9.red(`
2868
+ console.error(chalk10.red(`
2148
2869
  \u274C Error: ${error.message}
2149
2870
  `));
2150
2871
  process.exit(1);
@@ -2154,17 +2875,17 @@ Config file: ${configManager.getConfigPath()}
2154
2875
  try {
2155
2876
  const configManager = new ConfigManager();
2156
2877
  if (key !== "apiToken") {
2157
- console.log(chalk9.red(`
2878
+ console.log(chalk10.red(`
2158
2879
  \u274C Invalid key: ${key}
2159
2880
  `));
2160
2881
  process.exit(1);
2161
2882
  }
2162
2883
  configManager.unset(key);
2163
- console.log(chalk9.green(`
2884
+ console.log(chalk10.green(`
2164
2885
  \u2713 Removed ${key}
2165
2886
  `));
2166
2887
  } catch (error) {
2167
- console.error(chalk9.red(`
2888
+ console.error(chalk10.red(`
2168
2889
  \u274C Error: ${error.message}
2169
2890
  `));
2170
2891
  process.exit(1);
@@ -2175,7 +2896,7 @@ Config file: ${configManager.getConfigPath()}
2175
2896
  const configManager = new ConfigManager();
2176
2897
  console.log(configManager.getConfigPath());
2177
2898
  } catch (error) {
2178
- console.error(chalk9.red(`
2899
+ console.error(chalk10.red(`
2179
2900
  \u274C Error: ${error.message}
2180
2901
  `));
2181
2902
  process.exit(1);
@@ -2186,7 +2907,7 @@ Config file: ${configManager.getConfigPath()}
2186
2907
 
2187
2908
  // src/commands/logs.ts
2188
2909
  import { Command as Command5 } from "commander";
2189
- import chalk10 from "chalk";
2910
+ import chalk11 from "chalk";
2190
2911
  function createLogsCommand() {
2191
2912
  const cmd = new Command5("logs");
2192
2913
  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 +2917,13 @@ function createLogsCommand() {
2196
2917
  try {
2197
2918
  const app = await platformClient.getApp(appName);
2198
2919
  if (!app) {
2199
- console.log(chalk10.red(`
2920
+ console.log(chalk11.red(`
2200
2921
  App '${appName}' not found
2201
2922
  `));
2202
- console.log(chalk10.cyan("Available apps:"));
2923
+ console.log(chalk11.cyan("Available apps:"));
2203
2924
  const apps = await platformClient.listApps();
2204
2925
  if (apps.length === 0) {
2205
- console.log(chalk10.yellow(" No apps deployed yet\n"));
2926
+ console.log(chalk11.yellow(" No apps deployed yet\n"));
2206
2927
  } else {
2207
2928
  apps.forEach((a) => console.log(` - ${a.appName}`));
2208
2929
  console.log();
@@ -2225,27 +2946,27 @@ function createLogsCommand() {
2225
2946
  }
2226
2947
  }
2227
2948
  if (services.length === 0) {
2228
- console.log(chalk10.yellow(`
2949
+ console.log(chalk11.yellow(`
2229
2950
  No services found in app '${appName}'
2230
2951
  `));
2231
2952
  return;
2232
2953
  }
2233
- console.log(chalk10.cyan(`
2954
+ console.log(chalk11.cyan(`
2234
2955
  Available services in '${appName}':
2235
2956
  `));
2236
- services.forEach((s) => console.log(` ${chalk10.bold(s)}`));
2237
- console.log(chalk10.dim(`
2957
+ services.forEach((s) => console.log(` ${chalk11.bold(s)}`));
2958
+ console.log(chalk11.dim(`
2238
2959
  Usage: hackerrun logs ${appName} <service>`));
2239
- console.log(chalk10.dim(`Example: hackerrun logs ${appName} ${services[0]}
2960
+ console.log(chalk11.dim(`Example: hackerrun logs ${appName} ${services[0]}
2240
2961
  `));
2241
2962
  return;
2242
2963
  } 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"));
2964
+ console.error(chalk11.red("\n Failed to list services"));
2965
+ console.error(chalk11.yellow("Make sure the app is deployed and running\n"));
2245
2966
  process.exit(1);
2246
2967
  }
2247
2968
  }
2248
- console.log(chalk10.cyan(`
2969
+ console.log(chalk11.cyan(`
2249
2970
  Viewing logs for '${serviceName}' in app '${appName}'...
2250
2971
  `));
2251
2972
  try {
@@ -2254,13 +2975,13 @@ Usage: hackerrun logs ${appName} <service>`));
2254
2975
  tail: options.tail ? parseInt(options.tail) : void 0
2255
2976
  });
2256
2977
  } catch (error) {
2257
- console.error(chalk10.red(`
2978
+ console.error(chalk11.red(`
2258
2979
  Failed to fetch logs for service '${serviceName}'`));
2259
- console.error(chalk10.yellow("The service may not exist or may not be running\n"));
2980
+ console.error(chalk11.yellow("The service may not exist or may not be running\n"));
2260
2981
  process.exit(1);
2261
2982
  }
2262
2983
  } catch (error) {
2263
- console.error(chalk10.red(`
2984
+ console.error(chalk11.red(`
2264
2985
  Error: ${error.message}
2265
2986
  `));
2266
2987
  process.exit(1);
@@ -2273,10 +2994,10 @@ Usage: hackerrun logs ${appName} <service>`));
2273
2994
 
2274
2995
  // src/commands/env.ts
2275
2996
  import { Command as Command6 } from "commander";
2276
- import chalk11 from "chalk";
2997
+ import chalk12 from "chalk";
2277
2998
  import ora6 from "ora";
2278
2999
  import { password } from "@inquirer/prompts";
2279
- import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
3000
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
2280
3001
  function parseEnvFile(content) {
2281
3002
  const vars = {};
2282
3003
  for (const line of content.split("\n")) {
@@ -2315,14 +3036,14 @@ function createEnvCommand() {
2315
3036
  });
2316
3037
  }
2317
3038
  if (!key) {
2318
- console.error(chalk11.red("\nError: Key cannot be empty\n"));
3039
+ console.error(chalk12.red("\nError: Key cannot be empty\n"));
2319
3040
  process.exit(1);
2320
3041
  }
2321
3042
  const spinner = ora6(`Setting ${key}...`).start();
2322
3043
  await platformClient.setEnvVar(appName, key, value);
2323
3044
  spinner.succeed(`Set ${key}`);
2324
3045
  } catch (error) {
2325
- console.error(chalk11.red(`
3046
+ console.error(chalk12.red(`
2326
3047
  Error: ${error.message}
2327
3048
  `));
2328
3049
  process.exit(1);
@@ -2333,24 +3054,24 @@ Error: ${error.message}
2333
3054
  const appName = options.app || getAppName();
2334
3055
  const platformToken = getPlatformToken();
2335
3056
  const platformClient = new PlatformClient(platformToken);
2336
- if (!existsSync5(filePath)) {
2337
- console.error(chalk11.red(`
3057
+ if (!existsSync6(filePath)) {
3058
+ console.error(chalk12.red(`
2338
3059
  Error: File not found: ${filePath}
2339
3060
  `));
2340
3061
  process.exit(1);
2341
3062
  }
2342
- const content = readFileSync5(filePath, "utf-8");
3063
+ const content = readFileSync6(filePath, "utf-8");
2343
3064
  const vars = parseEnvFile(content);
2344
3065
  const count = Object.keys(vars).length;
2345
3066
  if (count === 0) {
2346
- console.log(chalk11.yellow("\nNo environment variables found in file.\n"));
3067
+ console.log(chalk12.yellow("\nNo environment variables found in file.\n"));
2347
3068
  return;
2348
3069
  }
2349
3070
  const spinner = ora6(`Uploading ${count} variable${count > 1 ? "s" : ""}...`).start();
2350
3071
  await platformClient.setEnvVars(appName, vars);
2351
3072
  spinner.succeed(`Uploaded ${count} environment variable${count > 1 ? "s" : ""}`);
2352
3073
  } catch (error) {
2353
- console.error(chalk11.red(`
3074
+ console.error(chalk12.red(`
2354
3075
  Error: ${error.message}
2355
3076
  `));
2356
3077
  process.exit(1);
@@ -2365,25 +3086,25 @@ Error: ${error.message}
2365
3086
  const vars = await platformClient.listEnvVars(appName);
2366
3087
  spinner.stop();
2367
3088
  if (vars.length === 0) {
2368
- console.log(chalk11.yellow(`
3089
+ console.log(chalk12.yellow(`
2369
3090
  No environment variables set for '${appName}'.
2370
3091
  `));
2371
- console.log(chalk11.cyan("Set variables with:\n"));
3092
+ console.log(chalk12.cyan("Set variables with:\n"));
2372
3093
  console.log(` hackerrun env set KEY=value`);
2373
3094
  console.log(` hackerrun env upload .env
2374
3095
  `);
2375
3096
  return;
2376
3097
  }
2377
- console.log(chalk11.cyan(`
3098
+ console.log(chalk12.cyan(`
2378
3099
  Environment variables for '${appName}':
2379
3100
  `));
2380
3101
  for (const v of vars) {
2381
3102
  const masked = "*".repeat(Math.min(v.valueLength, 20));
2382
- console.log(` ${chalk11.bold(v.key)}=${chalk11.dim(masked)}`);
3103
+ console.log(` ${chalk12.bold(v.key)}=${chalk12.dim(masked)}`);
2383
3104
  }
2384
3105
  console.log();
2385
3106
  } catch (error) {
2386
- console.error(chalk11.red(`
3107
+ console.error(chalk12.red(`
2387
3108
  Error: ${error.message}
2388
3109
  `));
2389
3110
  process.exit(1);
@@ -2398,7 +3119,7 @@ Error: ${error.message}
2398
3119
  await platformClient.unsetEnvVar(appName, key);
2399
3120
  spinner.succeed(`Removed ${key}`);
2400
3121
  } catch (error) {
2401
- console.error(chalk11.red(`
3122
+ console.error(chalk12.red(`
2402
3123
  Error: ${error.message}
2403
3124
  `));
2404
3125
  process.exit(1);
@@ -2409,16 +3130,16 @@ Error: ${error.message}
2409
3130
 
2410
3131
  // src/commands/connect.ts
2411
3132
  import { Command as Command7 } from "commander";
2412
- import chalk12 from "chalk";
3133
+ import chalk13 from "chalk";
2413
3134
  import ora7 from "ora";
2414
3135
  import { select } from "@inquirer/prompts";
2415
- function sleep2(ms) {
3136
+ function sleep3(ms) {
2416
3137
  return new Promise((resolve) => setTimeout(resolve, ms));
2417
3138
  }
2418
3139
  async function pollForInstallation(platformClient, stateToken, spinner, maxAttempts = 60, intervalMs = 5e3) {
2419
3140
  let attempts = 0;
2420
3141
  while (attempts < maxAttempts) {
2421
- await sleep2(intervalMs);
3142
+ await sleep3(intervalMs);
2422
3143
  attempts++;
2423
3144
  const result = await platformClient.pollGitHubConnect(stateToken);
2424
3145
  if (result.status === "complete" && result.installationId) {
@@ -2436,7 +3157,7 @@ function createConnectCommand() {
2436
3157
  const appName = options.app || getAppName();
2437
3158
  const platformToken = getPlatformToken();
2438
3159
  const platformClient = new PlatformClient(platformToken);
2439
- console.log(chalk12.cyan(`
3160
+ console.log(chalk13.cyan(`
2440
3161
  Connecting GitHub repository to '${appName}'
2441
3162
  `));
2442
3163
  let app = await platformClient.getApp(appName);
@@ -2446,35 +3167,42 @@ Connecting GitHub repository to '${appName}'
2446
3167
  spinner.succeed(`Created app '${appName}'`);
2447
3168
  if (!hasAppConfig()) {
2448
3169
  linkApp(appName);
2449
- console.log(chalk12.dim(` Created hackerrun.yaml`));
3170
+ console.log(chalk13.dim(` Created hackerrun.yaml`));
2450
3171
  }
2451
3172
  }
2452
3173
  const existingRepo = await platformClient.getConnectedRepo(appName);
2453
3174
  if (existingRepo) {
2454
- console.log(chalk12.yellow(`
3175
+ console.log(chalk13.yellow(`
2455
3176
  App '${appName}' is already connected to:`));
2456
- console.log(` ${chalk12.bold(existingRepo.repoFullName)} (branch: ${existingRepo.branch})
3177
+ console.log(` ${chalk13.bold(existingRepo.repoFullName)} (branch: ${existingRepo.branch})
2457
3178
  `);
2458
3179
  const action = await select({
2459
3180
  message: "What would you like to do?",
2460
3181
  choices: [
2461
3182
  { name: "Change repository", value: "change" },
2462
- { name: "Keep current connection", value: "keep" }
3183
+ { name: "Keep current connection", value: "keep" },
3184
+ { name: "Reconnect (delete and re-setup)", value: "reconnect" }
2463
3185
  ]
2464
3186
  });
2465
3187
  if (action === "keep") {
2466
- console.log(chalk12.green("\nConnection unchanged.\n"));
3188
+ console.log(chalk13.green("\nConnection unchanged.\n"));
2467
3189
  return;
2468
3190
  }
3191
+ if (action === "reconnect") {
3192
+ const disconnectSpinner = ora7("Disconnecting existing repository...").start();
3193
+ await platformClient.disconnectRepo(appName);
3194
+ disconnectSpinner.succeed("Existing connection removed");
3195
+ console.log(chalk13.cyan("\nReconnecting...\n"));
3196
+ }
2469
3197
  }
2470
3198
  let installation = await platformClient.getGitHubInstallation();
2471
3199
  if (!installation) {
2472
- console.log(chalk12.cyan("First, install the HackerRun GitHub App:\n"));
3200
+ console.log(chalk13.cyan("First, install the HackerRun GitHub App:\n"));
2473
3201
  const spinner = ora7("Initiating GitHub connection...").start();
2474
3202
  const flow = await platformClient.initiateGitHubConnect();
2475
3203
  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)}`);
3204
+ console.log(chalk13.cyan("\nPlease complete the following steps:\n"));
3205
+ console.log(` 1. Visit: ${chalk13.bold.blue(flow.authUrl)}`);
2478
3206
  console.log(` 2. Install the HackerRun app on your account`);
2479
3207
  console.log(` 3. Select the repositories you want to access
2480
3208
  `);
@@ -2487,23 +3215,56 @@ App '${appName}' is already connected to:`));
2487
3215
  process.exit(1);
2488
3216
  }
2489
3217
  } else {
2490
- console.log(chalk12.green(`\u2713 GitHub App already installed (${installation.accountLogin})
3218
+ console.log(chalk13.green(`\u2713 GitHub App already installed (${installation.accountLogin})
2491
3219
  `));
2492
3220
  }
2493
3221
  const repoSpinner = ora7("Fetching accessible repositories...").start();
2494
- const repos = await platformClient.listAccessibleRepos();
3222
+ let repos;
3223
+ try {
3224
+ repos = await platformClient.listAccessibleRepos();
3225
+ } catch (error) {
3226
+ repoSpinner.stop();
3227
+ const isStaleInstallation = error.message.includes("create-an-installation-access-token") || error.message.includes("Bad credentials") || error.message.includes("installation") && error.message.includes("Not Found");
3228
+ if (isStaleInstallation) {
3229
+ console.log(chalk13.yellow("\nGitHub App installation is stale or was removed from GitHub."));
3230
+ console.log(chalk13.cyan("Clearing stale record and prompting for reinstallation...\n"));
3231
+ await platformClient.deleteGitHubInstallation();
3232
+ console.log(chalk13.cyan("Please reinstall the HackerRun GitHub App:\n"));
3233
+ const spinner = ora7("Initiating GitHub connection...").start();
3234
+ const flow = await platformClient.initiateGitHubConnect();
3235
+ spinner.succeed("GitHub connection initiated");
3236
+ console.log(chalk13.cyan("\nPlease complete the following steps:\n"));
3237
+ console.log(` 1. Visit: ${chalk13.bold.blue(flow.authUrl)}`);
3238
+ console.log(` 2. Install the HackerRun app on your account`);
3239
+ console.log(` 3. Select the repositories you want to access
3240
+ `);
3241
+ const pollSpinner = ora7("Waiting for GitHub authorization...").start();
3242
+ try {
3243
+ await pollForInstallation(platformClient, flow.stateToken, pollSpinner);
3244
+ pollSpinner.succeed("GitHub App installed");
3245
+ } catch (pollError) {
3246
+ pollSpinner.fail(pollError.message);
3247
+ process.exit(1);
3248
+ }
3249
+ const retrySpinner = ora7("Fetching accessible repositories...").start();
3250
+ repos = await platformClient.listAccessibleRepos();
3251
+ retrySpinner.stop();
3252
+ } else {
3253
+ throw error;
3254
+ }
3255
+ }
2495
3256
  repoSpinner.stop();
2496
3257
  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.
3258
+ console.log(chalk13.yellow("\nNo repositories found."));
3259
+ console.log(chalk13.cyan("Make sure you have given HackerRun access to your repositories.\n"));
3260
+ console.log(`Visit ${chalk13.blue("https://github.com/settings/installations")} to manage access.
2500
3261
  `);
2501
3262
  process.exit(1);
2502
3263
  }
2503
3264
  const selectedRepo = await select({
2504
3265
  message: "Select repository to connect:",
2505
3266
  choices: repos.map((r) => ({
2506
- name: `${r.fullName}${r.private ? chalk12.dim(" (private)") : ""} [${r.defaultBranch}]`,
3267
+ name: `${r.fullName}${r.private ? chalk13.dim(" (private)") : ""} [${r.defaultBranch}]`,
2507
3268
  value: r.fullName
2508
3269
  }))
2509
3270
  });
@@ -2512,17 +3273,17 @@ App '${appName}' is already connected to:`));
2512
3273
  const connectSpinner = ora7("Connecting repository...").start();
2513
3274
  await platformClient.connectRepo(appName, selectedRepo, branch);
2514
3275
  connectSpinner.succeed(`Connected ${selectedRepo}`);
2515
- console.log(chalk12.green(`
3276
+ console.log(chalk13.green(`
2516
3277
  \u2713 Successfully connected!
2517
3278
  `));
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)}
3279
+ console.log(chalk13.cyan("Auto-deploy is now enabled:"));
3280
+ console.log(` Repository: ${chalk13.bold(selectedRepo)}`);
3281
+ console.log(` Branch: ${chalk13.bold(branch)}`);
3282
+ console.log(` App: ${chalk13.bold(appName)}
2522
3283
  `);
2523
- console.log(chalk12.dim("Every push to this branch will trigger a build and deploy.\n"));
3284
+ console.log(chalk13.dim("Every push to this branch will trigger a build and deploy.\n"));
2524
3285
  } catch (error) {
2525
- console.error(chalk12.red(`
3286
+ console.error(chalk13.red(`
2526
3287
  Error: ${error.message}
2527
3288
  `));
2528
3289
  process.exit(1);
@@ -2539,7 +3300,7 @@ function createDisconnectCommand() {
2539
3300
  const platformClient = new PlatformClient(platformToken);
2540
3301
  const existingRepo = await platformClient.getConnectedRepo(appName);
2541
3302
  if (!existingRepo) {
2542
- console.log(chalk12.yellow(`
3303
+ console.log(chalk13.yellow(`
2543
3304
  No repository connected to '${appName}'.
2544
3305
  `));
2545
3306
  return;
@@ -2547,12 +3308,12 @@ No repository connected to '${appName}'.
2547
3308
  const spinner = ora7("Disconnecting repository...").start();
2548
3309
  await platformClient.disconnectRepo(appName);
2549
3310
  spinner.succeed("Repository disconnected");
2550
- console.log(chalk12.green(`
3311
+ console.log(chalk13.green(`
2551
3312
  \u2713 Disconnected ${existingRepo.repoFullName} from '${appName}'
2552
3313
  `));
2553
- console.log(chalk12.dim("Auto-deploy is now disabled. Use `hackerrun connect` to reconnect.\n"));
3314
+ console.log(chalk13.dim("Auto-deploy is now disabled. Use `hackerrun connect` to reconnect.\n"));
2554
3315
  } catch (error) {
2555
- console.error(chalk12.red(`
3316
+ console.error(chalk13.red(`
2556
3317
  Error: ${error.message}
2557
3318
  `));
2558
3319
  process.exit(1);
@@ -2563,9 +3324,9 @@ Error: ${error.message}
2563
3324
 
2564
3325
  // src/commands/builds.ts
2565
3326
  import { Command as Command8 } from "commander";
2566
- import chalk13 from "chalk";
3327
+ import chalk14 from "chalk";
2567
3328
  import ora8 from "ora";
2568
- function sleep3(ms) {
3329
+ function sleep4(ms) {
2569
3330
  return new Promise((resolve) => setTimeout(resolve, ms));
2570
3331
  }
2571
3332
  function formatDuration(startedAt, completedAt) {
@@ -2592,22 +3353,22 @@ function formatRelativeTime(dateStr) {
2592
3353
  function getStatusIcon(status) {
2593
3354
  switch (status) {
2594
3355
  case "success":
2595
- return chalk13.green("\u2713");
3356
+ return chalk14.green("\u2713");
2596
3357
  case "failed":
2597
- return chalk13.red("\u2717");
3358
+ return chalk14.red("\u2717");
2598
3359
  case "building":
2599
- return chalk13.yellow("\u25CF");
3360
+ return chalk14.yellow("\u25CF");
2600
3361
  case "pending":
2601
- return chalk13.dim("\u25CB");
3362
+ return chalk14.dim("\u25CB");
2602
3363
  default:
2603
- return chalk13.dim("?");
3364
+ return chalk14.dim("?");
2604
3365
  }
2605
3366
  }
2606
3367
  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");
3368
+ if (status === "completed") return chalk14.green("\u2713");
3369
+ if (status === "failed") return chalk14.red("\u2717");
3370
+ if (status === "started") return chalk14.yellow("\u25CF");
3371
+ return chalk14.dim("\u25CB");
2611
3372
  }
2612
3373
  function formatEventTime(startTime, eventTime) {
2613
3374
  const diffMs = eventTime.getTime() - startTime.getTime();
@@ -2633,7 +3394,7 @@ function createBuildsCommand() {
2633
3394
  await listBuilds(platformClient, appName, parseInt(options.limit, 10));
2634
3395
  }
2635
3396
  } catch (error) {
2636
- console.error(chalk13.red(`
3397
+ console.error(chalk14.red(`
2637
3398
  Error: ${error.message}
2638
3399
  `));
2639
3400
  process.exit(1);
@@ -2646,25 +3407,25 @@ async function listBuilds(platformClient, appName, limit) {
2646
3407
  const builds = await platformClient.listBuilds(appName, limit);
2647
3408
  spinner.stop();
2648
3409
  if (builds.length === 0) {
2649
- console.log(chalk13.yellow(`
3410
+ console.log(chalk14.yellow(`
2650
3411
  No builds found for '${appName}'.
2651
3412
  `));
2652
- console.log(chalk13.cyan("Connect a GitHub repo to enable auto-deploy:\n"));
3413
+ console.log(chalk14.cyan("Connect a GitHub repo to enable auto-deploy:\n"));
2653
3414
  console.log(` hackerrun connect
2654
3415
  `);
2655
3416
  return;
2656
3417
  }
2657
- console.log(chalk13.cyan(`
3418
+ console.log(chalk14.cyan(`
2658
3419
  Builds for '${appName}':
2659
3420
  `));
2660
3421
  for (const build of builds) {
2661
3422
  const icon = getStatusIcon(build.status);
2662
3423
  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");
3424
+ const msg = build.commitMsg ? build.commitMsg.length > 50 ? build.commitMsg.substring(0, 47) + "..." : build.commitMsg : chalk14.dim("No message");
2664
3425
  const duration = formatDuration(build.startedAt, build.completedAt);
2665
3426
  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}`)}`);
3427
+ console.log(` ${icon} ${chalk14.bold(`#${build.id}`)} ${chalk14.dim(sha)} ${msg}`);
3428
+ console.log(` ${chalk14.dim(`${build.branch} \u2022 ${duration} \u2022 ${time}`)}`);
2668
3429
  console.log();
2669
3430
  }
2670
3431
  }
@@ -2675,16 +3436,16 @@ async function showBuildDetails(platformClient, appName, buildId) {
2675
3436
  spinner.stop();
2676
3437
  const icon = getStatusIcon(build.status);
2677
3438
  const sha = build.commitSha.substring(0, 7);
2678
- console.log(chalk13.cyan(`
3439
+ console.log(chalk14.cyan(`
2679
3440
  Build #${build.id}
2680
3441
  `));
2681
3442
  console.log(` Status: ${icon} ${build.status}`);
2682
- console.log(` Commit: ${chalk13.dim(sha)} ${build.commitMsg || chalk13.dim("No message")}`);
3443
+ console.log(` Commit: ${chalk14.dim(sha)} ${build.commitMsg || chalk14.dim("No message")}`);
2683
3444
  console.log(` Branch: ${build.branch}`);
2684
3445
  console.log(` Duration: ${formatDuration(build.startedAt, build.completedAt)}`);
2685
3446
  console.log(` Created: ${formatRelativeTime(build.createdAt)}`);
2686
3447
  if (events.length > 0) {
2687
- console.log(chalk13.cyan("\nBuild Events:\n"));
3448
+ console.log(chalk14.cyan("\nBuild Events:\n"));
2688
3449
  const startTime = new Date(events[0].timestamp);
2689
3450
  for (const event of events) {
2690
3451
  const eventTime = new Date(event.timestamp);
@@ -2694,29 +3455,29 @@ Build #${build.id}
2694
3455
  }
2695
3456
  }
2696
3457
  if (build.logs) {
2697
- console.log(chalk13.cyan("\nBuild Logs:\n"));
2698
- console.log(chalk13.dim(build.logs));
3458
+ console.log(chalk14.cyan("\nBuild Logs:\n"));
3459
+ console.log(chalk14.dim(build.logs));
2699
3460
  }
2700
3461
  console.log();
2701
3462
  }
2702
3463
  async function streamLatestBuild(platformClient, appName) {
2703
- console.log(chalk13.cyan(`
3464
+ console.log(chalk14.cyan(`
2704
3465
  Watching latest build for '${appName}'...
2705
3466
  `));
2706
- console.log(chalk13.dim("Press Ctrl+C to stop\n"));
3467
+ console.log(chalk14.dim("Press Ctrl+C to stop\n"));
2707
3468
  let currentBuildId = null;
2708
3469
  let lastEventTimestamp = null;
2709
3470
  let buildStartTime = null;
2710
3471
  process.on("SIGINT", () => {
2711
- console.log(chalk13.dim("\n\nStopped watching.\n"));
3472
+ console.log(chalk14.dim("\n\nStopped watching.\n"));
2712
3473
  process.exit(0);
2713
3474
  });
2714
3475
  while (true) {
2715
3476
  try {
2716
3477
  const builds = await platformClient.listBuilds(appName, 1);
2717
3478
  if (builds.length === 0) {
2718
- console.log(chalk13.dim("Waiting for builds..."));
2719
- await sleep3(5e3);
3479
+ console.log(chalk14.dim("Waiting for builds..."));
3480
+ await sleep4(5e3);
2720
3481
  continue;
2721
3482
  }
2722
3483
  const latestBuild = builds[0];
@@ -2724,9 +3485,9 @@ Watching latest build for '${appName}'...
2724
3485
  currentBuildId = latestBuild.id;
2725
3486
  lastEventTimestamp = null;
2726
3487
  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)}`));
3488
+ console.log(chalk14.bold(`
3489
+ Build #${latestBuild.id}`) + ` - commit ${chalk14.dim(latestBuild.commitSha.substring(0, 7))} "${latestBuild.commitMsg || "No message"}"`);
3490
+ console.log(chalk14.dim(`Branch: ${latestBuild.branch} | Started: ${formatRelativeTime(latestBuild.createdAt)}`));
2730
3491
  console.log();
2731
3492
  }
2732
3493
  const events = await platformClient.getBuildEvents(appName, currentBuildId, lastEventTimestamp || void 0);
@@ -2742,31 +3503,31 @@ Build #${latestBuild.id}`) + ` - commit ${chalk13.dim(latestBuild.commitSha.subs
2742
3503
  const icon = getStatusIcon(latestBuild.status);
2743
3504
  console.log();
2744
3505
  console.log(`${icon} Build #${latestBuild.id} ${latestBuild.status} in ${duration}`);
2745
- console.log(chalk13.dim("\nWaiting for next build...\n"));
3506
+ console.log(chalk14.dim("\nWaiting for next build...\n"));
2746
3507
  currentBuildId = null;
2747
3508
  }
2748
- await sleep3(2e3);
3509
+ await sleep4(2e3);
2749
3510
  } catch (error) {
2750
- await sleep3(5e3);
3511
+ await sleep4(5e3);
2751
3512
  }
2752
3513
  }
2753
3514
  }
2754
3515
  async function watchBuilds(platformClient, appName) {
2755
- console.log(chalk13.cyan(`
3516
+ console.log(chalk14.cyan(`
2756
3517
  Watching builds for '${appName}'...
2757
3518
  `));
2758
- console.log(chalk13.dim("Press Ctrl+C to stop\n"));
3519
+ console.log(chalk14.dim("Press Ctrl+C to stop\n"));
2759
3520
  let lastBuildId = null;
2760
3521
  process.on("SIGINT", () => {
2761
- console.log(chalk13.dim("\n\nStopped watching.\n"));
3522
+ console.log(chalk14.dim("\n\nStopped watching.\n"));
2762
3523
  process.exit(0);
2763
3524
  });
2764
3525
  while (true) {
2765
3526
  try {
2766
3527
  const builds = await platformClient.listBuilds(appName, 5);
2767
3528
  if (builds.length === 0) {
2768
- console.log(chalk13.dim("No builds yet. Waiting..."));
2769
- await sleep3(5e3);
3529
+ console.log(chalk14.dim("No builds yet. Waiting..."));
3530
+ await sleep4(5e3);
2770
3531
  continue;
2771
3532
  }
2772
3533
  const latestBuild = builds[0];
@@ -2774,25 +3535,396 @@ Watching builds for '${appName}'...
2774
3535
  if (lastBuildId !== null) {
2775
3536
  const icon = getStatusIcon(latestBuild.status);
2776
3537
  const sha = latestBuild.commitSha.substring(0, 7);
2777
- console.log(`${icon} New build #${latestBuild.id} ${chalk13.dim(sha)} "${latestBuild.commitMsg || "No message"}" [${latestBuild.status}]`);
3538
+ console.log(`${icon} New build #${latestBuild.id} ${chalk14.dim(sha)} "${latestBuild.commitMsg || "No message"}" [${latestBuild.status}]`);
2778
3539
  }
2779
3540
  lastBuildId = latestBuild.id;
2780
3541
  }
2781
3542
  for (const build of builds) {
2782
3543
  if (build.status === "building") {
2783
3544
  const duration = formatDuration(build.startedAt, null);
2784
- console.log(chalk13.yellow(` \u25CF Build #${build.id} in progress (${duration})...`));
3545
+ console.log(chalk14.yellow(` \u25CF Build #${build.id} in progress (${duration})...`));
2785
3546
  }
2786
3547
  }
2787
- await sleep3(5e3);
3548
+ await sleep4(5e3);
2788
3549
  } catch (error) {
2789
- await sleep3(5e3);
3550
+ await sleep4(5e3);
2790
3551
  }
2791
3552
  }
2792
3553
  }
2793
3554
 
3555
+ // src/commands/vpn.ts
3556
+ import { Command as Command9 } from "commander";
3557
+ import chalk15 from "chalk";
3558
+ import ora9 from "ora";
3559
+ var DEFAULT_LOCATION2 = "eu-central-h1";
3560
+ function createVPNCommands() {
3561
+ const vpnCmd = new Command9("vpn");
3562
+ vpnCmd.description("Manage VPN connection for IPv6 access to your apps");
3563
+ vpnCmd.command("status").description("Show VPN connection status").action(async () => {
3564
+ try {
3565
+ if (!isWireGuardInstalled()) {
3566
+ console.log(chalk15.yellow("\nWireGuard is not installed.\n"));
3567
+ console.log("Install it with:");
3568
+ console.log(" Ubuntu/Debian: sudo apt install wireguard");
3569
+ console.log(" Fedora: sudo dnf install wireguard-tools");
3570
+ console.log(" Arch: sudo pacman -S wireguard-tools");
3571
+ console.log(" macOS: brew install wireguard-tools\n");
3572
+ return;
3573
+ }
3574
+ const status = getVPNStatus();
3575
+ console.log(chalk15.cyan("\nVPN Status\n"));
3576
+ if (status.connected) {
3577
+ console.log(` Status: ${chalk15.green("Connected")}`);
3578
+ console.log(` Interface: ${status.interface}`);
3579
+ if (status.endpoint) {
3580
+ console.log(` Endpoint: ${status.endpoint}`);
3581
+ }
3582
+ if (status.latestHandshake) {
3583
+ console.log(` Handshake: ${status.latestHandshake}`);
3584
+ }
3585
+ if (status.transferRx && status.transferTx) {
3586
+ console.log(` Transfer: ${status.transferRx} received, ${status.transferTx} sent`);
3587
+ }
3588
+ } else {
3589
+ console.log(` Status: ${chalk15.yellow("Disconnected")}`);
3590
+ }
3591
+ const publicKey = getPublicKey();
3592
+ if (publicKey) {
3593
+ console.log(` Public Key: ${chalk15.dim(publicKey.substring(0, 20) + "...")}`);
3594
+ }
3595
+ console.log();
3596
+ } catch (error) {
3597
+ console.error(chalk15.red(`
3598
+ Error: ${error.message}
3599
+ `));
3600
+ process.exit(1);
3601
+ }
3602
+ });
3603
+ vpnCmd.command("connect").description("Establish VPN connection to gateway for IPv6 access").option("-l, --location <location>", "Gateway location", DEFAULT_LOCATION2).action(async (options) => {
3604
+ try {
3605
+ if (!isWireGuardInstalled()) {
3606
+ console.error(chalk15.red("\nWireGuard is not installed.\n"));
3607
+ console.log("Install it with:");
3608
+ console.log(" Ubuntu/Debian: sudo apt install wireguard");
3609
+ console.log(" Fedora: sudo dnf install wireguard-tools");
3610
+ console.log(" Arch: sudo pacman -S wireguard-tools");
3611
+ console.log(" macOS: brew install wireguard-tools\n");
3612
+ process.exit(1);
3613
+ }
3614
+ if (isVPNUp()) {
3615
+ console.log(chalk15.green("\nVPN is already connected.\n"));
3616
+ const status2 = getVPNStatus();
3617
+ if (status2.endpoint) {
3618
+ console.log(` Endpoint: ${status2.endpoint}`);
3619
+ }
3620
+ console.log();
3621
+ return;
3622
+ }
3623
+ const platformToken = getPlatformToken();
3624
+ const platformClient = new PlatformClient(platformToken);
3625
+ console.log(chalk15.cyan("\nEstablishing VPN connection...\n"));
3626
+ const spinner = ora9("Preparing WireGuard keypair...").start();
3627
+ const { publicKey, isNew } = getOrCreateKeyPair();
3628
+ if (isNew) {
3629
+ spinner.succeed("Generated new WireGuard keypair");
3630
+ } else {
3631
+ spinner.succeed("Using existing WireGuard keypair");
3632
+ }
3633
+ spinner.start("Registering with gateway...");
3634
+ const vpnConfigResponse = await platformClient.registerVPNPeer(publicKey, options.location);
3635
+ spinner.succeed("Registered with gateway");
3636
+ const { privateKey } = getOrCreateKeyPair();
3637
+ const vpnConfig = {
3638
+ privateKey,
3639
+ publicKey,
3640
+ address: vpnConfigResponse.address,
3641
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
3642
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
3643
+ allowedIPs: vpnConfigResponse.allowedIPs
3644
+ };
3645
+ spinner.start("Writing WireGuard configuration...");
3646
+ writeWireGuardConfig(vpnConfig);
3647
+ spinner.succeed("WireGuard configuration written");
3648
+ spinner.start("Starting VPN tunnel (requires sudo)...");
3649
+ spinner.stop();
3650
+ console.log(chalk15.dim(" Starting VPN tunnel (requires sudo)..."));
3651
+ vpnUp();
3652
+ console.log(chalk15.green(" \u2713 VPN tunnel started"));
3653
+ spinner.start("Verifying connection...");
3654
+ await new Promise((r) => setTimeout(r, 1e3));
3655
+ const status = getVPNStatus();
3656
+ if (status.connected) {
3657
+ spinner.succeed("VPN connected successfully");
3658
+ } else {
3659
+ spinner.warn("VPN interface is up but connection not verified");
3660
+ }
3661
+ console.log(chalk15.green("\n\u2713 VPN connected!\n"));
3662
+ console.log(` Your IPv6: ${chalk15.bold(vpnConfigResponse.address.split("/")[0])}`);
3663
+ console.log(` Gateway: ${chalk15.bold(vpnConfigResponse.gatewayEndpoint)}`);
3664
+ console.log();
3665
+ console.log(chalk15.dim("You now have IPv6 connectivity to your app VMs."));
3666
+ console.log(chalk15.dim(`Run ${chalk15.bold("hackerrun vpn disconnect")} to disconnect.
3667
+ `));
3668
+ } catch (error) {
3669
+ console.error(chalk15.red(`
3670
+ Error: ${error.message}
3671
+ `));
3672
+ process.exit(1);
3673
+ }
3674
+ });
3675
+ vpnCmd.command("disconnect").description("Disconnect VPN").action(async () => {
3676
+ try {
3677
+ if (!isVPNUp()) {
3678
+ console.log(chalk15.yellow("\nVPN is not connected.\n"));
3679
+ return;
3680
+ }
3681
+ console.log(chalk15.cyan("\nDisconnecting VPN...\n"));
3682
+ console.log(chalk15.dim(" Stopping VPN tunnel (requires sudo)..."));
3683
+ vpnDown();
3684
+ console.log(chalk15.green(" \u2713 VPN tunnel stopped"));
3685
+ console.log(chalk15.green("\n\u2713 VPN disconnected.\n"));
3686
+ } catch (error) {
3687
+ console.error(chalk15.red(`
3688
+ Error: ${error.message}
3689
+ `));
3690
+ process.exit(1);
3691
+ }
3692
+ });
3693
+ vpnCmd.command("test").description("Test IPv6 connectivity to an app VM").argument("<ipv6>", "IPv6 address to test").action(async (ipv6) => {
3694
+ try {
3695
+ console.log(chalk15.cyan(`
3696
+ Testing connectivity to ${ipv6}...
3697
+ `));
3698
+ const spinner = ora9("Testing direct IPv6...").start();
3699
+ const directOk = testIPv6Connectivity2(ipv6, 3);
3700
+ if (directOk) {
3701
+ spinner.succeed("Direct IPv6 connectivity works");
3702
+ console.log(chalk15.green("\n\u2713 You can reach this address directly.\n"));
3703
+ } else {
3704
+ spinner.fail("Direct IPv6 not available");
3705
+ if (isVPNUp()) {
3706
+ spinner.start("VPN is up, testing through VPN...");
3707
+ const vpnOk = testIPv6Connectivity2(ipv6, 5);
3708
+ if (vpnOk) {
3709
+ spinner.succeed("Connectivity works through VPN");
3710
+ console.log(chalk15.green("\n\u2713 You can reach this address via VPN.\n"));
3711
+ } else {
3712
+ spinner.fail("Cannot reach address even through VPN");
3713
+ console.log(chalk15.red("\n\u2717 Cannot reach this address.\n"));
3714
+ console.log("Possible issues:");
3715
+ console.log(" - The target VM may be down");
3716
+ console.log(" - VPN routing may be misconfigured");
3717
+ console.log(" - Firewall may be blocking traffic\n");
3718
+ }
3719
+ } else {
3720
+ console.log(chalk15.yellow("\n\u2717 Cannot reach this address directly.\n"));
3721
+ console.log(`Run ${chalk15.bold("hackerrun vpn connect")} to establish VPN and try again.
3722
+ `);
3723
+ }
3724
+ }
3725
+ } catch (error) {
3726
+ console.error(chalk15.red(`
3727
+ Error: ${error.message}
3728
+ `));
3729
+ process.exit(1);
3730
+ }
3731
+ });
3732
+ return vpnCmd;
3733
+ }
3734
+
3735
+ // src/commands/scale.ts
3736
+ import { Command as Command10 } from "commander";
3737
+ import chalk16 from "chalk";
3738
+ import ora10 from "ora";
3739
+ import { confirm } from "@inquirer/prompts";
3740
+ var DEFAULT_VM_SIZE = "standard-2";
3741
+ var DEFAULT_BOOT_IMAGE = "ubuntu-noble";
3742
+ function createScaleCommand() {
3743
+ const cmd = new Command10("scale");
3744
+ 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) => {
3745
+ let uncloudRunner = null;
3746
+ try {
3747
+ const appName = options.app || getAppName();
3748
+ const platformToken = getPlatformToken();
3749
+ const platformClient = new PlatformClient(platformToken);
3750
+ const clusterManager = new ClusterManager(platformClient);
3751
+ uncloudRunner = new UncloudRunner(platformClient);
3752
+ const app = await platformClient.getApp(appName);
3753
+ if (!app) {
3754
+ console.log(chalk16.red(`
3755
+ App '${appName}' not found
3756
+ `));
3757
+ console.log(`Run ${chalk16.bold("hackerrun deploy")} to create an app first.
3758
+ `);
3759
+ process.exit(1);
3760
+ }
3761
+ const currentNodeCount = app.nodes.length;
3762
+ if (!countArg) {
3763
+ console.log(chalk16.cyan(`
3764
+ App '${appName}' Scale Info:
3765
+ `));
3766
+ console.log(` Current nodes: ${chalk16.bold(currentNodeCount.toString())}`);
3767
+ console.log();
3768
+ app.nodes.forEach((node, index) => {
3769
+ const primaryLabel = node.isPrimary ? chalk16.yellow(" (primary)") : "";
3770
+ console.log(` ${index + 1}. ${chalk16.bold(node.name)}${primaryLabel}`);
3771
+ console.log(` IPv6: ${node.ipv6 || "pending"}`);
3772
+ console.log();
3773
+ });
3774
+ console.log(chalk16.dim("Usage:"));
3775
+ console.log(chalk16.dim(` hackerrun scale 3 # Scale to 3 nodes`));
3776
+ console.log(chalk16.dim(` hackerrun scale +1 # Add 1 node`));
3777
+ console.log(chalk16.dim(` hackerrun scale -1 # Remove 1 node`));
3778
+ console.log();
3779
+ return;
3780
+ }
3781
+ let targetCount;
3782
+ if (countArg.startsWith("+")) {
3783
+ const delta = parseInt(countArg.slice(1), 10);
3784
+ if (isNaN(delta) || delta <= 0) {
3785
+ console.log(chalk16.red(`
3786
+ Invalid count: ${countArg}
3787
+ `));
3788
+ process.exit(1);
3789
+ }
3790
+ targetCount = currentNodeCount + delta;
3791
+ } else if (countArg.startsWith("-")) {
3792
+ const delta = parseInt(countArg.slice(1), 10);
3793
+ if (isNaN(delta) || delta <= 0) {
3794
+ console.log(chalk16.red(`
3795
+ Invalid count: ${countArg}
3796
+ `));
3797
+ process.exit(1);
3798
+ }
3799
+ targetCount = currentNodeCount - delta;
3800
+ } else {
3801
+ targetCount = parseInt(countArg, 10);
3802
+ if (isNaN(targetCount)) {
3803
+ console.log(chalk16.red(`
3804
+ Invalid count: ${countArg}
3805
+ `));
3806
+ process.exit(1);
3807
+ }
3808
+ }
3809
+ if (targetCount < 1) {
3810
+ console.log(chalk16.red(`
3811
+ Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
3812
+ `));
3813
+ process.exit(1);
3814
+ }
3815
+ if (targetCount === currentNodeCount) {
3816
+ console.log(chalk16.yellow(`
3817
+ App already has ${currentNodeCount} node(s). Nothing to do.
3818
+ `));
3819
+ return;
3820
+ }
3821
+ const vmSize = options.size || DEFAULT_VM_SIZE;
3822
+ if (targetCount > currentNodeCount) {
3823
+ const nodesToAdd = targetCount - currentNodeCount;
3824
+ console.log(chalk16.cyan(`
3825
+ Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (+${nodesToAdd})
3826
+ `));
3827
+ for (let i = 0; i < nodesToAdd; i++) {
3828
+ console.log(chalk16.cyan(`
3829
+ Adding node ${i + 1} of ${nodesToAdd}...`));
3830
+ const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
3831
+ console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
3832
+ }
3833
+ const updatedApp = await platformClient.getApp(appName);
3834
+ const syncSpinner = ora10("Syncing gateway routes...").start();
3835
+ try {
3836
+ await platformClient.syncGatewayRoutes(app.location);
3837
+ syncSpinner.succeed("Gateway routes synced");
3838
+ } catch (error) {
3839
+ syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3840
+ }
3841
+ console.log(chalk16.green(`
3842
+ \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3843
+ `));
3844
+ console.log(chalk16.dim("To deploy to specific nodes, use x-machines in your compose file:"));
3845
+ console.log(chalk16.dim(" services:"));
3846
+ console.log(chalk16.dim(" web:"));
3847
+ console.log(chalk16.dim(" x-machines:"));
3848
+ updatedApp?.nodes.forEach((node) => {
3849
+ console.log(chalk16.dim(` - ${node.name}`));
3850
+ });
3851
+ console.log();
3852
+ } else {
3853
+ const nodesToRemove = currentNodeCount - targetCount;
3854
+ const sortedNodes = [...app.nodes].sort((a, b) => {
3855
+ const numA = parseInt(a.name.split("-").pop() || "0", 10);
3856
+ const numB = parseInt(b.name.split("-").pop() || "0", 10);
3857
+ return numB - numA;
3858
+ });
3859
+ const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
3860
+ console.log(chalk16.yellow(`
3861
+ Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (-${nodesToRemove})
3862
+ `));
3863
+ console.log(chalk16.yellow("The following nodes will be removed:"));
3864
+ nodesToRemoveList.forEach((node) => {
3865
+ console.log(` - ${node.name} (${node.ipv6})`);
3866
+ });
3867
+ console.log();
3868
+ if (!options.force) {
3869
+ console.log(chalk16.yellow("Warning: All containers on these nodes will be stopped and the VMs deleted."));
3870
+ console.log(chalk16.yellow("Data on these nodes will be lost.\n"));
3871
+ const confirmed = await confirm({
3872
+ message: "Are you sure you want to remove these nodes?",
3873
+ default: false
3874
+ });
3875
+ if (!confirmed) {
3876
+ console.log(chalk16.dim("\nScale cancelled.\n"));
3877
+ return;
3878
+ }
3879
+ }
3880
+ for (const node of nodesToRemoveList) {
3881
+ console.log(chalk16.cyan(`
3882
+ Removing node '${node.name}'...`));
3883
+ try {
3884
+ const spinner = ora10("Removing from uncloud cluster...").start();
3885
+ await uncloudRunner.removeNode(appName, node.name);
3886
+ spinner.succeed("Removed from cluster");
3887
+ spinner.start("Deleting VM...");
3888
+ await platformClient.deleteVM(app.location, node.name);
3889
+ spinner.succeed("VM deleted");
3890
+ spinner.start("Updating app state...");
3891
+ const updatedNodes = app.nodes.filter((n) => n.name !== node.name);
3892
+ app.nodes = updatedNodes;
3893
+ await platformClient.saveApp(app);
3894
+ spinner.succeed("App state updated");
3895
+ console.log(chalk16.green(` Removed: ${node.name}`));
3896
+ } catch (error) {
3897
+ console.log(chalk16.red(` Failed to remove ${node.name}: ${error.message}`));
3898
+ }
3899
+ }
3900
+ const updatedApp = await platformClient.getApp(appName);
3901
+ const syncSpinner = ora10("Syncing gateway routes...").start();
3902
+ try {
3903
+ await platformClient.syncGatewayRoutes(app.location);
3904
+ syncSpinner.succeed("Gateway routes synced");
3905
+ } catch (error) {
3906
+ syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3907
+ }
3908
+ console.log(chalk16.green(`
3909
+ \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3910
+ `));
3911
+ }
3912
+ } catch (error) {
3913
+ console.error(chalk16.red(`
3914
+ Error: ${error.message}
3915
+ `));
3916
+ process.exit(1);
3917
+ } finally {
3918
+ if (uncloudRunner) {
3919
+ uncloudRunner.cleanup();
3920
+ }
3921
+ }
3922
+ });
3923
+ return cmd;
3924
+ }
3925
+
2794
3926
  // src/index.ts
2795
- var program = new Command9();
3927
+ var program = new Command11();
2796
3928
  program.name("hackerrun").description("Deploy apps with full control over your infrastructure").version("0.1.0");
2797
3929
  program.addCommand(createLoginCommand());
2798
3930
  program.addCommand(createConfigCommand());
@@ -2802,6 +3934,8 @@ program.addCommand(createConnectCommand());
2802
3934
  program.addCommand(createDisconnectCommand());
2803
3935
  program.addCommand(createEnvCommand());
2804
3936
  program.addCommand(createBuildsCommand());
3937
+ program.addCommand(createVPNCommands());
3938
+ program.addCommand(createScaleCommand());
2805
3939
  var { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
2806
3940
  program.addCommand(appsCmd);
2807
3941
  program.addCommand(nodesCmd);