hackerrun 0.1.10 → 0.1.12

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 Command11 } from "commander";
10
+ import { Command as Command12 } from "commander";
11
11
 
12
12
  // src/commands/login.ts
13
13
  import { Command } from "commander";
@@ -537,1026 +537,1315 @@ var TunnelManager = class {
537
537
  }
538
538
  };
539
539
 
540
- // src/lib/cluster.ts
541
- import { execSync as execSync2 } from "child_process";
542
- import ora2 from "ora";
540
+ // src/lib/uncloud-runner.ts
541
+ import { execSync as execSync3, spawnSync as spawnSync3 } from "child_process";
542
+
543
+ // src/lib/vpn.ts
544
+ import { execSync as execSync2, spawnSync as spawnSync2 } from "child_process";
545
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, unlinkSync } from "fs";
546
+ import { homedir as homedir2, platform } from "os";
547
+ import { join as join3 } from "path";
543
548
  import chalk2 from "chalk";
544
- var platformKeysCache = null;
545
- var ClusterManager = class {
546
- constructor(platformClient) {
547
- this.platformClient = platformClient;
548
- this.sshCertManager = new SSHCertManager(platformClient);
549
- }
550
- sshCertManager;
551
- /**
552
- * Get platform SSH keys (cached for the session)
553
- * Returns CA public key and platform public key for VM creation
554
- */
555
- async getPlatformKeys() {
556
- if (platformKeysCache) {
557
- return platformKeysCache;
558
- }
559
- try {
560
- platformKeysCache = await this.platformClient.getPlatformSSHKeys();
561
- return platformKeysCache;
562
- } catch (error) {
563
- throw new Error(`Failed to get platform SSH keys: ${error.message}`);
564
- }
565
- }
566
- /**
567
- * Initialize a new cluster for an app (creates first VM and sets up uncloud)
568
- *
569
- * Flow:
570
- * 1. Create VM with platform SSH key
571
- * 2. Wait for VM to get IPv6 address
572
- * 3. Call platform API to setup VM (DNS64, WireGuard, SSH CA)
573
- * 4. Run `uc machine init` to install Docker + uncloud
574
- * 5. Configure Docker for NAT64
575
- * 6. Save app state to platform
576
- *
577
- * Users access VMs via SSH certificates signed by the platform CA.
578
- */
579
- async initializeCluster(options) {
580
- const { appName, location, vmSize, storageSize, bootImage } = options;
581
- const vmName = `${appName}-1`;
582
- let spinner = ora2(`Creating VM '${vmName}' in ${location}...`).start();
583
- try {
584
- spinner.text = "Fetching platform SSH keys...";
585
- const platformKeys = await this.getPlatformKeys();
586
- const gateway = await this.platformClient.getGateway(location);
587
- const privateSubnetId = gateway?.subnetId;
588
- spinner.text = `Creating VM '${vmName}'...`;
589
- const vm = await this.platformClient.createVM({
590
- name: vmName,
591
- location,
592
- size: vmSize,
593
- storage_size: storageSize,
594
- boot_image: bootImage,
595
- unix_user: "root",
596
- public_key: platformKeys.platformPublicKey,
597
- enable_ip4: !privateSubnetId,
598
- // IPv6-only if we have a subnet for NAT64
599
- private_subnet_id: privateSubnetId
600
- });
601
- spinner.text = `Waiting for VM to be ready...`;
602
- spinner.stop();
603
- console.log(chalk2.cyan("\nWaiting for VM to get an IPv6 address..."));
604
- const vmWithIp = await this.waitForVM(location, vmName, 600, false);
605
- spinner = ora2("Setting up VM...").start();
606
- spinner.text = "Waiting for SSH to be ready...";
607
- await this.sleep(3e4);
608
- spinner.text = "Configuring VM (DNS64, NAT64, SSH CA)...";
609
- await this.platformClient.setupVM(vmWithIp.ip6, location, appName);
610
- const cluster = {
611
- appName,
612
- location,
613
- nodes: [{
614
- name: vmName,
615
- id: vm.id,
616
- ipv6: vmWithIp.ip6,
617
- isPrimary: true
618
- }],
619
- uncloudContext: appName,
620
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
621
- };
622
- spinner.text = "Registering app...";
623
- const savedCluster = await this.platformClient.saveApp(cluster);
624
- spinner.text = "Installing Docker and Uncloud...";
625
- spinner.stop();
626
- console.log(chalk2.cyan("\nInitializing uncloud (this may take a few minutes)..."));
627
- await this.initializeUncloud(vmWithIp.ip6, appName, gateway?.ipv4);
628
- spinner = ora2("Configuring Docker for NAT64...").start();
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();
633
- spinner.succeed(chalk2.green(`Cluster initialized successfully`));
634
- return savedCluster;
635
- } catch (error) {
636
- spinner.fail(chalk2.red("Failed to initialize cluster"));
637
- throw error;
638
- }
549
+ function detectOS() {
550
+ const os = platform();
551
+ if (os === "darwin") {
552
+ return { type: "macos" };
639
553
  }
640
- /**
641
- * Add a node to an existing cluster
642
- */
643
- async addNode(appName, vmSize, bootImage) {
644
- const cluster = await this.platformClient.getApp(appName);
645
- if (!cluster) {
646
- throw new Error(`App '${appName}' not found`);
647
- }
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`);
651
- }
652
- const gateway = await this.platformClient.getGateway(cluster.location);
653
- const privateSubnetId = gateway?.subnetId;
654
- const vmName = this.generateNodeName(appName, cluster.nodes);
655
- let spinner = ora2(`Adding node '${vmName}' to cluster...`).start();
656
- try {
657
- spinner.text = "Fetching platform SSH keys...";
658
- const platformKeys = await this.getPlatformKeys();
659
- spinner.text = `Creating VM '${vmName}'...`;
660
- const vm = await this.platformClient.createVM({
661
- name: vmName,
662
- location: cluster.location,
663
- size: vmSize,
664
- boot_image: bootImage,
665
- unix_user: "root",
666
- public_key: platformKeys.platformPublicKey,
667
- enable_ip4: !privateSubnetId,
668
- private_subnet_id: privateSubnetId
669
- });
670
- spinner.text = `Waiting for VM to be ready...`;
671
- spinner.stop();
672
- console.log(chalk2.cyan("\nWaiting for VM to get an IPv6 address..."));
673
- const vmWithIp = await this.waitForVM(cluster.location, vmName, 600, false);
674
- spinner = ora2("Setting up VM...").start();
675
- await this.sleep(3e4);
676
- spinner.text = "Configuring VM...";
677
- await this.platformClient.setupVM(vmWithIp.ip6, cluster.location, appName);
678
- spinner.text = "Joining uncloud cluster...";
679
- spinner.stop();
680
- console.log(chalk2.cyan("\nJoining uncloud cluster..."));
681
- await this.joinUncloudCluster(vmWithIp.ip6, existingNode.ipv6, appName);
682
- spinner = ora2("Configuring Docker for NAT64...").start();
683
- await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
684
- spinner.succeed(chalk2.green(`Node '${vmName}' added successfully`));
685
- const newNode = {
686
- name: vmName,
687
- id: vm.id,
688
- ipv6: vmWithIp.ip6,
689
- isPrimary: false
690
- };
691
- cluster.nodes.push(newNode);
692
- await this.platformClient.saveApp(cluster);
693
- return newNode;
694
- } catch (error) {
695
- spinner.fail(chalk2.red("Failed to add node"));
696
- throw error;
697
- }
554
+ if (os === "win32") {
555
+ return { type: "windows" };
698
556
  }
699
- /**
700
- * Initialize uncloud on the VM using uc machine init
701
- * Uses --no-dns because we manage our own domain via gateway
702
- *
703
- * Before running uc, we get an SSH certificate from the platform
704
- * and add it to the ssh-agent. This allows uc to authenticate
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.
708
- */
709
- async initializeUncloud(vmIp, contextName, gatewayIp) {
710
- let tunnel = null;
711
- let targetHost = vmIp;
557
+ if (os === "linux") {
712
558
  try {
713
- await this.sshCertManager.getSession(contextName, vmIp);
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
- }
559
+ if (existsSync3("/etc/os-release")) {
560
+ const osRelease = readFileSync3("/etc/os-release", "utf-8");
561
+ const idMatch = osRelease.match(/^ID=(.*)$/m);
562
+ if (idMatch) {
563
+ const distro = idMatch[1].replace(/"/g, "").toLowerCase();
564
+ return { type: "linux", distro };
751
565
  }
752
566
  }
753
- console.log(chalk2.dim("Uncloud initialization complete."));
754
- } catch (error) {
755
- throw new Error(`Failed to initialize uncloud: ${error.message}`);
756
- } finally {
757
- if (tunnel) {
758
- killTunnel(tunnel);
759
- }
567
+ } catch {
760
568
  }
569
+ return { type: "linux" };
761
570
  }
762
- /**
763
- * Join a new node to an existing uncloud cluster
764
- * Uses --connect ssh:// to avoid local context dependency
765
- */
766
- async joinUncloudCluster(newVmIp, primaryVmIp, contextName) {
767
- try {
768
- await this.sshCertManager.getSession(contextName, primaryVmIp);
769
- await this.sshCertManager.getSession(contextName, newVmIp);
770
- const token = execSync2(`uc --connect ssh://root@${primaryVmIp} machine token`, {
771
- encoding: "utf-8",
772
- timeout: 3e4
773
- }).trim();
774
- if (!token) {
775
- throw new Error("Failed to get join token from primary node");
571
+ return { type: "unknown" };
572
+ }
573
+ function getWireGuardInstallInstructions() {
574
+ const os = detectOS();
575
+ switch (os.type) {
576
+ case "macos":
577
+ return ` ${chalk2.cyan("macOS:")} brew install wireguard-tools`;
578
+ case "windows":
579
+ return ` ${chalk2.cyan("Windows:")} Download from https://www.wireguard.com/install/
580
+ Or using winget: winget install WireGuard.WireGuard`;
581
+ case "linux":
582
+ switch (os.distro) {
583
+ case "arch":
584
+ case "manjaro":
585
+ case "endeavouros":
586
+ case "artix":
587
+ return ` ${chalk2.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
588
+ case "ubuntu":
589
+ case "debian":
590
+ case "linuxmint":
591
+ case "pop":
592
+ case "elementary":
593
+ case "zorin":
594
+ return ` ${chalk2.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
595
+ case "fedora":
596
+ return ` ${chalk2.cyan("Fedora:")} sudo dnf install wireguard-tools`;
597
+ case "rhel":
598
+ case "centos":
599
+ case "rocky":
600
+ case "almalinux":
601
+ return ` ${chalk2.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
602
+ (may need EPEL: sudo dnf install epel-release)`;
603
+ case "opensuse":
604
+ case "opensuse-leap":
605
+ case "opensuse-tumbleweed":
606
+ return ` ${chalk2.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
607
+ case "gentoo":
608
+ return ` ${chalk2.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
609
+ case "void":
610
+ return ` ${chalk2.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
611
+ case "alpine":
612
+ return ` ${chalk2.cyan("Alpine:")} sudo apk add wireguard-tools`;
613
+ case "nixos":
614
+ return ` ${chalk2.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
615
+ default:
616
+ return ` ${chalk2.cyan("Linux:")} Install wireguard-tools using your package manager:
617
+ Arch: sudo pacman -S wireguard-tools
618
+ Ubuntu/Debian: sudo apt install wireguard
619
+ Fedora: sudo dnf install wireguard-tools
620
+ openSUSE: sudo zypper install wireguard-tools`;
776
621
  }
777
- execSync2(`uc --connect ssh://root@${primaryVmIp} machine add root@${newVmIp} --token "${token}"`, {
778
- stdio: "inherit",
779
- timeout: 6e5
780
- // 10 min timeout
781
- });
782
- } catch (error) {
783
- throw new Error(`Failed to join cluster: ${error.message}`);
784
- }
622
+ default:
623
+ return ` Visit https://www.wireguard.com/install/ for installation instructions`;
785
624
  }
786
- /**
787
- * Configure Docker for NAT64 support on IPv6-only VMs
788
- *
789
- * Problem: DNS64 returns both A (IPv4) and AAAA (IPv6) records. Applications may try
790
- * IPv4 first, which times out because there's no route to IPv4 internet.
791
- *
792
- * Solution:
793
- * 1. Enable IPv6 on Docker networks so containers get IPv6 addresses
794
- * 2. Block IPv4 forwarding from Docker to internet so IPv4 fails immediately
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.
798
- */
799
- async configureDockerNAT64(vmIp, gatewayIp) {
800
- const setupScript = `#!/bin/bash
801
- set -e
802
-
803
- # Step 1: Add IPv6 support to Docker daemon config
804
- DAEMON_JSON='/etc/docker/daemon.json'
805
- if [ -f "$DAEMON_JSON" ]; then
806
- # Merge with existing config using jq
807
- jq '. + {"ipv6": true, "fixed-cidr-v6": "fd00:d0c6:e4::/64"}' "$DAEMON_JSON" > /tmp/daemon.json
808
- mv /tmp/daemon.json "$DAEMON_JSON"
809
- else
810
- cat > "$DAEMON_JSON" << 'EOFJSON'
811
- {
812
- "ipv6": true,
813
- "fixed-cidr-v6": "fd00:d0c6:e4::/64"
814
625
  }
815
- EOFJSON
816
- fi
817
-
818
- # Step 2: Create systemd service to block IPv4 forwarding from Docker containers
819
- cat > /etc/systemd/system/docker-ipv6-nat64.service << 'EOFSVC'
820
- [Unit]
821
- Description=Block IPv4 forwarding from Docker containers (force NAT64)
822
- After=docker.service
823
- Requires=docker.service
824
-
825
- [Service]
826
- Type=oneshot
827
- RemainAfterExit=yes
828
- ExecStart=/sbin/iptables -I FORWARD -i br-+ -o ens3 -j REJECT --reject-with icmp-net-unreachable
829
- ExecStop=/sbin/iptables -D FORWARD -i br-+ -o ens3 -j REJECT --reject-with icmp-net-unreachable
830
-
831
- [Install]
832
- WantedBy=multi-user.target
833
- EOFSVC
834
-
835
- systemctl daemon-reload
836
- systemctl enable docker-ipv6-nat64
837
-
838
- # Step 3: Restart Docker to apply IPv6 config
839
- systemctl restart docker
840
- sleep 3
841
-
842
- # Step 4: Recreate uncloud network with IPv6 enabled
843
- CONTAINERS=$(docker ps -aq --filter network=uncloud 2>/dev/null || true)
844
-
845
- for container in $CONTAINERS; do
846
- docker network disconnect uncloud "$container" 2>/dev/null || true
847
- done
848
-
849
- docker network rm uncloud 2>/dev/null || true
850
- docker network create --driver bridge --subnet 10.210.0.0/24 --gateway 10.210.0.1 --ipv6 --subnet fd00:a10:210::/64 --gateway fd00:a10:210::1 uncloud
851
-
852
- for container in $CONTAINERS; do
853
- docker network connect uncloud "$container" 2>/dev/null || true
854
- done
626
+ var CONFIG_DIR = join3(homedir2(), ".config", "hackerrun");
627
+ var PRIVATE_KEY_FILE = join3(CONFIG_DIR, "wg-private-key");
628
+ var WG_INTERFACE = "hackerrun";
629
+ var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
630
+ function isWireGuardInstalled() {
631
+ try {
632
+ execSync2("which wg", { stdio: "ignore" });
633
+ execSync2("which wg-quick", { stdio: "ignore" });
634
+ return true;
635
+ } catch {
636
+ return false;
637
+ }
638
+ }
639
+ function ensureConfigDir() {
640
+ if (!existsSync3(CONFIG_DIR)) {
641
+ mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
642
+ }
643
+ }
644
+ function generateKeyPair() {
645
+ const privateKey = execSync2("wg genkey", { encoding: "utf-8" }).trim();
646
+ const publicKey = execSync2("wg pubkey", {
647
+ input: privateKey,
648
+ encoding: "utf-8"
649
+ }).trim();
650
+ return { privateKey, publicKey };
651
+ }
652
+ function getOrCreateKeyPair() {
653
+ ensureConfigDir();
654
+ if (existsSync3(PRIVATE_KEY_FILE)) {
655
+ const privateKey2 = readFileSync3(PRIVATE_KEY_FILE, "utf-8").trim();
656
+ const publicKey2 = execSync2("wg pubkey", {
657
+ input: privateKey2,
658
+ encoding: "utf-8"
659
+ }).trim();
660
+ return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
661
+ }
662
+ const { privateKey, publicKey } = generateKeyPair();
663
+ writeFileSync3(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
664
+ return { privateKey, publicKey, isNew: true };
665
+ }
666
+ function getPublicKey() {
667
+ if (!existsSync3(PRIVATE_KEY_FILE)) {
668
+ return null;
669
+ }
670
+ const privateKey = readFileSync3(PRIVATE_KEY_FILE, "utf-8").trim();
671
+ return execSync2("wg pubkey", {
672
+ input: privateKey,
673
+ encoding: "utf-8"
674
+ }).trim();
675
+ }
676
+ function isVPNUp() {
677
+ try {
678
+ const result = execSync2(`ip link show ${WG_INTERFACE}`, {
679
+ encoding: "utf-8",
680
+ stdio: ["pipe", "pipe", "pipe"]
681
+ });
682
+ return result.includes(WG_INTERFACE);
683
+ } catch {
684
+ return false;
685
+ }
686
+ }
687
+ function isRoutedViaVPN(ipv6Address) {
688
+ if (!isVPNUp()) {
689
+ return false;
690
+ }
691
+ try {
692
+ const result = execSync2(`ip -6 route get ${ipv6Address}`, {
693
+ encoding: "utf-8",
694
+ stdio: ["pipe", "pipe", "pipe"]
695
+ });
696
+ return result.includes(`dev ${WG_INTERFACE}`);
697
+ } catch {
698
+ return false;
699
+ }
700
+ }
701
+ function getVPNStatus() {
702
+ if (!isVPNUp()) {
703
+ return { connected: false };
704
+ }
705
+ try {
706
+ const output = execSync2(`sudo wg show ${WG_INTERFACE}`, {
707
+ encoding: "utf-8",
708
+ stdio: ["pipe", "pipe", "pipe"]
709
+ });
710
+ const lines = output.split("\n");
711
+ let endpoint;
712
+ let latestHandshake;
713
+ let transferRx;
714
+ let transferTx;
715
+ for (const line of lines) {
716
+ if (line.includes("endpoint:")) {
717
+ endpoint = line.split("endpoint:")[1]?.trim();
718
+ }
719
+ if (line.includes("latest handshake:")) {
720
+ latestHandshake = line.split("latest handshake:")[1]?.trim();
721
+ }
722
+ if (line.includes("transfer:")) {
723
+ const transfer = line.split("transfer:")[1]?.trim();
724
+ const parts = transfer?.split(",");
725
+ transferRx = parts?.[0]?.trim();
726
+ transferTx = parts?.[1]?.trim();
727
+ }
728
+ }
729
+ return {
730
+ connected: true,
731
+ interface: WG_INTERFACE,
732
+ endpoint,
733
+ latestHandshake,
734
+ transferRx,
735
+ transferTx
736
+ };
737
+ } catch {
738
+ return { connected: isVPNUp(), interface: WG_INTERFACE };
739
+ }
740
+ }
741
+ function generateWireGuardConfig(config) {
742
+ return `# HackerRun VPN - Auto-generated
743
+ # Do not edit manually
855
744
 
856
- # Step 5: Start the iptables service
857
- systemctl start docker-ipv6-nat64
745
+ [Interface]
746
+ PrivateKey = ${config.privateKey}
747
+ Address = ${config.address}
858
748
 
859
- echo "Docker NAT64 configuration complete"
749
+ [Peer]
750
+ PublicKey = ${config.gatewayPublicKey}
751
+ Endpoint = ${config.gatewayEndpoint}
752
+ AllowedIPs = ${config.allowedIPs}
753
+ PersistentKeepalive = 25
860
754
  `;
861
- let tunnel = null;
862
- let sshHost = vmIp;
863
- let sshPortArgs = "";
864
- try {
865
- const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
866
- if (!canConnectDirect && gatewayIp) {
867
- tunnel = await createTunnel(vmIp, gatewayIp);
868
- sshHost = "localhost";
869
- sshPortArgs = `-p ${tunnel.localPort}`;
870
- }
871
- execSync2(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
872
- ${setupScript}
873
- REMOTESCRIPT`, {
874
- stdio: "inherit",
875
- timeout: 12e4
876
- // 2 min timeout
877
- });
878
- } catch (error) {
879
- throw new Error(`Failed to configure Docker for NAT64: ${error.message}`);
880
- } finally {
881
- if (tunnel) {
882
- killTunnel(tunnel);
883
- }
755
+ }
756
+ function writeWireGuardConfig(config) {
757
+ const configContent = generateWireGuardConfig(config);
758
+ const tempFile = join3(CONFIG_DIR, "wg-temp.conf");
759
+ writeFileSync3(tempFile, configContent, { mode: 384 });
760
+ try {
761
+ execSync2("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
762
+ execSync2(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
763
+ execSync2(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
764
+ } catch (error) {
765
+ if (existsSync3(tempFile)) {
766
+ unlinkSync(tempFile);
884
767
  }
768
+ throw error;
769
+ }
770
+ }
771
+ function vpnUp() {
772
+ if (isVPNUp()) {
773
+ return;
774
+ }
775
+ const result = spawnSync2("sudo", ["wg-quick", "up", WG_INTERFACE], {
776
+ stdio: "inherit"
777
+ });
778
+ if (result.status !== 0) {
779
+ throw new Error(`Failed to bring up VPN interface (exit code ${result.status})`);
780
+ }
781
+ }
782
+ function vpnDown() {
783
+ if (!isVPNUp()) {
784
+ return;
785
+ }
786
+ const result = spawnSync2("sudo", ["wg-quick", "down", WG_INTERFACE], {
787
+ stdio: "inherit"
788
+ });
789
+ if (result.status !== 0) {
790
+ throw new Error(`Failed to bring down VPN interface (exit code ${result.status})`);
791
+ }
792
+ }
793
+ function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
794
+ try {
795
+ execSync2(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
796
+ stdio: "ignore",
797
+ timeout: (timeoutSeconds + 2) * 1e3
798
+ });
799
+ return true;
800
+ } catch {
801
+ return false;
885
802
  }
803
+ }
804
+ var VPNManager = class {
805
+ wasConnectedByUs = false;
886
806
  /**
887
- * Wait for VM to have an IP address
888
- * @param requireIpv4 - If true, wait for IPv4 (for gateway VMs). Otherwise wait for IPv6.
807
+ * Ensure VPN is connected if IPv6 is not available
808
+ * Returns true if VPN was established (and should be torn down later)
889
809
  */
890
- async waitForVM(location, vmName, timeoutSeconds, requireIpv4 = false) {
891
- const startTime = Date.now();
892
- const timeoutMs = timeoutSeconds * 1e3;
893
- let lastStatus = "";
894
- while (Date.now() - startTime < timeoutMs) {
895
- const vm = await this.platformClient.getVM(location, vmName);
896
- if (vm.status && vm.status !== lastStatus) {
897
- console.log(chalk2.dim(` Status: ${vm.status}`));
898
- lastStatus = vm.status;
899
- }
900
- const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
901
- if (hasRequiredIp) {
902
- const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
903
- console.log(chalk2.green(` IP assigned: ${ipDisplay}`));
904
- return vm;
810
+ async ensureConnected(targetIPv6, getVPNConfig) {
811
+ if (testIPv6Connectivity2(targetIPv6)) {
812
+ console.log(chalk2.green("\u2713 Direct IPv6 connectivity available"));
813
+ return false;
814
+ }
815
+ console.log(chalk2.yellow("IPv6 not available, checking VPN..."));
816
+ if (isVPNUp()) {
817
+ if (testIPv6Connectivity2(targetIPv6)) {
818
+ console.log(chalk2.green("\u2713 VPN already connected"));
819
+ return false;
905
820
  }
906
- const elapsed = Math.floor((Date.now() - startTime) / 1e3);
907
- const ipType = requireIpv4 ? "IPv4" : "IPv6";
908
- process.stdout.write(`\r Waiting for ${ipType}... (${elapsed}s / ${timeoutSeconds}s)`);
909
- await this.sleep(5e3);
821
+ console.log(chalk2.yellow("VPN is up but cannot reach target, reconnecting..."));
822
+ vpnDown();
910
823
  }
911
- process.stdout.write("\n");
912
- throw new Error(
913
- `Timeout waiting for VM to get IP address after ${timeoutSeconds}s.
914
-
915
- The VM may still be provisioning. You can:
916
- 1. Check VM status: hackerrun vm list
917
- 2. Delete and retry: hackerrun vm delete ${vmName}
918
- 3. Check Ubicloud console: https://console.ubicloud.com`
919
- );
824
+ if (!isWireGuardInstalled()) {
825
+ const instructions = getWireGuardInstallInstructions();
826
+ throw new Error(
827
+ "WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
828
+ );
829
+ }
830
+ console.log(chalk2.cyan("Establishing VPN tunnel (requires sudo)..."));
831
+ const { publicKey, isNew } = getOrCreateKeyPair();
832
+ if (isNew) {
833
+ console.log(chalk2.dim("Generated new WireGuard keypair"));
834
+ }
835
+ const vpnConfig = await getVPNConfig();
836
+ writeWireGuardConfig(vpnConfig);
837
+ vpnUp();
838
+ if (!testIPv6Connectivity2(targetIPv6)) {
839
+ vpnDown();
840
+ throw new Error("VPN established but cannot reach target. Please try again.");
841
+ }
842
+ console.log(chalk2.green("\u2713 VPN connected"));
843
+ this.wasConnectedByUs = true;
844
+ return true;
920
845
  }
921
846
  /**
922
- * Wait for uncloud services to be ready on the VM
923
- * Specifically checks that unregistry (Docker registry at 10.210.0.1:5000) is accepting connections
924
- * This is critical for first deploy - push will fail if unregistry isn't ready
847
+ * Disconnect VPN if we established it
925
848
  */
926
- async waitForUncloudReady(vmIp, gatewayIp) {
927
- const maxAttempts = 30;
928
- const checkInterval = 2e3;
929
- const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
930
- const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
931
- const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
932
- console.log(chalk2.dim(" Waiting for uncloud services..."));
933
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
849
+ disconnect() {
850
+ if (this.wasConnectedByUs && isVPNUp()) {
851
+ console.log(chalk2.dim("Disconnecting VPN..."));
934
852
  try {
935
- const result = execSync2(
936
- `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
937
- { encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
938
- ).trim();
939
- if (result.includes("ready")) {
940
- console.log(chalk2.dim(" Uncloud services ready"));
941
- return;
942
- }
853
+ vpnDown();
854
+ console.log(chalk2.green("\u2713 VPN disconnected"));
943
855
  } catch (error) {
856
+ console.log(chalk2.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
944
857
  }
945
- if (attempt % 5 === 0) {
946
- console.log(chalk2.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
947
- }
948
- if (attempt < maxAttempts) {
949
- await this.sleep(checkInterval);
950
- }
858
+ this.wasConnectedByUs = false;
951
859
  }
952
- console.log(chalk2.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
953
- }
954
- /**
955
- * Generate a sequential node name: appName-1, appName-2, etc.
956
- * Finds the next available number by checking existing node names
957
- */
958
- generateNodeName(appName, existingNodes) {
959
- const existingNumbers = existingNodes.map((n) => {
960
- const match = n.name.match(new RegExp(`^${appName}-(\\d+)$`));
961
- return match ? parseInt(match[1], 10) : 0;
962
- }).filter((n) => n > 0);
963
- const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
964
- return `${appName}-${maxNumber + 1}`;
965
- }
966
- /**
967
- * Generate a random ID (used for legacy naming or fallback)
968
- */
969
- generateId() {
970
- return Math.random().toString(36).substring(2, 9);
971
- }
972
- /**
973
- * Sleep for specified milliseconds
974
- */
975
- sleep(ms) {
976
- return new Promise((resolve) => setTimeout(resolve, ms));
977
860
  }
978
861
  };
979
862
 
980
- // src/lib/platform.ts
981
- import { platform } from "os";
863
+ // src/lib/uncloud-runner.ts
982
864
  import chalk3 from "chalk";
983
- var PlatformDetector = class {
984
- /**
985
- * Check if running on Windows
986
- */
987
- static isWindows() {
988
- return platform() === "win32";
865
+ var UncloudRunner = class {
866
+ constructor(platformClient) {
867
+ this.platformClient = platformClient;
868
+ this.certManager = new SSHCertManager(platformClient);
989
869
  }
870
+ certManager;
871
+ gatewayCache = /* @__PURE__ */ new Map();
872
+ tunnelManager = new TunnelManager();
873
+ vpnManager = new VPNManager();
874
+ tempConfigPath = null;
875
+ vpnEstablishedByUs = false;
990
876
  /**
991
- * Check if running inside WSL (Windows Subsystem for Linux)
877
+ * Get the connection URL for an app's primary VM
878
+ * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
992
879
  */
993
- static isWSL() {
994
- if (!this.isWindows()) {
995
- if (platform() === "linux") {
996
- try {
997
- const fs = __require("fs");
998
- const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
999
- return procVersion.includes("microsoft") || procVersion.includes("wsl");
1000
- } catch {
1001
- return false;
880
+ async getConnectionInfo(appName) {
881
+ const app = await this.platformClient.getApp(appName);
882
+ if (!app) {
883
+ throw new Error(`App '${appName}' not found`);
884
+ }
885
+ const primaryNode = app.nodes.find((n) => n.isPrimary);
886
+ if (!primaryNode?.ipv6) {
887
+ throw new Error(`App '${appName}' has no primary node with IPv6 address`);
888
+ }
889
+ const vmIp = primaryNode.ipv6;
890
+ await this.certManager.getSession(appName, vmIp);
891
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
892
+ if (canConnectDirect) {
893
+ const viaVPN = isRoutedViaVPN(vmIp);
894
+ return {
895
+ url: `ssh://root@${vmIp}`,
896
+ vmIp,
897
+ viaGateway: false,
898
+ viaVPN
899
+ };
900
+ }
901
+ const vpnAvailable = isWireGuardInstalled();
902
+ if (vpnAvailable) {
903
+ console.log(chalk3.yellow("Direct IPv6 not available, trying VPN..."));
904
+ try {
905
+ if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
906
+ console.log(chalk3.green("\u2713 VPN already connected"));
907
+ return {
908
+ url: `ssh://root@${vmIp}`,
909
+ vmIp,
910
+ viaGateway: false,
911
+ viaVPN: true
912
+ };
1002
913
  }
914
+ this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
915
+ vmIp,
916
+ async () => this.getVPNConfig(app.location)
917
+ );
918
+ if (testIPv6Connectivity2(vmIp, 5)) {
919
+ console.log(chalk3.green("\u2713 Connected via VPN"));
920
+ return {
921
+ url: `ssh://root@${vmIp}`,
922
+ vmIp,
923
+ viaGateway: false,
924
+ viaVPN: true
925
+ };
926
+ }
927
+ } catch (error) {
928
+ console.log(chalk3.yellow(`VPN setup failed: ${error.message}`));
929
+ console.log(chalk3.dim("Falling back to SSH tunnel..."));
1003
930
  }
1004
- return false;
931
+ } else {
932
+ console.log(chalk3.yellow("Direct IPv6 not available on your network."));
933
+ console.log(chalk3.dim("For faster deploys, install WireGuard:"));
934
+ console.log(chalk3.dim(getWireGuardInstallInstructions()));
935
+ console.log();
1005
936
  }
1006
- return false;
1007
- }
1008
- /**
1009
- * Check if platform is supported for hackerrun
1010
- */
1011
- static isSupported() {
1012
- return platform() === "darwin" || platform() === "linux" || this.isWSL();
1013
- }
1014
- /**
1015
- * Get platform name for display
1016
- */
1017
- static getPlatformName() {
1018
- if (this.isWSL()) return "WSL (Windows Subsystem for Linux)";
1019
- if (platform() === "win32") return "Windows";
1020
- if (platform() === "darwin") return "macOS";
1021
- if (platform() === "linux") return "Linux";
1022
- return platform();
937
+ console.log(chalk3.yellow("Using SSH tunnel (may be slower for large transfers)..."));
938
+ const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
939
+ return {
940
+ url: `ssh://root@localhost:${tunnelInfo.localPort}`,
941
+ vmIp,
942
+ viaGateway: true,
943
+ viaVPN: false,
944
+ localPort: tunnelInfo.localPort
945
+ };
1023
946
  }
1024
947
  /**
1025
- * Ensure platform is supported, exit with helpful message if not
948
+ * Get VPN configuration from platform
1026
949
  */
1027
- static ensureSupported() {
1028
- if (this.isWindows() && !this.isWSL()) {
1029
- console.log(chalk3.yellow("\n\u26A0\uFE0F Windows detected\n"));
1030
- console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
1031
- console.log("Uncloud and SSH tools work best in a Linux environment.\n");
1032
- console.log(chalk3.cyan("How to set up WSL:\n"));
1033
- console.log("1. Open PowerShell as Administrator and run:");
1034
- console.log(chalk3.bold(" wsl --install\n"));
1035
- console.log("2. Restart your computer\n");
1036
- console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
1037
- console.log("4. Open WSL terminal and install hackerrun:");
1038
- console.log(chalk3.bold(" npm install -g hackerrun\n"));
1039
- console.log("Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n");
1040
- console.log(chalk3.red("Please install WSL and run hackerrun from WSL terminal.\n"));
1041
- process.exit(1);
1042
- }
1043
- if (!this.isSupported()) {
1044
- console.log(chalk3.red(`
1045
- \u274C Platform '${platform()}' is not supported
1046
- `));
1047
- console.log("Hackerrun supports:");
1048
- console.log(" - macOS");
1049
- console.log(" - Linux");
1050
- console.log(" - Windows (via WSL)\n");
1051
- process.exit(1);
1052
- }
950
+ async getVPNConfig(location) {
951
+ const { privateKey, publicKey } = getOrCreateKeyPair();
952
+ const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
953
+ return {
954
+ privateKey,
955
+ publicKey,
956
+ address: vpnConfigResponse.address,
957
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
958
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
959
+ allowedIPs: vpnConfigResponse.allowedIPs
960
+ };
1053
961
  }
1054
- };
1055
-
1056
- // src/lib/uncloud.ts
1057
- import { execSync as execSync3 } from "child_process";
1058
- import { platform as platform2 } from "os";
1059
- import chalk4 from "chalk";
1060
- import ora3 from "ora";
1061
- var UncloudManager = class {
1062
962
  /**
1063
- * Check if uncloud CLI is installed
963
+ * Pre-accept a host's SSH key by running ssh-keyscan
964
+ * This prevents "Host key verification failed" errors from uncloud
1064
965
  */
1065
- static isInstalled() {
966
+ preAcceptHostKey(host, port) {
1066
967
  try {
1067
- execSync3("uc --version", { stdio: "pipe" });
1068
- return true;
968
+ const portArg = port ? `-p ${port}` : "";
969
+ execSync3(
970
+ `ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
971
+ { stdio: "pipe", timeout: 1e4 }
972
+ );
1069
973
  } catch {
1070
- return false;
1071
974
  }
1072
975
  }
1073
976
  /**
1074
- * Get uncloud version
977
+ * Ensure an SSH tunnel exists for gateway fallback
1075
978
  */
1076
- static getVersion() {
1077
- try {
1078
- const version = execSync3("uc --version", { encoding: "utf-8" }).trim();
1079
- return version;
1080
- } catch {
1081
- return null;
979
+ async ensureTunnel(appName, vmIp, location) {
980
+ const gateway = await this.getGateway(location);
981
+ if (!gateway) {
982
+ throw new Error(`No gateway found for location ${location}`);
1082
983
  }
984
+ return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
1083
985
  }
1084
986
  /**
1085
- * Check if uncloud is installed, offer to install if not
1086
- * @param options.nonInteractive - If true, fail immediately without prompting (for CI/CD)
987
+ * Run an uncloud command on the app's VM
1087
988
  */
1088
- static async ensureInstalled(options) {
1089
- if (this.isInstalled()) {
1090
- return;
1091
- }
1092
- if (options?.nonInteractive) {
1093
- console.error(chalk4.red("\n\u274C Uncloud CLI (uc) not found\n"));
1094
- console.error("The uncloud CLI must be installed on the build VM.");
1095
- console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
1096
- process.exit(1);
989
+ async run(appName, command, args = [], options = {}) {
990
+ const connInfo = await this.getConnectionInfo(appName);
991
+ if (connInfo.viaGateway) {
992
+ console.log(chalk3.dim(`Connecting via gateway...`));
1097
993
  }
1098
- console.log(chalk4.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
1099
- console.log("Uncloud is required to deploy and manage your apps.");
1100
- console.log("Learn more: https://uncloud.run\n");
1101
- const os = platform2();
1102
- if (os === "darwin" || os === "linux") {
1103
- this.showInstallInstructions();
1104
- console.log(chalk4.cyan("Would you like to install uncloud now? (Y/n): "));
1105
- const answer = await this.promptUser();
1106
- if (answer === "y" || answer === "yes" || answer === "") {
1107
- try {
1108
- await this.autoInstall();
1109
- return;
1110
- } catch (error) {
1111
- console.log(chalk4.red("\nAuto-installation failed. Please install manually.\n"));
1112
- process.exit(1);
1113
- }
1114
- } else {
1115
- console.log(chalk4.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
1116
- process.exit(1);
994
+ const fullArgs = ["--connect", connInfo.url, command, ...args];
995
+ if (options.stdio === "inherit") {
996
+ const result = spawnSync3("uc", fullArgs, {
997
+ cwd: options.cwd,
998
+ stdio: "inherit",
999
+ timeout: options.timeout
1000
+ });
1001
+ if (result.status !== 0) {
1002
+ throw new Error(`Uncloud command failed with exit code ${result.status}`);
1117
1003
  }
1118
1004
  } else {
1119
- this.showInstallInstructions();
1120
- console.log(chalk4.red("Please install uncloud and run hackerrun again.\n"));
1121
- process.exit(1);
1122
- }
1123
- }
1124
- /**
1125
- * Show installation instructions based on platform
1126
- */
1127
- static showInstallInstructions() {
1128
- const os = platform2();
1129
- console.log(chalk4.cyan("Installation instructions:\n"));
1130
- if (os === "darwin" || os === "linux") {
1131
- console.log(chalk4.bold("Option 1: Automated install (Recommended)"));
1132
- console.log(chalk4.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1133
- console.log(chalk4.bold("Option 2: Homebrew"));
1134
- console.log(chalk4.green(" brew install psviderski/tap/uncloud\n"));
1135
- console.log(chalk4.bold("Option 3: Manual download"));
1136
- console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
1137
- console.log(" Extract and move to /usr/local/bin\n");
1138
- } else if (os === "win32") {
1139
- console.log(chalk4.bold("For Windows (via WSL):"));
1140
- console.log(" Open your WSL terminal and run:");
1141
- console.log(chalk4.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1005
+ const result = execSync3(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
1006
+ cwd: options.cwd,
1007
+ encoding: "utf-8",
1008
+ timeout: options.timeout
1009
+ });
1010
+ return result;
1142
1011
  }
1143
- console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
1144
- }
1145
- /**
1146
- * Prompt user for input from stdin
1147
- */
1148
- static async promptUser() {
1149
- return new Promise((resolve) => {
1150
- process.stdin.resume();
1151
- process.stdin.setEncoding("utf8");
1152
- const onData = (input) => {
1153
- process.stdin.pause();
1154
- process.stdin.removeListener("data", onData);
1155
- resolve(input.trim().toLowerCase());
1156
- };
1157
- process.stdin.on("data", onData);
1158
- });
1159
1012
  }
1160
1013
  /**
1161
- * Attempt to auto-install uncloud with user confirmation
1014
+ * Run 'uc deploy' for an app
1162
1015
  */
1163
- static async autoInstall() {
1164
- const spinner = ora3("Installing uncloud...").start();
1165
- try {
1166
- execSync3("curl -fsS https://get.uncloud.run/install.sh | sh", {
1167
- stdio: "inherit"
1168
- });
1169
- spinner.succeed(chalk4.green("Uncloud installed successfully!"));
1170
- if (this.isInstalled()) {
1171
- const version = this.getVersion();
1172
- console.log(chalk4.cyan(`\u2713 Uncloud ${version} is ready to use
1173
- `));
1174
- } else {
1175
- spinner.warn(chalk4.yellow("Installation completed but uc command not found in PATH"));
1176
- console.log(chalk4.yellow("\nYou may need to:"));
1177
- console.log(" 1. Restart your terminal");
1178
- console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
1179
- throw new Error("Please restart your terminal and try again");
1180
- }
1181
- } catch (error) {
1182
- spinner.fail(chalk4.red("Failed to install uncloud"));
1183
- console.log(chalk4.red(`
1184
- Error: ${error.message}
1185
- `));
1186
- console.log(chalk4.yellow("Please try manual installation:"));
1187
- console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
1188
- throw error;
1189
- }
1190
- }
1191
- };
1192
-
1193
- // src/lib/platform-auth.ts
1194
- import chalk5 from "chalk";
1195
- function getPlatformToken() {
1196
- const configManager = new ConfigManager();
1197
- try {
1198
- const config = configManager.load();
1199
- return config.apiToken;
1200
- } catch (error) {
1201
- console.error(chalk5.red("\n Not logged in"));
1202
- console.log(chalk5.cyan("\nPlease login first:\n"));
1203
- console.log(` ${chalk5.bold("hackerrun login")}
1204
- `);
1205
- process.exit(1);
1206
- }
1207
- }
1208
-
1209
- // src/lib/platform-client.ts
1210
- var PLATFORM_API_URL2 = process.env.HACKERRUN_API_URL || "http://localhost:3000";
1211
- var DEFAULT_RETRIES = 3;
1212
- var RETRY_DELAY_MS = 2e3;
1213
- function sleep2(ms) {
1214
- return new Promise((resolve) => setTimeout(resolve, ms));
1215
- }
1216
- function isRetryableError(error, status) {
1217
- if (error?.cause?.code === "ECONNREFUSED" || error?.cause?.code === "ECONNRESET" || error?.cause?.code === "ETIMEDOUT" || error?.message?.includes("fetch failed") || error?.message?.includes("network") || error?.message?.includes("ECONNREFUSED")) {
1218
- return true;
1219
- }
1220
- if (status && status >= 500) {
1221
- return true;
1222
- }
1223
- return false;
1224
- }
1225
- var PlatformClient = class {
1226
- constructor(authToken) {
1227
- this.authToken = authToken;
1228
- }
1229
- async request(method, path, body, options = {}) {
1230
- const {
1231
- retries = DEFAULT_RETRIES,
1232
- retryDelay = RETRY_DELAY_MS,
1233
- operation = `${method} ${path}`
1234
- } = options;
1235
- const url = `${PLATFORM_API_URL2}${path}`;
1236
- let lastError = null;
1237
- for (let attempt = 1; attempt <= retries; attempt++) {
1238
- try {
1239
- const response = await fetch(url, {
1240
- method,
1241
- headers: {
1242
- Authorization: `Bearer ${this.authToken}`,
1243
- "Content-Type": "application/json"
1244
- },
1245
- body: body ? JSON.stringify(body) : void 0
1246
- });
1247
- if (!response.ok) {
1248
- const errorText = await response.text();
1249
- let errorMessage;
1250
- try {
1251
- const errorJson = JSON.parse(errorText);
1252
- errorMessage = errorJson.error || response.statusText;
1253
- } catch {
1254
- errorMessage = errorText || response.statusText;
1255
- }
1256
- const error = new Error(`API error (${response.status}): ${errorMessage}`);
1257
- if (isRetryableError(error, response.status) && attempt < retries) {
1258
- lastError = error;
1259
- await sleep2(retryDelay * attempt);
1260
- continue;
1261
- }
1262
- throw error;
1263
- }
1264
- return response.json();
1265
- } catch (error) {
1266
- lastError = error;
1267
- if (isRetryableError(error) && attempt < retries) {
1268
- await sleep2(retryDelay * attempt);
1269
- continue;
1270
- }
1271
- const errorMessage = error.message || "Unknown error";
1272
- throw new Error(`${operation} failed: ${errorMessage}`);
1273
- }
1016
+ async deploy(appName, cwd) {
1017
+ await this.run(appName, "deploy", ["--yes"], { cwd, stdio: "inherit" });
1018
+ }
1019
+ /**
1020
+ * Run 'uc service logs' for an app
1021
+ */
1022
+ async logs(appName, serviceName, options = {}) {
1023
+ const args = [serviceName];
1024
+ if (options.follow) args.push("-f");
1025
+ if (options.tail) args.push("--tail", String(options.tail));
1026
+ await this.run(appName, "service", ["logs", ...args], { stdio: "inherit" });
1027
+ }
1028
+ /**
1029
+ * Run 'uc service ls' for an app
1030
+ */
1031
+ async serviceList(appName) {
1032
+ const result = await this.run(appName, "service", ["ls"], { stdio: "pipe" });
1033
+ return result;
1034
+ }
1035
+ /**
1036
+ * Run 'uc machine token' on remote VM via SSH
1037
+ * This is different - it runs on the VM directly, not via uncloud connector
1038
+ */
1039
+ async getMachineToken(appName) {
1040
+ const connInfo = await this.getConnectionInfo(appName);
1041
+ let sshCmd2;
1042
+ if (connInfo.viaGateway && connInfo.localPort) {
1043
+ sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
1044
+ } else {
1045
+ sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
1274
1046
  }
1275
- throw lastError || new Error(`${operation} failed after ${retries} attempts`);
1047
+ const token = execSync3(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
1048
+ return token;
1276
1049
  }
1277
- // ==================== App State Management ====================
1278
1050
  /**
1279
- * List all apps for the authenticated user
1051
+ * Execute an SSH command on the VM
1280
1052
  */
1281
- async listApps() {
1282
- const { apps } = await this.request("GET", "/api/apps");
1283
- return apps;
1053
+ async sshExec(appName, command) {
1054
+ const connInfo = await this.getConnectionInfo(appName);
1055
+ let sshCmd2;
1056
+ if (connInfo.viaGateway && connInfo.localPort) {
1057
+ sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
1058
+ } else {
1059
+ sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
1060
+ }
1061
+ return execSync3(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
1284
1062
  }
1285
1063
  /**
1286
- * Get a specific app by name
1064
+ * Remove a node from the uncloud cluster
1065
+ * Uses 'uc machine rm' to drain containers and remove from cluster
1287
1066
  */
1288
- async getApp(appName) {
1289
- try {
1290
- const { app } = await this.request(
1291
- "GET",
1292
- `/api/apps/${appName}`,
1293
- void 0,
1294
- { operation: `Get app '${appName}'` }
1295
- );
1296
- return app;
1297
- } catch (error) {
1298
- if (error.message.includes("404") || error.message.includes("not found")) {
1299
- return null;
1300
- }
1301
- throw error;
1067
+ async removeNode(appName, nodeName) {
1068
+ const connInfo = await this.getConnectionInfo(appName);
1069
+ const result = spawnSync3("uc", ["--connect", connInfo.url, "machine", "rm", nodeName, "--yes"], {
1070
+ stdio: "inherit",
1071
+ timeout: 3e5
1072
+ // 5 minute timeout for container drainage
1073
+ });
1074
+ if (result.status !== 0) {
1075
+ throw new Error(`Failed to remove node '${nodeName}' from cluster (exit code ${result.status})`);
1302
1076
  }
1303
1077
  }
1304
1078
  /**
1305
- * Create or update an app
1306
- * Returns the app with auto-generated domainName
1079
+ * List machines in the uncloud cluster
1307
1080
  */
1308
- async saveApp(cluster) {
1309
- const { app } = await this.request(
1310
- "POST",
1311
- "/api/apps",
1312
- {
1313
- appName: cluster.appName,
1314
- location: cluster.location,
1315
- uncloudContext: cluster.uncloudContext,
1316
- nodes: cluster.nodes.map((node) => ({
1317
- name: node.name,
1318
- id: node.id,
1319
- ipv4: node.ipv4,
1320
- ipv6: node.ipv6,
1321
- isPrimary: node.isPrimary
1322
- }))
1323
- },
1324
- { operation: `Save app '${cluster.appName}'` }
1325
- );
1326
- return app;
1081
+ async listMachines(appName) {
1082
+ const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
1083
+ return result;
1327
1084
  }
1328
1085
  /**
1329
- * Update app's last deployed timestamp
1086
+ * Get gateway info (cached)
1330
1087
  */
1331
- async updateLastDeployed(appName) {
1332
- await this.request("PATCH", `/api/apps/${appName}`, {
1333
- lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
1334
- }, { operation: `Update last deployed for '${appName}'` });
1088
+ async getGateway(location) {
1089
+ if (this.gatewayCache.has(location)) {
1090
+ return this.gatewayCache.get(location) || null;
1091
+ }
1092
+ const gateway = await this.platformClient.getGateway(location);
1093
+ if (gateway) {
1094
+ this.gatewayCache.set(location, { ipv4: gateway.ipv4, ipv6: gateway.ipv6 });
1095
+ return { ipv4: gateway.ipv4, ipv6: gateway.ipv6 };
1096
+ }
1097
+ this.gatewayCache.set(location, null);
1098
+ return null;
1335
1099
  }
1336
1100
  /**
1337
- * Rename an app (per-user unique)
1101
+ * Clean up all SSH sessions, tunnels, and VPN
1338
1102
  */
1339
- async renameApp(currentAppName, newAppName) {
1340
- const { app } = await this.request("PATCH", `/api/apps/${currentAppName}`, {
1341
- newAppName
1342
- });
1343
- return app;
1103
+ cleanup() {
1104
+ this.tunnelManager.closeAll();
1105
+ this.certManager.cleanupAll();
1106
+ if (this.vpnEstablishedByUs) {
1107
+ this.vpnManager.disconnect();
1108
+ this.vpnEstablishedByUs = false;
1109
+ }
1344
1110
  }
1111
+ };
1112
+
1113
+ // src/lib/cluster.ts
1114
+ import { execSync as execSync4 } from "child_process";
1115
+ import ora2 from "ora";
1116
+ import chalk4 from "chalk";
1117
+ var platformKeysCache = null;
1118
+ var ClusterManager = class {
1119
+ constructor(platformClient, uncloudRunner) {
1120
+ this.platformClient = platformClient;
1121
+ this.sshCertManager = new SSHCertManager(platformClient);
1122
+ this.uncloudRunner = uncloudRunner || new UncloudRunner(platformClient);
1123
+ }
1124
+ sshCertManager;
1125
+ uncloudRunner;
1345
1126
  /**
1346
- * Rename app's domain (globally unique)
1127
+ * Get platform SSH keys (cached for the session)
1128
+ * Returns CA public key and platform public key for VM creation
1347
1129
  */
1348
- async renameDomain(appName, newDomainName) {
1349
- const { app } = await this.request("PATCH", `/api/apps/${appName}`, {
1350
- newDomainName
1351
- });
1352
- return app;
1130
+ async getPlatformKeys() {
1131
+ if (platformKeysCache) {
1132
+ return platformKeysCache;
1133
+ }
1134
+ try {
1135
+ platformKeysCache = await this.platformClient.getPlatformSSHKeys();
1136
+ return platformKeysCache;
1137
+ } catch (error) {
1138
+ throw new Error(`Failed to get platform SSH keys: ${error.message}`);
1139
+ }
1353
1140
  }
1354
1141
  /**
1355
- * Delete an app
1142
+ * Initialize a new cluster for an app (creates first VM and sets up uncloud)
1143
+ *
1144
+ * Flow:
1145
+ * 1. Create VM with platform SSH key
1146
+ * 2. Wait for VM to get IPv6 address
1147
+ * 3. Call platform API to setup VM (DNS64, WireGuard, SSH CA)
1148
+ * 4. Run `uc machine init` to install Docker + uncloud
1149
+ * 5. Configure Docker for NAT64
1150
+ * 6. Save app state to platform
1151
+ *
1152
+ * Users access VMs via SSH certificates signed by the platform CA.
1356
1153
  */
1357
- async deleteApp(appName) {
1358
- await this.request("DELETE", `/api/apps/${appName}`, void 0, { operation: `Delete app '${appName}'` });
1154
+ async initializeCluster(options) {
1155
+ const { appName, location, vmSize, storageSize, bootImage } = options;
1156
+ let spinner = ora2(`Creating VM for '${appName}' in ${location}...`).start();
1157
+ try {
1158
+ spinner.text = "Fetching platform SSH keys...";
1159
+ const platformKeys = await this.getPlatformKeys();
1160
+ const gateway = await this.platformClient.getGateway(location);
1161
+ const privateSubnetId = gateway?.subnetId;
1162
+ spinner.text = "Creating VM...";
1163
+ const vm = await this.platformClient.createAppVM(appName, {
1164
+ location,
1165
+ size: vmSize,
1166
+ storage_size: storageSize,
1167
+ boot_image: bootImage,
1168
+ unix_user: "root",
1169
+ public_key: platformKeys.platformPublicKey,
1170
+ enable_ip4: !privateSubnetId,
1171
+ // IPv6-only if we have a subnet for NAT64
1172
+ private_subnet_id: privateSubnetId,
1173
+ isPrimary: true
1174
+ });
1175
+ const vmName = vm.name;
1176
+ spinner.text = `Waiting for VM '${vmName}' to be ready...`;
1177
+ spinner.stop();
1178
+ console.log(chalk4.cyan("\nWaiting for VM to get an IPv6 address..."));
1179
+ const vmWithIp = await this.waitForVM(location, vmName, 600, false);
1180
+ spinner = ora2("Setting up VM...").start();
1181
+ spinner.text = "Waiting for SSH to be ready...";
1182
+ await this.sleep(3e4);
1183
+ spinner.text = "Configuring VM (DNS64, NAT64, SSH CA)...";
1184
+ await this.platformClient.setupVM(vmWithIp.ip6, location, appName);
1185
+ const cluster = {
1186
+ appName,
1187
+ location,
1188
+ nodes: [{
1189
+ name: vmName,
1190
+ id: vm.id,
1191
+ ipv6: vmWithIp.ip6,
1192
+ isPrimary: true
1193
+ }],
1194
+ uncloudContext: appName,
1195
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1196
+ };
1197
+ spinner.text = "Registering app...";
1198
+ const savedCluster = await this.platformClient.saveApp(cluster);
1199
+ spinner.text = "Installing Docker and Uncloud...";
1200
+ spinner.stop();
1201
+ console.log(chalk4.cyan("\nInitializing uncloud (this may take a few minutes)..."));
1202
+ await this.initializeUncloud(vmWithIp.ip6, appName, gateway?.ipv4);
1203
+ spinner = ora2("Configuring Docker for NAT64...").start();
1204
+ await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
1205
+ spinner.text = "Waiting for uncloud services to be ready...";
1206
+ await this.waitForUncloudReady(vmWithIp.ip6, gateway?.ipv4);
1207
+ this.sshCertManager.cleanupAll();
1208
+ spinner.succeed(chalk4.green(`Cluster initialized successfully`));
1209
+ return savedCluster;
1210
+ } catch (error) {
1211
+ spinner.fail(chalk4.red("Failed to initialize cluster"));
1212
+ throw error;
1213
+ }
1359
1214
  }
1360
1215
  /**
1361
- * Check if app exists
1216
+ * Add a node to an existing cluster
1362
1217
  */
1363
- async hasApp(appName) {
1364
- const app = await this.getApp(appName);
1365
- return app !== null;
1218
+ async addNode(appName, vmSize, bootImage) {
1219
+ const cluster = await this.platformClient.getApp(appName);
1220
+ if (!cluster) {
1221
+ throw new Error(`App '${appName}' not found`);
1222
+ }
1223
+ const existingNode = cluster.nodes.find((n) => n.ipv6);
1224
+ if (!existingNode || !existingNode.ipv6) {
1225
+ throw new Error(`No existing node with IPv6 address found to join`);
1226
+ }
1227
+ const gateway = await this.platformClient.getGateway(cluster.location);
1228
+ const privateSubnetId = gateway?.subnetId;
1229
+ let spinner = ora2("Adding node to cluster...").start();
1230
+ try {
1231
+ spinner.text = "Fetching platform SSH keys...";
1232
+ const platformKeys = await this.getPlatformKeys();
1233
+ spinner.text = "Creating VM...";
1234
+ const vm = await this.platformClient.createAppVM(appName, {
1235
+ location: cluster.location,
1236
+ size: vmSize,
1237
+ boot_image: bootImage,
1238
+ unix_user: "root",
1239
+ public_key: platformKeys.platformPublicKey,
1240
+ enable_ip4: !privateSubnetId,
1241
+ private_subnet_id: privateSubnetId,
1242
+ isPrimary: false
1243
+ });
1244
+ const vmName = vm.name;
1245
+ spinner.text = `Waiting for VM '${vmName}' to be ready...`;
1246
+ spinner.stop();
1247
+ console.log(chalk4.cyan("\nWaiting for VM to get an IPv6 address..."));
1248
+ const vmWithIp = await this.waitForVM(cluster.location, vmName, 600, false);
1249
+ spinner = ora2("Setting up VM...").start();
1250
+ await this.sleep(3e4);
1251
+ spinner.text = "Configuring VM...";
1252
+ await this.platformClient.setupVM(vmWithIp.ip6, cluster.location, appName);
1253
+ spinner.text = "Joining uncloud cluster...";
1254
+ spinner.stop();
1255
+ console.log(chalk4.cyan("\nJoining uncloud cluster..."));
1256
+ await this.joinUncloudCluster(vmWithIp.ip6, appName);
1257
+ spinner = ora2("Configuring Docker for NAT64...").start();
1258
+ await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
1259
+ spinner.succeed(chalk4.green(`Node '${vmName}' added successfully`));
1260
+ const newNode = {
1261
+ name: vmName,
1262
+ id: vm.id,
1263
+ ipv6: vmWithIp.ip6,
1264
+ isPrimary: false
1265
+ };
1266
+ cluster.nodes.push(newNode);
1267
+ await this.platformClient.saveApp(cluster);
1268
+ return newNode;
1269
+ } catch (error) {
1270
+ spinner.fail(chalk4.red("Failed to add node"));
1271
+ throw error;
1272
+ }
1366
1273
  }
1367
1274
  /**
1368
- * Get primary node for an app
1275
+ * Initialize uncloud on the VM using uc machine init
1276
+ * Uses --no-dns because we manage our own domain via gateway
1277
+ *
1278
+ * Before running uc, we get an SSH certificate from the platform
1279
+ * and add it to the ssh-agent. This allows uc to authenticate
1280
+ * since the VM only accepts platform SSH key or signed certificates.
1281
+ *
1282
+ * If direct IPv6 connectivity is not available, tunnels through the gateway.
1369
1283
  */
1370
- async getPrimaryNode(appName) {
1371
- const app = await this.getApp(appName);
1372
- return app?.nodes.find((node) => node.isPrimary) || null;
1284
+ async initializeUncloud(vmIp, contextName, gatewayIp) {
1285
+ let tunnel = null;
1286
+ let targetHost = vmIp;
1287
+ try {
1288
+ await this.sshCertManager.getSession(contextName, vmIp);
1289
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
1290
+ if (!canConnectDirect) {
1291
+ if (!gatewayIp) {
1292
+ throw new Error("No direct IPv6 connectivity and no gateway available");
1293
+ }
1294
+ console.log(chalk4.dim("No direct IPv6 connectivity, tunneling through gateway..."));
1295
+ tunnel = await createTunnel(vmIp, gatewayIp);
1296
+ targetHost = `localhost:${tunnel.localPort}`;
1297
+ }
1298
+ let initSucceeded = false;
1299
+ try {
1300
+ execSync4(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
1301
+ stdio: "inherit",
1302
+ timeout: 6e5
1303
+ // 10 min timeout
1304
+ });
1305
+ initSucceeded = true;
1306
+ } catch (error) {
1307
+ console.log(chalk4.yellow("\nInitial setup had errors, will attempt to recover..."));
1308
+ }
1309
+ if (!initSucceeded) {
1310
+ const maxCaddyAttempts = 5;
1311
+ for (let attempt = 1; attempt <= maxCaddyAttempts; attempt++) {
1312
+ console.log(chalk4.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
1313
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
1314
+ try {
1315
+ execSync4(`yes | uc -c "${contextName}" caddy deploy`, {
1316
+ stdio: "inherit",
1317
+ timeout: 12e4
1318
+ });
1319
+ console.log(chalk4.green("Caddy deployed successfully"));
1320
+ break;
1321
+ } catch (caddyError) {
1322
+ if (attempt >= maxCaddyAttempts) {
1323
+ throw new Error(`Caddy deployment failed after ${maxCaddyAttempts} attempts. The machine may need more time to initialize.`);
1324
+ }
1325
+ }
1326
+ }
1327
+ }
1328
+ console.log(chalk4.dim("Uncloud initialization complete."));
1329
+ } catch (error) {
1330
+ throw new Error(`Failed to initialize uncloud: ${error.message}`);
1331
+ } finally {
1332
+ if (tunnel) {
1333
+ killTunnel(tunnel);
1334
+ }
1335
+ }
1373
1336
  }
1374
- // ==================== VM Operations (Proxied to Ubicloud) ====================
1375
1337
  /**
1376
- * List all VMs for the user's Ubicloud project
1338
+ * Join a new node to an existing uncloud cluster
1339
+ * Uses `uc machine add` which handles:
1340
+ * - SSH to the new machine
1341
+ * - Installing uncloudd if needed
1342
+ * - Getting token from new machine via gRPC
1343
+ * - Registering machine in cluster
1377
1344
  */
1378
- async listVMs() {
1379
- const { vms } = await this.request("GET", "/api/vms");
1380
- return vms;
1345
+ async joinUncloudCluster(newVmIp, appName) {
1346
+ try {
1347
+ await this.uncloudRunner.run(appName, "machine", ["add", `root@${newVmIp}`, "--no-caddy"], {
1348
+ stdio: "inherit",
1349
+ timeout: 6e5
1350
+ // 10 min timeout
1351
+ });
1352
+ } catch (error) {
1353
+ throw new Error(`Failed to join cluster: ${error.message}`);
1354
+ }
1381
1355
  }
1382
1356
  /**
1383
- * Create a new VM
1357
+ * Configure Docker for NAT64 support on IPv6-only VMs
1358
+ *
1359
+ * Problem: DNS64 returns both A (IPv4) and AAAA (IPv6) records. Applications may try
1360
+ * IPv4 first, which times out because there's no route to IPv4 internet.
1361
+ *
1362
+ * Solution:
1363
+ * 1. Enable IPv6 on Docker networks so containers get IPv6 addresses
1364
+ * 2. Block IPv4 forwarding from Docker to internet so IPv4 fails immediately
1365
+ * 3. Applications then use IPv6 (NAT64) which works via the gateway
1366
+ *
1367
+ * If direct IPv6 connectivity is not available, tunnels through the gateway.
1384
1368
  */
1385
- async createVM(params) {
1386
- const { vm } = await this.request("POST", "/api/vms", params, { operation: `Create VM '${params.name}'` });
1387
- return vm;
1369
+ async configureDockerNAT64(vmIp, gatewayIp) {
1370
+ const setupScript = `#!/bin/bash
1371
+ set -e
1372
+
1373
+ # Step 1: Add IPv6 support to Docker daemon config
1374
+ DAEMON_JSON='/etc/docker/daemon.json'
1375
+ if [ -f "$DAEMON_JSON" ]; then
1376
+ # Merge with existing config using jq
1377
+ jq '. + {"ipv6": true, "fixed-cidr-v6": "fd00:d0c6:e4::/64"}' "$DAEMON_JSON" > /tmp/daemon.json
1378
+ mv /tmp/daemon.json "$DAEMON_JSON"
1379
+ else
1380
+ cat > "$DAEMON_JSON" << 'EOFJSON'
1381
+ {
1382
+ "ipv6": true,
1383
+ "fixed-cidr-v6": "fd00:d0c6:e4::/64"
1384
+ }
1385
+ EOFJSON
1386
+ fi
1387
+
1388
+ # Step 2: Create systemd service to block IPv4 forwarding from Docker containers
1389
+ cat > /etc/systemd/system/docker-ipv6-nat64.service << 'EOFSVC'
1390
+ [Unit]
1391
+ Description=Block IPv4 forwarding from Docker containers (force NAT64)
1392
+ After=docker.service
1393
+ Requires=docker.service
1394
+
1395
+ [Service]
1396
+ Type=oneshot
1397
+ RemainAfterExit=yes
1398
+ ExecStart=/sbin/iptables -I FORWARD -i br-+ -o ens3 -j REJECT --reject-with icmp-net-unreachable
1399
+ ExecStop=/sbin/iptables -D FORWARD -i br-+ -o ens3 -j REJECT --reject-with icmp-net-unreachable
1400
+
1401
+ [Install]
1402
+ WantedBy=multi-user.target
1403
+ EOFSVC
1404
+
1405
+ systemctl daemon-reload
1406
+ systemctl enable docker-ipv6-nat64
1407
+
1408
+ # Step 3: Restart Docker to apply IPv6 config
1409
+ systemctl restart docker
1410
+ sleep 3
1411
+
1412
+ # Step 4: Recreate uncloud network with IPv6 enabled
1413
+ CONTAINERS=$(docker ps -aq --filter network=uncloud 2>/dev/null || true)
1414
+
1415
+ for container in $CONTAINERS; do
1416
+ docker network disconnect uncloud "$container" 2>/dev/null || true
1417
+ done
1418
+
1419
+ docker network rm uncloud 2>/dev/null || true
1420
+ docker network create --driver bridge --subnet 10.210.0.0/24 --gateway 10.210.0.1 --ipv6 --subnet fd00:a10:210::/64 --gateway fd00:a10:210::1 uncloud
1421
+
1422
+ for container in $CONTAINERS; do
1423
+ docker network connect uncloud "$container" 2>/dev/null || true
1424
+ done
1425
+
1426
+ # Step 5: Start the iptables service
1427
+ systemctl start docker-ipv6-nat64
1428
+
1429
+ echo "Docker NAT64 configuration complete"
1430
+ `;
1431
+ let tunnel = null;
1432
+ let sshHost = vmIp;
1433
+ let sshPortArgs = "";
1434
+ try {
1435
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
1436
+ if (!canConnectDirect && gatewayIp) {
1437
+ tunnel = await createTunnel(vmIp, gatewayIp);
1438
+ sshHost = "localhost";
1439
+ sshPortArgs = `-p ${tunnel.localPort}`;
1440
+ }
1441
+ execSync4(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
1442
+ ${setupScript}
1443
+ REMOTESCRIPT`, {
1444
+ stdio: "inherit",
1445
+ timeout: 12e4
1446
+ // 2 min timeout
1447
+ });
1448
+ } catch (error) {
1449
+ throw new Error(`Failed to configure Docker for NAT64: ${error.message}`);
1450
+ } finally {
1451
+ if (tunnel) {
1452
+ killTunnel(tunnel);
1453
+ }
1454
+ }
1388
1455
  }
1389
1456
  /**
1390
- * Get a specific VM
1457
+ * Wait for VM to have an IP address
1458
+ * @param requireIpv4 - If true, wait for IPv4 (for gateway VMs). Otherwise wait for IPv6.
1391
1459
  */
1392
- async getVM(location, vmName) {
1393
- const { vm } = await this.request(
1394
- "GET",
1395
- `/api/vms/${location}/${vmName}`,
1396
- void 0,
1397
- { operation: `Get VM '${vmName}'` }
1460
+ async waitForVM(location, vmName, timeoutSeconds, requireIpv4 = false) {
1461
+ const startTime = Date.now();
1462
+ const timeoutMs = timeoutSeconds * 1e3;
1463
+ let lastStatus = "";
1464
+ while (Date.now() - startTime < timeoutMs) {
1465
+ const vm = await this.platformClient.getVM(location, vmName);
1466
+ if (vm.status && vm.status !== lastStatus) {
1467
+ console.log(chalk4.dim(` Status: ${vm.status}`));
1468
+ lastStatus = vm.status;
1469
+ }
1470
+ const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
1471
+ if (hasRequiredIp) {
1472
+ const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
1473
+ console.log(chalk4.green(` IP assigned: ${ipDisplay}`));
1474
+ return vm;
1475
+ }
1476
+ const elapsed = Math.floor((Date.now() - startTime) / 1e3);
1477
+ const ipType = requireIpv4 ? "IPv4" : "IPv6";
1478
+ process.stdout.write(`\r Waiting for ${ipType}... (${elapsed}s / ${timeoutSeconds}s)`);
1479
+ await this.sleep(5e3);
1480
+ }
1481
+ process.stdout.write("\n");
1482
+ throw new Error(
1483
+ `Timeout waiting for VM to get IP address after ${timeoutSeconds}s.
1484
+
1485
+ The VM may still be provisioning. You can:
1486
+ 1. Check VM status: hackerrun vm list
1487
+ 2. Delete and retry: hackerrun vm delete ${vmName}
1488
+ 3. Check Ubicloud console: https://console.ubicloud.com`
1398
1489
  );
1399
- return vm;
1400
- }
1401
- /**
1402
- * Delete a VM
1403
- */
1404
- async deleteVM(location, vmName) {
1405
- await this.request("DELETE", `/api/vms/${location}/${vmName}`, void 0, { operation: `Delete VM '${vmName}'` });
1406
- }
1407
- // ==================== Uncloud Config Management ====================
1408
- /**
1409
- * Upload uncloud config to platform
1410
- */
1411
- async uploadUncloudConfig(configYaml) {
1412
- await this.request("POST", "/api/uncloud/config", { config: configYaml });
1413
1490
  }
1414
1491
  /**
1415
- * Download uncloud config from platform
1492
+ * Wait for uncloud services to be ready on the VM
1493
+ * Specifically checks that unregistry (Docker registry at 10.210.0.1:5000) is accepting connections
1494
+ * This is critical for first deploy - push will fail if unregistry isn't ready
1416
1495
  */
1417
- async downloadUncloudConfig() {
1418
- try {
1419
- const { config } = await this.request("GET", "/api/uncloud/config");
1420
- return config;
1421
- } catch (error) {
1422
- if (error.message.includes("404") || error.message.includes("not found")) {
1423
- return null;
1496
+ async waitForUncloudReady(vmIp, gatewayIp) {
1497
+ const maxAttempts = 30;
1498
+ const checkInterval = 2e3;
1499
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
1500
+ const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
1501
+ const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
1502
+ console.log(chalk4.dim(" Waiting for uncloud services..."));
1503
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1504
+ try {
1505
+ const result = execSync4(
1506
+ `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
1507
+ { encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
1508
+ ).trim();
1509
+ if (result.includes("ready")) {
1510
+ console.log(chalk4.dim(" Uncloud services ready"));
1511
+ return;
1512
+ }
1513
+ } catch (error) {
1424
1514
  }
1425
- throw error;
1426
- }
1427
- }
1428
- // ==================== Gateway Management ====================
1429
- /**
1430
- * Get gateway info for a location
1431
- */
1432
- async getGateway(location) {
1433
- try {
1434
- const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
1435
- return gateway;
1436
- } catch (error) {
1437
- if (error.message.includes("404") || error.message.includes("not found")) {
1438
- return null;
1515
+ if (attempt % 5 === 0) {
1516
+ console.log(chalk4.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
1517
+ }
1518
+ if (attempt < maxAttempts) {
1519
+ await this.sleep(checkInterval);
1439
1520
  }
1440
- throw error;
1441
1521
  }
1522
+ console.log(chalk4.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
1442
1523
  }
1443
- // ==================== Route Management ====================
1444
1524
  /**
1445
- * Register a route for an app (maps hostname to VM IPv6)
1525
+ * Sleep for specified milliseconds
1446
1526
  */
1447
- async registerRoute(appName, backendIpv6, backendPort = 443) {
1448
- const { route } = await this.request("POST", "/api/routes", {
1449
- appName,
1450
- backendIpv6,
1451
- backendPort
1452
- }, { operation: `Register route for '${appName}'` });
1453
- return route;
1527
+ sleep(ms) {
1528
+ return new Promise((resolve) => setTimeout(resolve, ms));
1454
1529
  }
1530
+ };
1531
+
1532
+ // src/lib/platform.ts
1533
+ import { platform as platform2 } from "os";
1534
+ import chalk5 from "chalk";
1535
+ var PlatformDetector = class {
1455
1536
  /**
1456
- * Delete routes for an app
1537
+ * Check if running on Windows
1457
1538
  */
1458
- async deleteRoute(appName) {
1459
- await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
1539
+ static isWindows() {
1540
+ return platform2() === "win32";
1460
1541
  }
1461
- // ==================== Gateway Route Sync ====================
1462
1542
  /**
1463
- * Sync gateway Caddy configuration for a location
1464
- * This updates the gateway's reverse proxy config with current routes
1543
+ * Check if running inside WSL (Windows Subsystem for Linux)
1465
1544
  */
1466
- async syncGatewayRoutes(location) {
1467
- const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
1468
- return { routeCount: result.routeCount };
1545
+ static isWSL() {
1546
+ if (!this.isWindows()) {
1547
+ if (platform2() === "linux") {
1548
+ try {
1549
+ const fs = __require("fs");
1550
+ const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
1551
+ return procVersion.includes("microsoft") || procVersion.includes("wsl");
1552
+ } catch {
1553
+ return false;
1554
+ }
1555
+ }
1556
+ return false;
1557
+ }
1558
+ return false;
1469
1559
  }
1470
- // ==================== SSH Certificate Management ====================
1471
1560
  /**
1472
- * Get platform SSH keys (CA public key + platform public key for VM creation)
1561
+ * Check if platform is supported for hackerrun
1473
1562
  */
1474
- async getPlatformSSHKeys() {
1475
- return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1563
+ static isSupported() {
1564
+ return platform2() === "darwin" || platform2() === "linux" || this.isWSL();
1476
1565
  }
1477
1566
  /**
1478
- * Initialize platform SSH keys (creates them if they don't exist)
1567
+ * Get platform name for display
1479
1568
  */
1480
- async initPlatformSSHKeys() {
1481
- return this.request("POST", "/api/platform/ssh-keys");
1569
+ static getPlatformName() {
1570
+ if (this.isWSL()) return "WSL (Windows Subsystem for Linux)";
1571
+ if (platform2() === "win32") return "Windows";
1572
+ if (platform2() === "darwin") return "macOS";
1573
+ if (platform2() === "linux") return "Linux";
1574
+ return platform2();
1482
1575
  }
1483
1576
  /**
1484
- * Request a signed SSH certificate for accessing an app's VMs
1485
- * Returns a short-lived certificate (5 min) signed by the platform CA
1577
+ * Ensure platform is supported, exit with helpful message if not
1486
1578
  */
1487
- async requestSSHCertificate(appName, publicKey) {
1488
- return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1579
+ static ensureSupported() {
1580
+ if (this.isWindows() && !this.isWSL()) {
1581
+ console.log(chalk5.yellow("\n\u26A0\uFE0F Windows detected\n"));
1582
+ console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
1583
+ console.log("Uncloud and SSH tools work best in a Linux environment.\n");
1584
+ console.log(chalk5.cyan("How to set up WSL:\n"));
1585
+ console.log("1. Open PowerShell as Administrator and run:");
1586
+ console.log(chalk5.bold(" wsl --install\n"));
1587
+ console.log("2. Restart your computer\n");
1588
+ console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
1589
+ console.log("4. Open WSL terminal and install hackerrun:");
1590
+ console.log(chalk5.bold(" npm install -g hackerrun\n"));
1591
+ console.log("Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n");
1592
+ console.log(chalk5.red("Please install WSL and run hackerrun from WSL terminal.\n"));
1593
+ process.exit(1);
1594
+ }
1595
+ if (!this.isSupported()) {
1596
+ console.log(chalk5.red(`
1597
+ \u274C Platform '${platform2()}' is not supported
1598
+ `));
1599
+ console.log("Hackerrun supports:");
1600
+ console.log(" - macOS");
1601
+ console.log(" - Linux");
1602
+ console.log(" - Windows (via WSL)\n");
1603
+ process.exit(1);
1604
+ }
1489
1605
  }
1490
- // ==================== VM Setup ====================
1606
+ };
1607
+
1608
+ // src/lib/uncloud.ts
1609
+ import { execSync as execSync5 } from "child_process";
1610
+ import { platform as platform3 } from "os";
1611
+ import chalk6 from "chalk";
1612
+ import ora3 from "ora";
1613
+ var UncloudManager = class {
1491
1614
  /**
1492
- * Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
1493
- * This runs all configuration using the platform SSH key
1615
+ * Check if uncloud CLI is installed
1494
1616
  */
1495
- async setupVM(vmIp, location, appName) {
1496
- await this.request("POST", "/api/vms/setup", {
1497
- vmIp,
1498
- location,
1499
- appName
1500
- }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
1617
+ static isInstalled() {
1618
+ try {
1619
+ execSync5("uc --version", { stdio: "pipe" });
1620
+ return true;
1621
+ } catch {
1622
+ return false;
1623
+ }
1501
1624
  }
1502
- // ==================== Environment Variables ====================
1503
1625
  /**
1504
- * Set a single environment variable
1626
+ * Get uncloud version
1505
1627
  */
1506
- async setEnvVar(appName, key, value) {
1507
- await this.request("POST", `/api/apps/${appName}/env`, { key, value });
1628
+ static getVersion() {
1629
+ try {
1630
+ const version = execSync5("uc --version", { encoding: "utf-8" }).trim();
1631
+ return version;
1632
+ } catch {
1633
+ return null;
1634
+ }
1508
1635
  }
1509
1636
  /**
1510
- * Set multiple environment variables at once
1637
+ * Check if uncloud is installed, offer to install if not
1638
+ * @param options.nonInteractive - If true, fail immediately without prompting (for CI/CD)
1511
1639
  */
1512
- async setEnvVars(appName, vars) {
1513
- await this.request("POST", `/api/apps/${appName}/env`, { vars });
1640
+ static async ensureInstalled(options) {
1641
+ if (this.isInstalled()) {
1642
+ return;
1643
+ }
1644
+ if (options?.nonInteractive) {
1645
+ console.error(chalk6.red("\n\u274C Uncloud CLI (uc) not found\n"));
1646
+ console.error("The uncloud CLI must be installed on the build VM.");
1647
+ console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
1648
+ process.exit(1);
1649
+ }
1650
+ console.log(chalk6.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
1651
+ console.log("Uncloud is required to deploy and manage your apps.");
1652
+ console.log("Learn more: https://uncloud.run\n");
1653
+ const os = platform3();
1654
+ if (os === "darwin" || os === "linux") {
1655
+ this.showInstallInstructions();
1656
+ console.log(chalk6.cyan("Would you like to install uncloud now? (Y/n): "));
1657
+ const answer = await this.promptUser();
1658
+ if (answer === "y" || answer === "yes" || answer === "") {
1659
+ try {
1660
+ await this.autoInstall();
1661
+ return;
1662
+ } catch (error) {
1663
+ console.log(chalk6.red("\nAuto-installation failed. Please install manually.\n"));
1664
+ process.exit(1);
1665
+ }
1666
+ } else {
1667
+ console.log(chalk6.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
1668
+ process.exit(1);
1669
+ }
1670
+ } else {
1671
+ this.showInstallInstructions();
1672
+ console.log(chalk6.red("Please install uncloud and run hackerrun again.\n"));
1673
+ process.exit(1);
1674
+ }
1514
1675
  }
1515
1676
  /**
1516
- * List environment variables (values are masked)
1677
+ * Show installation instructions based on platform
1517
1678
  */
1518
- async listEnvVars(appName) {
1519
- const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
1520
- return envVars;
1679
+ static showInstallInstructions() {
1680
+ const os = platform3();
1681
+ console.log(chalk6.cyan("Installation instructions:\n"));
1682
+ if (os === "darwin" || os === "linux") {
1683
+ console.log(chalk6.bold("Option 1: Automated install (Recommended)"));
1684
+ console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1685
+ console.log(chalk6.bold("Option 2: Homebrew"));
1686
+ console.log(chalk6.green(" brew install psviderski/tap/uncloud\n"));
1687
+ console.log(chalk6.bold("Option 3: Manual download"));
1688
+ console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
1689
+ console.log(" Extract and move to /usr/local/bin\n");
1690
+ } else if (os === "win32") {
1691
+ console.log(chalk6.bold("For Windows (via WSL):"));
1692
+ console.log(" Open your WSL terminal and run:");
1693
+ console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1694
+ }
1695
+ console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
1521
1696
  }
1522
1697
  /**
1523
- * Remove an environment variable
1698
+ * Prompt user for input from stdin
1524
1699
  */
1525
- async unsetEnvVar(appName, key) {
1526
- await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
1700
+ static async promptUser() {
1701
+ return new Promise((resolve) => {
1702
+ process.stdin.resume();
1703
+ process.stdin.setEncoding("utf8");
1704
+ const onData = (input) => {
1705
+ process.stdin.pause();
1706
+ process.stdin.removeListener("data", onData);
1707
+ resolve(input.trim().toLowerCase());
1708
+ };
1709
+ process.stdin.on("data", onData);
1710
+ });
1527
1711
  }
1528
- // ==================== GitHub Connection ====================
1529
1712
  /**
1530
- * Create app metadata without creating VM (for connect-before-deploy flow)
1713
+ * Attempt to auto-install uncloud with user confirmation
1531
1714
  */
1532
- async createAppMetadata(appName, location) {
1533
- const { app } = await this.request("POST", "/api/apps", {
1534
- appName,
1535
- location: location || "eu-central-h1",
1536
- metadataOnly: true
1537
- // Signal that we don't want to create VM yet
1538
- });
1539
- return app;
1715
+ static async autoInstall() {
1716
+ const spinner = ora3("Installing uncloud...").start();
1717
+ try {
1718
+ execSync5("curl -fsS https://get.uncloud.run/install.sh | sh", {
1719
+ stdio: "inherit"
1720
+ });
1721
+ spinner.succeed(chalk6.green("Uncloud installed successfully!"));
1722
+ if (this.isInstalled()) {
1723
+ const version = this.getVersion();
1724
+ console.log(chalk6.cyan(`\u2713 Uncloud ${version} is ready to use
1725
+ `));
1726
+ } else {
1727
+ spinner.warn(chalk6.yellow("Installation completed but uc command not found in PATH"));
1728
+ console.log(chalk6.yellow("\nYou may need to:"));
1729
+ console.log(" 1. Restart your terminal");
1730
+ console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
1731
+ throw new Error("Please restart your terminal and try again");
1732
+ }
1733
+ } catch (error) {
1734
+ spinner.fail(chalk6.red("Failed to install uncloud"));
1735
+ console.log(chalk6.red(`
1736
+ Error: ${error.message}
1737
+ `));
1738
+ console.log(chalk6.yellow("Please try manual installation:"));
1739
+ console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
1740
+ throw error;
1741
+ }
1742
+ }
1743
+ };
1744
+
1745
+ // src/lib/platform-auth.ts
1746
+ import chalk7 from "chalk";
1747
+ function getPlatformToken() {
1748
+ const configManager = new ConfigManager();
1749
+ try {
1750
+ const config = configManager.load();
1751
+ return config.apiToken;
1752
+ } catch (error) {
1753
+ console.error(chalk7.red("\n Not logged in"));
1754
+ console.log(chalk7.cyan("\nPlease login first:\n"));
1755
+ console.log(` ${chalk7.bold("hackerrun login")}
1756
+ `);
1757
+ process.exit(1);
1758
+ }
1759
+ }
1760
+
1761
+ // src/lib/platform-client.ts
1762
+ var PLATFORM_API_URL2 = process.env.HACKERRUN_API_URL || "http://localhost:3000";
1763
+ var DEFAULT_RETRIES = 3;
1764
+ var RETRY_DELAY_MS = 2e3;
1765
+ function sleep2(ms) {
1766
+ return new Promise((resolve) => setTimeout(resolve, ms));
1767
+ }
1768
+ function isRetryableError(error, status) {
1769
+ 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")) {
1770
+ return true;
1771
+ }
1772
+ if (status && status >= 500) {
1773
+ return true;
1774
+ }
1775
+ return false;
1776
+ }
1777
+ var PlatformClient = class {
1778
+ constructor(authToken) {
1779
+ this.authToken = authToken;
1540
1780
  }
1541
- /**
1542
- * Initiate GitHub App installation flow
1543
- */
1544
- async initiateGitHubConnect() {
1545
- return this.request("POST", "/api/github/connect");
1781
+ async request(method, path, body, options = {}) {
1782
+ const {
1783
+ retries = DEFAULT_RETRIES,
1784
+ retryDelay = RETRY_DELAY_MS,
1785
+ operation = `${method} ${path}`
1786
+ } = options;
1787
+ const url = `${PLATFORM_API_URL2}${path}`;
1788
+ let lastError = null;
1789
+ for (let attempt = 1; attempt <= retries; attempt++) {
1790
+ try {
1791
+ const response = await fetch(url, {
1792
+ method,
1793
+ headers: {
1794
+ Authorization: `Bearer ${this.authToken}`,
1795
+ "Content-Type": "application/json"
1796
+ },
1797
+ body: body ? JSON.stringify(body) : void 0
1798
+ });
1799
+ if (!response.ok) {
1800
+ const errorText = await response.text();
1801
+ let errorMessage;
1802
+ try {
1803
+ const errorJson = JSON.parse(errorText);
1804
+ errorMessage = errorJson.error || response.statusText;
1805
+ } catch {
1806
+ errorMessage = errorText || response.statusText;
1807
+ }
1808
+ const error = new Error(`API error (${response.status}): ${errorMessage}`);
1809
+ if (isRetryableError(error, response.status) && attempt < retries) {
1810
+ lastError = error;
1811
+ await sleep2(retryDelay * attempt);
1812
+ continue;
1813
+ }
1814
+ throw error;
1815
+ }
1816
+ return response.json();
1817
+ } catch (error) {
1818
+ lastError = error;
1819
+ if (isRetryableError(error) && attempt < retries) {
1820
+ await sleep2(retryDelay * attempt);
1821
+ continue;
1822
+ }
1823
+ const errorMessage = error.message || "Unknown error";
1824
+ throw new Error(`${operation} failed: ${errorMessage}`);
1825
+ }
1826
+ }
1827
+ throw lastError || new Error(`${operation} failed after ${retries} attempts`);
1546
1828
  }
1829
+ // ==================== App State Management ====================
1547
1830
  /**
1548
- * Poll for GitHub App installation completion
1831
+ * List all apps for the authenticated user
1549
1832
  */
1550
- async pollGitHubConnect(stateToken) {
1551
- return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
1833
+ async listApps() {
1834
+ const { apps } = await this.request("GET", "/api/apps");
1835
+ return apps;
1552
1836
  }
1553
1837
  /**
1554
- * Get current user's GitHub App installation
1838
+ * Get a specific app by name
1555
1839
  */
1556
- async getGitHubInstallation() {
1840
+ async getApp(appName) {
1557
1841
  try {
1558
- const { installation } = await this.request("GET", "/api/github/installation");
1559
- return installation;
1842
+ const { app } = await this.request(
1843
+ "GET",
1844
+ `/api/apps/${appName}`,
1845
+ void 0,
1846
+ { operation: `Get app '${appName}'` }
1847
+ );
1848
+ return app;
1560
1849
  } catch (error) {
1561
1850
  if (error.message.includes("404") || error.message.includes("not found")) {
1562
1851
  return null;
@@ -1565,722 +1854,425 @@ var PlatformClient = class {
1565
1854
  }
1566
1855
  }
1567
1856
  /**
1568
- * Delete current user's GitHub App installation record
1569
- * Used when the installation becomes stale/invalid
1857
+ * Create or update an app
1858
+ * Returns the app with auto-generated domainName
1570
1859
  */
1571
- async deleteGitHubInstallation() {
1572
- await this.request("DELETE", "/api/github/installation");
1860
+ async saveApp(cluster) {
1861
+ const { app } = await this.request(
1862
+ "POST",
1863
+ "/api/apps",
1864
+ {
1865
+ appName: cluster.appName,
1866
+ location: cluster.location,
1867
+ uncloudContext: cluster.uncloudContext,
1868
+ nodes: cluster.nodes.map((node) => ({
1869
+ name: node.name,
1870
+ id: node.id,
1871
+ ipv4: node.ipv4,
1872
+ ipv6: node.ipv6,
1873
+ isPrimary: node.isPrimary
1874
+ }))
1875
+ },
1876
+ { operation: `Save app '${cluster.appName}'` }
1877
+ );
1878
+ return app;
1573
1879
  }
1574
1880
  /**
1575
- * List repositories accessible via GitHub App installation
1881
+ * Update app's last deployed timestamp
1576
1882
  */
1577
- async listAccessibleRepos() {
1578
- const { repos } = await this.request("GET", "/api/github/repos");
1579
- return repos;
1883
+ async updateLastDeployed(appName) {
1884
+ await this.request("PATCH", `/api/apps/${appName}`, {
1885
+ lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
1886
+ }, { operation: `Update last deployed for '${appName}'` });
1580
1887
  }
1581
1888
  /**
1582
- * Connect a repository to an app
1889
+ * Rename an app (per-user unique)
1583
1890
  */
1584
- async connectRepo(appName, repoFullName, branch) {
1585
- await this.request("POST", `/api/apps/${appName}/repo`, {
1586
- repoFullName,
1587
- branch: branch || "main"
1891
+ async renameApp(currentAppName, newAppName) {
1892
+ const { app } = await this.request("PATCH", `/api/apps/${currentAppName}`, {
1893
+ newAppName
1588
1894
  });
1895
+ return app;
1589
1896
  }
1590
1897
  /**
1591
- * Get connected repository for an app
1592
- */
1593
- async getConnectedRepo(appName) {
1594
- try {
1595
- const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
1596
- return repo;
1597
- } catch (error) {
1598
- if (error.message.includes("404") || error.message.includes("not found")) {
1599
- return null;
1600
- }
1601
- throw error;
1602
- }
1603
- }
1604
- /**
1605
- * Disconnect repository from an app
1898
+ * Rename app's domain (globally unique)
1606
1899
  */
1607
- async disconnectRepo(appName) {
1608
- await this.request("DELETE", `/api/apps/${appName}/repo`);
1900
+ async renameDomain(appName, newDomainName) {
1901
+ const { app } = await this.request("PATCH", `/api/apps/${appName}`, {
1902
+ newDomainName
1903
+ });
1904
+ return app;
1609
1905
  }
1610
- // ==================== Builds ====================
1611
1906
  /**
1612
- * List builds for an app
1907
+ * Delete an app
1613
1908
  */
1614
- async listBuilds(appName, limit = 10) {
1615
- const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
1616
- return builds;
1909
+ async deleteApp(appName) {
1910
+ await this.request("DELETE", `/api/apps/${appName}`, void 0, { operation: `Delete app '${appName}'` });
1617
1911
  }
1618
1912
  /**
1619
- * Get build details
1913
+ * Check if app exists
1620
1914
  */
1621
- async getBuild(appName, buildId) {
1622
- const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
1623
- return build;
1915
+ async hasApp(appName) {
1916
+ const app = await this.getApp(appName);
1917
+ return app !== null;
1624
1918
  }
1625
1919
  /**
1626
- * Get build events (for live streaming)
1920
+ * Get primary node for an app
1627
1921
  */
1628
- async getBuildEvents(appName, buildId, after) {
1629
- let url = `/api/apps/${appName}/builds/${buildId}/events`;
1630
- if (after) {
1631
- url += `?after=${encodeURIComponent(after.toISOString())}`;
1632
- }
1633
- const { events } = await this.request("GET", url);
1634
- return events;
1922
+ async getPrimaryNode(appName) {
1923
+ const app = await this.getApp(appName);
1924
+ return app?.nodes.find((node) => node.isPrimary) || null;
1635
1925
  }
1636
- // ==================== VPN Management ====================
1926
+ // ==================== VM Operations (Proxied to Ubicloud) ====================
1637
1927
  /**
1638
- * Register VPN peer with the platform
1639
- * This is idempotent - if already registered, returns existing config
1928
+ * List all VMs for the user's Ubicloud project
1640
1929
  */
1641
- async registerVPNPeer(publicKey, location) {
1642
- const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
1643
- return vpnConfig;
1930
+ async listVMs() {
1931
+ const { vms } = await this.request("GET", "/api/vms");
1932
+ return vms;
1644
1933
  }
1645
1934
  /**
1646
- * Get existing VPN config if registered
1935
+ * Create a new VM
1647
1936
  */
1648
- async getVPNConfig(location) {
1649
- try {
1650
- const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
1651
- return vpnConfig;
1652
- } catch (error) {
1653
- if (error.message.includes("404") || error.message.includes("not found")) {
1654
- return null;
1655
- }
1656
- throw error;
1657
- }
1937
+ async createVM(params) {
1938
+ const { vm } = await this.request("POST", "/api/vms", params, { operation: `Create VM '${params.name}'` });
1939
+ return vm;
1658
1940
  }
1659
1941
  /**
1660
- * Unregister VPN peer
1942
+ * Create a new VM for an app with auto-generated name
1943
+ * Name format: u{userId}-{appName}-{nodeNum}
1661
1944
  */
1662
- async unregisterVPNPeer() {
1663
- await this.request("DELETE", "/api/vpn/unregister");
1664
- }
1665
- };
1666
-
1667
- // src/lib/app-config.ts
1668
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1669
- import { join as join3 } from "path";
1670
- import { parse, stringify } from "yaml";
1671
- var CONFIG_FILENAME = "hackerrun.yaml";
1672
- function getConfigPath(directory = process.cwd()) {
1673
- return join3(directory, CONFIG_FILENAME);
1674
- }
1675
- function hasAppConfig(directory = process.cwd()) {
1676
- return existsSync3(getConfigPath(directory));
1677
- }
1678
- function readAppConfig(directory = process.cwd()) {
1679
- const configPath = getConfigPath(directory);
1680
- if (!existsSync3(configPath)) {
1681
- return null;
1682
- }
1683
- try {
1684
- const content = readFileSync3(configPath, "utf-8");
1685
- const config = parse(content);
1686
- if (!config.appName) {
1687
- return null;
1688
- }
1689
- return config;
1690
- } catch (error) {
1691
- console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
1692
- return null;
1693
- }
1694
- }
1695
- function writeAppConfig(config, directory = process.cwd()) {
1696
- const configPath = getConfigPath(directory);
1697
- const content = stringify(config);
1698
- writeFileSync3(configPath, content, "utf-8");
1699
- }
1700
- function getAppName(directory = process.cwd()) {
1701
- const config = readAppConfig(directory);
1702
- if (config?.appName) {
1703
- return config.appName;
1704
- }
1705
- const folderName = directory.split("/").pop() || "app";
1706
- return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1707
- }
1708
- function linkApp(appName, directory = process.cwd()) {
1709
- writeAppConfig({ appName }, directory);
1710
- }
1711
-
1712
- // src/lib/uncloud-runner.ts
1713
- import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
1714
-
1715
- // src/lib/vpn.ts
1716
- import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
1717
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
1718
- import { homedir as homedir2, platform as platform3 } from "os";
1719
- import { join as join4 } from "path";
1720
- import chalk6 from "chalk";
1721
- function detectOS() {
1722
- const os = platform3();
1723
- if (os === "darwin") {
1724
- return { type: "macos" };
1945
+ async createAppVM(appName, params) {
1946
+ const { vm } = await this.request(
1947
+ "POST",
1948
+ `/api/apps/${appName}/vms`,
1949
+ params,
1950
+ { operation: `Create VM for app '${appName}'` }
1951
+ );
1952
+ return vm;
1725
1953
  }
1726
- if (os === "win32") {
1727
- return { type: "windows" };
1954
+ /**
1955
+ * Get a specific VM
1956
+ */
1957
+ async getVM(location, vmName) {
1958
+ const { vm } = await this.request(
1959
+ "GET",
1960
+ `/api/vms/${location}/${vmName}`,
1961
+ void 0,
1962
+ { operation: `Get VM '${vmName}'` }
1963
+ );
1964
+ return vm;
1728
1965
  }
1729
- if (os === "linux") {
1966
+ /**
1967
+ * Delete a VM
1968
+ */
1969
+ async deleteVM(location, vmName) {
1970
+ await this.request("DELETE", `/api/vms/${location}/${vmName}`, void 0, { operation: `Delete VM '${vmName}'` });
1971
+ }
1972
+ // ==================== Uncloud Config Management ====================
1973
+ /**
1974
+ * Upload uncloud config to platform
1975
+ */
1976
+ async uploadUncloudConfig(configYaml) {
1977
+ await this.request("POST", "/api/uncloud/config", { config: configYaml });
1978
+ }
1979
+ /**
1980
+ * Download uncloud config from platform
1981
+ */
1982
+ async downloadUncloudConfig() {
1730
1983
  try {
1731
- if (existsSync4("/etc/os-release")) {
1732
- const osRelease = readFileSync4("/etc/os-release", "utf-8");
1733
- const idMatch = osRelease.match(/^ID=(.*)$/m);
1734
- if (idMatch) {
1735
- const distro = idMatch[1].replace(/"/g, "").toLowerCase();
1736
- return { type: "linux", distro };
1737
- }
1984
+ const { config } = await this.request("GET", "/api/uncloud/config");
1985
+ return config;
1986
+ } catch (error) {
1987
+ if (error.message.includes("404") || error.message.includes("not found")) {
1988
+ return null;
1738
1989
  }
1739
- } catch {
1990
+ throw error;
1740
1991
  }
1741
- return { type: "linux" };
1742
1992
  }
1743
- return { type: "unknown" };
1744
- }
1745
- function getWireGuardInstallInstructions() {
1746
- const os = detectOS();
1747
- switch (os.type) {
1748
- case "macos":
1749
- return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
1750
- case "windows":
1751
- return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
1752
- Or using winget: winget install WireGuard.WireGuard`;
1753
- case "linux":
1754
- switch (os.distro) {
1755
- case "arch":
1756
- case "manjaro":
1757
- case "endeavouros":
1758
- case "artix":
1759
- return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
1760
- case "ubuntu":
1761
- case "debian":
1762
- case "linuxmint":
1763
- case "pop":
1764
- case "elementary":
1765
- case "zorin":
1766
- return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
1767
- case "fedora":
1768
- return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
1769
- case "rhel":
1770
- case "centos":
1771
- case "rocky":
1772
- case "almalinux":
1773
- return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
1774
- (may need EPEL: sudo dnf install epel-release)`;
1775
- case "opensuse":
1776
- case "opensuse-leap":
1777
- case "opensuse-tumbleweed":
1778
- return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
1779
- case "gentoo":
1780
- return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
1781
- case "void":
1782
- return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
1783
- case "alpine":
1784
- return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
1785
- case "nixos":
1786
- return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
1787
- default:
1788
- return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
1789
- Arch: sudo pacman -S wireguard-tools
1790
- Ubuntu/Debian: sudo apt install wireguard
1791
- Fedora: sudo dnf install wireguard-tools
1792
- openSUSE: sudo zypper install wireguard-tools`;
1993
+ // ==================== Gateway Management ====================
1994
+ /**
1995
+ * Get gateway info for a location
1996
+ */
1997
+ async getGateway(location) {
1998
+ try {
1999
+ const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
2000
+ return gateway;
2001
+ } catch (error) {
2002
+ if (error.message.includes("404") || error.message.includes("not found")) {
2003
+ return null;
1793
2004
  }
1794
- default:
1795
- return ` Visit https://www.wireguard.com/install/ for installation instructions`;
1796
- }
1797
- }
1798
- var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
1799
- var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
1800
- var WG_INTERFACE = "hackerrun";
1801
- var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
1802
- function isWireGuardInstalled() {
1803
- try {
1804
- execSync4("which wg", { stdio: "ignore" });
1805
- execSync4("which wg-quick", { stdio: "ignore" });
1806
- return true;
1807
- } catch {
1808
- return false;
1809
- }
1810
- }
1811
- function ensureConfigDir() {
1812
- if (!existsSync4(CONFIG_DIR)) {
1813
- mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
1814
- }
1815
- }
1816
- function generateKeyPair() {
1817
- const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
1818
- const publicKey = execSync4("wg pubkey", {
1819
- input: privateKey,
1820
- encoding: "utf-8"
1821
- }).trim();
1822
- return { privateKey, publicKey };
1823
- }
1824
- function getOrCreateKeyPair() {
1825
- ensureConfigDir();
1826
- if (existsSync4(PRIVATE_KEY_FILE)) {
1827
- const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1828
- const publicKey2 = execSync4("wg pubkey", {
1829
- input: privateKey2,
1830
- encoding: "utf-8"
1831
- }).trim();
1832
- return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
2005
+ throw error;
2006
+ }
1833
2007
  }
1834
- const { privateKey, publicKey } = generateKeyPair();
1835
- writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
1836
- return { privateKey, publicKey, isNew: true };
1837
- }
1838
- function getPublicKey() {
1839
- if (!existsSync4(PRIVATE_KEY_FILE)) {
1840
- return null;
2008
+ // ==================== Route Management ====================
2009
+ /**
2010
+ * Register a route for an app (maps hostname to VM IPv6)
2011
+ */
2012
+ async registerRoute(appName, backendIpv6, backendPort = 443) {
2013
+ const { route } = await this.request("POST", "/api/routes", {
2014
+ appName,
2015
+ backendIpv6,
2016
+ backendPort
2017
+ }, { operation: `Register route for '${appName}'` });
2018
+ return route;
1841
2019
  }
1842
- const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1843
- return execSync4("wg pubkey", {
1844
- input: privateKey,
1845
- encoding: "utf-8"
1846
- }).trim();
1847
- }
1848
- function isVPNUp() {
1849
- try {
1850
- const result = execSync4(`ip link show ${WG_INTERFACE}`, {
1851
- encoding: "utf-8",
1852
- stdio: ["pipe", "pipe", "pipe"]
1853
- });
1854
- return result.includes(WG_INTERFACE);
1855
- } catch {
1856
- return false;
2020
+ /**
2021
+ * Delete routes for an app
2022
+ */
2023
+ async deleteRoute(appName) {
2024
+ await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
1857
2025
  }
1858
- }
1859
- function isRoutedViaVPN(ipv6Address) {
1860
- if (!isVPNUp()) {
1861
- return false;
2026
+ // ==================== Gateway Route Sync ====================
2027
+ /**
2028
+ * Sync gateway Caddy configuration for a location
2029
+ * This updates the gateway's reverse proxy config with current routes
2030
+ */
2031
+ async syncGatewayRoutes(location) {
2032
+ const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
2033
+ return { routeCount: result.routeCount };
1862
2034
  }
1863
- try {
1864
- const result = execSync4(`ip -6 route get ${ipv6Address}`, {
1865
- encoding: "utf-8",
1866
- stdio: ["pipe", "pipe", "pipe"]
1867
- });
1868
- return result.includes(`dev ${WG_INTERFACE}`);
1869
- } catch {
1870
- return false;
2035
+ // ==================== SSH Certificate Management ====================
2036
+ /**
2037
+ * Get platform SSH keys (CA public key + platform public key for VM creation)
2038
+ */
2039
+ async getPlatformSSHKeys() {
2040
+ return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1871
2041
  }
1872
- }
1873
- function getVPNStatus() {
1874
- if (!isVPNUp()) {
1875
- return { connected: false };
2042
+ /**
2043
+ * Initialize platform SSH keys (creates them if they don't exist)
2044
+ */
2045
+ async initPlatformSSHKeys() {
2046
+ return this.request("POST", "/api/platform/ssh-keys");
1876
2047
  }
1877
- try {
1878
- const output = execSync4(`sudo wg show ${WG_INTERFACE}`, {
1879
- encoding: "utf-8",
1880
- stdio: ["pipe", "pipe", "pipe"]
1881
- });
1882
- const lines = output.split("\n");
1883
- let endpoint;
1884
- let latestHandshake;
1885
- let transferRx;
1886
- let transferTx;
1887
- for (const line of lines) {
1888
- if (line.includes("endpoint:")) {
1889
- endpoint = line.split("endpoint:")[1]?.trim();
1890
- }
1891
- if (line.includes("latest handshake:")) {
1892
- latestHandshake = line.split("latest handshake:")[1]?.trim();
1893
- }
1894
- if (line.includes("transfer:")) {
1895
- const transfer = line.split("transfer:")[1]?.trim();
1896
- const parts = transfer?.split(",");
1897
- transferRx = parts?.[0]?.trim();
1898
- transferTx = parts?.[1]?.trim();
1899
- }
1900
- }
1901
- return {
1902
- connected: true,
1903
- interface: WG_INTERFACE,
1904
- endpoint,
1905
- latestHandshake,
1906
- transferRx,
1907
- transferTx
1908
- };
1909
- } catch {
1910
- return { connected: isVPNUp(), interface: WG_INTERFACE };
2048
+ /**
2049
+ * Request a signed SSH certificate for accessing an app's VMs
2050
+ * Returns a short-lived certificate (5 min) signed by the platform CA
2051
+ */
2052
+ async requestSSHCertificate(appName, publicKey) {
2053
+ return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1911
2054
  }
1912
- }
1913
- function generateWireGuardConfig(config) {
1914
- return `# HackerRun VPN - Auto-generated
1915
- # Do not edit manually
1916
-
1917
- [Interface]
1918
- PrivateKey = ${config.privateKey}
1919
- Address = ${config.address}
1920
-
1921
- [Peer]
1922
- PublicKey = ${config.gatewayPublicKey}
1923
- Endpoint = ${config.gatewayEndpoint}
1924
- AllowedIPs = ${config.allowedIPs}
1925
- PersistentKeepalive = 25
1926
- `;
1927
- }
1928
- function writeWireGuardConfig(config) {
1929
- const configContent = generateWireGuardConfig(config);
1930
- const tempFile = join4(CONFIG_DIR, "wg-temp.conf");
1931
- writeFileSync4(tempFile, configContent, { mode: 384 });
1932
- try {
1933
- execSync4("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
1934
- execSync4(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1935
- execSync4(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1936
- } catch (error) {
1937
- if (existsSync4(tempFile)) {
1938
- unlinkSync(tempFile);
1939
- }
1940
- throw error;
2055
+ // ==================== VM Setup ====================
2056
+ /**
2057
+ * Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
2058
+ * This runs all configuration using the platform SSH key
2059
+ */
2060
+ async setupVM(vmIp, location, appName) {
2061
+ await this.request("POST", "/api/vms/setup", {
2062
+ vmIp,
2063
+ location,
2064
+ appName
2065
+ }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
1941
2066
  }
1942
- }
1943
- function vpnUp() {
1944
- if (isVPNUp()) {
1945
- return;
2067
+ // ==================== Environment Variables ====================
2068
+ /**
2069
+ * Set a single environment variable
2070
+ */
2071
+ async setEnvVar(appName, key, value) {
2072
+ await this.request("POST", `/api/apps/${appName}/env`, { key, value });
1946
2073
  }
1947
- const result = spawnSync2("sudo", ["wg-quick", "up", WG_INTERFACE], {
1948
- stdio: "inherit"
1949
- });
1950
- if (result.status !== 0) {
1951
- throw new Error(`Failed to bring up VPN interface (exit code ${result.status})`);
2074
+ /**
2075
+ * Set multiple environment variables at once
2076
+ */
2077
+ async setEnvVars(appName, vars) {
2078
+ await this.request("POST", `/api/apps/${appName}/env`, { vars });
1952
2079
  }
1953
- }
1954
- function vpnDown() {
1955
- if (!isVPNUp()) {
1956
- return;
2080
+ /**
2081
+ * List environment variables (values are masked)
2082
+ */
2083
+ async listEnvVars(appName) {
2084
+ const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
2085
+ return envVars;
1957
2086
  }
1958
- const result = spawnSync2("sudo", ["wg-quick", "down", WG_INTERFACE], {
1959
- stdio: "inherit"
1960
- });
1961
- if (result.status !== 0) {
1962
- throw new Error(`Failed to bring down VPN interface (exit code ${result.status})`);
2087
+ /**
2088
+ * Remove an environment variable
2089
+ */
2090
+ async unsetEnvVar(appName, key) {
2091
+ await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
1963
2092
  }
1964
- }
1965
- function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
1966
- try {
1967
- execSync4(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
1968
- stdio: "ignore",
1969
- timeout: (timeoutSeconds + 2) * 1e3
2093
+ // ==================== GitHub Connection ====================
2094
+ /**
2095
+ * Create app metadata without creating VM (for connect-before-deploy flow)
2096
+ */
2097
+ async createAppMetadata(appName, location) {
2098
+ const { app } = await this.request("POST", "/api/apps", {
2099
+ appName,
2100
+ location: location || "eu-central-h1",
2101
+ metadataOnly: true
2102
+ // Signal that we don't want to create VM yet
1970
2103
  });
1971
- return true;
1972
- } catch {
1973
- return false;
2104
+ return app;
1974
2105
  }
1975
- }
1976
- var VPNManager = class {
1977
- wasConnectedByUs = false;
1978
2106
  /**
1979
- * Ensure VPN is connected if IPv6 is not available
1980
- * Returns true if VPN was established (and should be torn down later)
2107
+ * Initiate GitHub App installation flow
1981
2108
  */
1982
- async ensureConnected(targetIPv6, getVPNConfig) {
1983
- if (testIPv6Connectivity2(targetIPv6)) {
1984
- console.log(chalk6.green("\u2713 Direct IPv6 connectivity available"));
1985
- return false;
1986
- }
1987
- console.log(chalk6.yellow("IPv6 not available, checking VPN..."));
1988
- if (isVPNUp()) {
1989
- if (testIPv6Connectivity2(targetIPv6)) {
1990
- console.log(chalk6.green("\u2713 VPN already connected"));
1991
- return false;
1992
- }
1993
- console.log(chalk6.yellow("VPN is up but cannot reach target, reconnecting..."));
1994
- vpnDown();
1995
- }
1996
- if (!isWireGuardInstalled()) {
1997
- const instructions = getWireGuardInstallInstructions();
1998
- throw new Error(
1999
- "WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
2000
- );
2001
- }
2002
- console.log(chalk6.cyan("Establishing VPN tunnel (requires sudo)..."));
2003
- const { publicKey, isNew } = getOrCreateKeyPair();
2004
- if (isNew) {
2005
- console.log(chalk6.dim("Generated new WireGuard keypair"));
2006
- }
2007
- const vpnConfig = await getVPNConfig();
2008
- writeWireGuardConfig(vpnConfig);
2009
- vpnUp();
2010
- if (!testIPv6Connectivity2(targetIPv6)) {
2011
- vpnDown();
2012
- throw new Error("VPN established but cannot reach target. Please try again.");
2013
- }
2014
- console.log(chalk6.green("\u2713 VPN connected"));
2015
- this.wasConnectedByUs = true;
2016
- return true;
2109
+ async initiateGitHubConnect() {
2110
+ return this.request("POST", "/api/github/connect");
2017
2111
  }
2018
2112
  /**
2019
- * Disconnect VPN if we established it
2113
+ * Poll for GitHub App installation completion
2020
2114
  */
2021
- disconnect() {
2022
- if (this.wasConnectedByUs && isVPNUp()) {
2023
- console.log(chalk6.dim("Disconnecting VPN..."));
2024
- try {
2025
- vpnDown();
2026
- console.log(chalk6.green("\u2713 VPN disconnected"));
2027
- } catch (error) {
2028
- console.log(chalk6.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
2029
- }
2030
- this.wasConnectedByUs = false;
2031
- }
2032
- }
2033
- };
2034
-
2035
- // src/lib/uncloud-runner.ts
2036
- import chalk7 from "chalk";
2037
- var UncloudRunner = class {
2038
- constructor(platformClient) {
2039
- this.platformClient = platformClient;
2040
- this.certManager = new SSHCertManager(platformClient);
2115
+ async pollGitHubConnect(stateToken) {
2116
+ return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
2041
2117
  }
2042
- certManager;
2043
- gatewayCache = /* @__PURE__ */ new Map();
2044
- tunnelManager = new TunnelManager();
2045
- vpnManager = new VPNManager();
2046
- tempConfigPath = null;
2047
- vpnEstablishedByUs = false;
2048
2118
  /**
2049
- * Get the connection URL for an app's primary VM
2050
- * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
2119
+ * Get current user's GitHub App installation
2051
2120
  */
2052
- async getConnectionInfo(appName) {
2053
- const app = await this.platformClient.getApp(appName);
2054
- if (!app) {
2055
- throw new Error(`App '${appName}' not found`);
2056
- }
2057
- const primaryNode = app.nodes.find((n) => n.isPrimary);
2058
- if (!primaryNode?.ipv6) {
2059
- throw new Error(`App '${appName}' has no primary node with IPv6 address`);
2060
- }
2061
- const vmIp = primaryNode.ipv6;
2062
- await this.certManager.getSession(appName, vmIp);
2063
- const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
2064
- if (canConnectDirect) {
2065
- const viaVPN = isRoutedViaVPN(vmIp);
2066
- return {
2067
- url: `ssh://root@${vmIp}`,
2068
- vmIp,
2069
- viaGateway: false,
2070
- viaVPN
2071
- };
2072
- }
2073
- const vpnAvailable = isWireGuardInstalled();
2074
- if (vpnAvailable) {
2075
- console.log(chalk7.yellow("Direct IPv6 not available, trying VPN..."));
2076
- try {
2077
- if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
2078
- console.log(chalk7.green("\u2713 VPN already connected"));
2079
- return {
2080
- url: `ssh://root@${vmIp}`,
2081
- vmIp,
2082
- viaGateway: false,
2083
- viaVPN: true
2084
- };
2085
- }
2086
- this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
2087
- vmIp,
2088
- async () => this.getVPNConfig(app.location)
2089
- );
2090
- if (testIPv6Connectivity2(vmIp, 5)) {
2091
- console.log(chalk7.green("\u2713 Connected via VPN"));
2092
- return {
2093
- url: `ssh://root@${vmIp}`,
2094
- vmIp,
2095
- viaGateway: false,
2096
- viaVPN: true
2097
- };
2098
- }
2099
- } catch (error) {
2100
- console.log(chalk7.yellow(`VPN setup failed: ${error.message}`));
2101
- console.log(chalk7.dim("Falling back to SSH tunnel..."));
2121
+ async getGitHubInstallation() {
2122
+ try {
2123
+ const { installation } = await this.request("GET", "/api/github/installation");
2124
+ return installation;
2125
+ } catch (error) {
2126
+ if (error.message.includes("404") || error.message.includes("not found")) {
2127
+ return null;
2102
2128
  }
2103
- } else {
2104
- console.log(chalk7.yellow("Direct IPv6 not available on your network."));
2105
- console.log(chalk7.dim("For faster deploys, install WireGuard:"));
2106
- console.log(chalk7.dim(getWireGuardInstallInstructions()));
2107
- console.log();
2129
+ throw error;
2108
2130
  }
2109
- console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
2110
- const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
2111
- return {
2112
- url: `ssh://root@localhost:${tunnelInfo.localPort}`,
2113
- vmIp,
2114
- viaGateway: true,
2115
- viaVPN: false,
2116
- localPort: tunnelInfo.localPort
2117
- };
2118
2131
  }
2119
2132
  /**
2120
- * Get VPN configuration from platform
2133
+ * Delete current user's GitHub App installation record
2134
+ * Used when the installation becomes stale/invalid
2121
2135
  */
2122
- async getVPNConfig(location) {
2123
- const { privateKey, publicKey } = getOrCreateKeyPair();
2124
- const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
2125
- return {
2126
- privateKey,
2127
- publicKey,
2128
- address: vpnConfigResponse.address,
2129
- gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
2130
- gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
2131
- allowedIPs: vpnConfigResponse.allowedIPs
2132
- };
2136
+ async deleteGitHubInstallation() {
2137
+ await this.request("DELETE", "/api/github/installation");
2133
2138
  }
2134
2139
  /**
2135
- * Pre-accept a host's SSH key by running ssh-keyscan
2136
- * This prevents "Host key verification failed" errors from uncloud
2140
+ * List repositories accessible via GitHub App installation
2137
2141
  */
2138
- preAcceptHostKey(host, port) {
2139
- try {
2140
- const portArg = port ? `-p ${port}` : "";
2141
- execSync5(
2142
- `ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
2143
- { stdio: "pipe", timeout: 1e4 }
2144
- );
2145
- } catch {
2146
- }
2142
+ async listAccessibleRepos() {
2143
+ const { repos } = await this.request("GET", "/api/github/repos");
2144
+ return repos;
2147
2145
  }
2148
2146
  /**
2149
- * Ensure an SSH tunnel exists for gateway fallback
2147
+ * Connect a repository to an app
2150
2148
  */
2151
- async ensureTunnel(appName, vmIp, location) {
2152
- const gateway = await this.getGateway(location);
2153
- if (!gateway) {
2154
- throw new Error(`No gateway found for location ${location}`);
2155
- }
2156
- return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
2149
+ async connectRepo(appName, repoFullName, branch) {
2150
+ await this.request("POST", `/api/apps/${appName}/repo`, {
2151
+ repoFullName,
2152
+ branch: branch || "main"
2153
+ });
2157
2154
  }
2158
2155
  /**
2159
- * Run an uncloud command on the app's VM
2156
+ * Get connected repository for an app
2160
2157
  */
2161
- async run(appName, command, args = [], options = {}) {
2162
- const connInfo = await this.getConnectionInfo(appName);
2163
- if (connInfo.viaGateway) {
2164
- console.log(chalk7.dim(`Connecting via gateway...`));
2165
- }
2166
- const fullArgs = ["--connect", connInfo.url, command, ...args];
2167
- if (options.stdio === "inherit") {
2168
- const result = spawnSync3("uc", fullArgs, {
2169
- cwd: options.cwd,
2170
- stdio: "inherit",
2171
- timeout: options.timeout
2172
- });
2173
- if (result.status !== 0) {
2174
- throw new Error(`Uncloud command failed with exit code ${result.status}`);
2158
+ async getConnectedRepo(appName) {
2159
+ try {
2160
+ const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
2161
+ return repo;
2162
+ } catch (error) {
2163
+ if (error.message.includes("404") || error.message.includes("not found")) {
2164
+ return null;
2175
2165
  }
2176
- } else {
2177
- const result = execSync5(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
2178
- cwd: options.cwd,
2179
- encoding: "utf-8",
2180
- timeout: options.timeout
2181
- });
2182
- return result;
2166
+ throw error;
2183
2167
  }
2184
2168
  }
2185
2169
  /**
2186
- * Run 'uc deploy' for an app
2170
+ * Disconnect repository from an app
2187
2171
  */
2188
- async deploy(appName, cwd) {
2189
- await this.run(appName, "deploy", ["--yes"], { cwd, stdio: "inherit" });
2172
+ async disconnectRepo(appName) {
2173
+ await this.request("DELETE", `/api/apps/${appName}/repo`);
2190
2174
  }
2175
+ // ==================== Builds ====================
2191
2176
  /**
2192
- * Run 'uc service logs' for an app
2177
+ * List builds for an app
2193
2178
  */
2194
- async logs(appName, serviceName, options = {}) {
2195
- const args = [serviceName];
2196
- if (options.follow) args.push("-f");
2197
- if (options.tail) args.push("--tail", String(options.tail));
2198
- await this.run(appName, "service", ["logs", ...args], { stdio: "inherit" });
2179
+ async listBuilds(appName, limit = 10) {
2180
+ const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
2181
+ return builds;
2199
2182
  }
2200
2183
  /**
2201
- * Run 'uc service ls' for an app
2184
+ * Get build details
2202
2185
  */
2203
- async serviceList(appName) {
2204
- const result = await this.run(appName, "service", ["ls"], { stdio: "pipe" });
2205
- return result;
2186
+ async getBuild(appName, buildId) {
2187
+ const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
2188
+ return build;
2206
2189
  }
2207
2190
  /**
2208
- * Run 'uc machine token' on remote VM via SSH
2209
- * This is different - it runs on the VM directly, not via uncloud connector
2191
+ * Get build events (for live streaming)
2210
2192
  */
2211
- async getMachineToken(appName) {
2212
- const connInfo = await this.getConnectionInfo(appName);
2213
- let sshCmd2;
2214
- if (connInfo.viaGateway && connInfo.localPort) {
2215
- sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
2216
- } else {
2217
- sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
2193
+ async getBuildEvents(appName, buildId, after) {
2194
+ let url = `/api/apps/${appName}/builds/${buildId}/events`;
2195
+ if (after) {
2196
+ url += `?after=${encodeURIComponent(after.toISOString())}`;
2218
2197
  }
2219
- const token = execSync5(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
2220
- return token;
2198
+ const { events } = await this.request("GET", url);
2199
+ return events;
2221
2200
  }
2201
+ // ==================== VPN Management ====================
2222
2202
  /**
2223
- * Execute an SSH command on the VM
2203
+ * Register VPN peer with the platform
2204
+ * This is idempotent - if already registered, returns existing config
2224
2205
  */
2225
- async sshExec(appName, command) {
2226
- const connInfo = await this.getConnectionInfo(appName);
2227
- let sshCmd2;
2228
- if (connInfo.viaGateway && connInfo.localPort) {
2229
- sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
2230
- } else {
2231
- sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
2232
- }
2233
- return execSync5(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
2206
+ async registerVPNPeer(publicKey, location) {
2207
+ const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
2208
+ return vpnConfig;
2234
2209
  }
2235
2210
  /**
2236
- * Remove a node from the uncloud cluster
2237
- * Uses 'uc machine rm' to drain containers and remove from cluster
2211
+ * Get existing VPN config if registered
2238
2212
  */
2239
- async removeNode(appName, nodeName) {
2240
- const connInfo = await this.getConnectionInfo(appName);
2241
- const result = spawnSync3("uc", ["--connect", connInfo.url, "machine", "rm", nodeName, "--yes"], {
2242
- stdio: "inherit",
2243
- timeout: 3e5
2244
- // 5 minute timeout for container drainage
2245
- });
2246
- if (result.status !== 0) {
2247
- throw new Error(`Failed to remove node '${nodeName}' from cluster (exit code ${result.status})`);
2213
+ async getVPNConfig(location) {
2214
+ try {
2215
+ const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
2216
+ return vpnConfig;
2217
+ } catch (error) {
2218
+ if (error.message.includes("404") || error.message.includes("not found")) {
2219
+ return null;
2220
+ }
2221
+ throw error;
2248
2222
  }
2249
2223
  }
2250
2224
  /**
2251
- * List machines in the uncloud cluster
2225
+ * Unregister VPN peer
2252
2226
  */
2253
- async listMachines(appName) {
2254
- const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
2255
- return result;
2227
+ async unregisterVPNPeer() {
2228
+ await this.request("DELETE", "/api/vpn/unregister");
2256
2229
  }
2257
- /**
2258
- * Get gateway info (cached)
2259
- */
2260
- async getGateway(location) {
2261
- if (this.gatewayCache.has(location)) {
2262
- return this.gatewayCache.get(location) || null;
2263
- }
2264
- const gateway = await this.platformClient.getGateway(location);
2265
- if (gateway) {
2266
- this.gatewayCache.set(location, { ipv4: gateway.ipv4, ipv6: gateway.ipv6 });
2267
- return { ipv4: gateway.ipv4, ipv6: gateway.ipv6 };
2268
- }
2269
- this.gatewayCache.set(location, null);
2230
+ };
2231
+
2232
+ // src/lib/app-config.ts
2233
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
2234
+ import { join as join4 } from "path";
2235
+ import { parse, stringify } from "yaml";
2236
+ var CONFIG_FILENAME = "hackerrun.yaml";
2237
+ function getConfigPath(directory = process.cwd()) {
2238
+ return join4(directory, CONFIG_FILENAME);
2239
+ }
2240
+ function hasAppConfig(directory = process.cwd()) {
2241
+ return existsSync4(getConfigPath(directory));
2242
+ }
2243
+ function readAppConfig(directory = process.cwd()) {
2244
+ const configPath = getConfigPath(directory);
2245
+ if (!existsSync4(configPath)) {
2270
2246
  return null;
2271
2247
  }
2272
- /**
2273
- * Clean up all SSH sessions, tunnels, and VPN
2274
- */
2275
- cleanup() {
2276
- this.tunnelManager.closeAll();
2277
- this.certManager.cleanupAll();
2278
- if (this.vpnEstablishedByUs) {
2279
- this.vpnManager.disconnect();
2280
- this.vpnEstablishedByUs = false;
2248
+ try {
2249
+ const content = readFileSync4(configPath, "utf-8");
2250
+ const config = parse(content);
2251
+ if (!config.appName) {
2252
+ return null;
2281
2253
  }
2254
+ return config;
2255
+ } catch (error) {
2256
+ console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
2257
+ return null;
2282
2258
  }
2283
- };
2259
+ }
2260
+ function writeAppConfig(config, directory = process.cwd()) {
2261
+ const configPath = getConfigPath(directory);
2262
+ const content = stringify(config);
2263
+ writeFileSync4(configPath, content, "utf-8");
2264
+ }
2265
+ function getAppName(directory = process.cwd()) {
2266
+ const config = readAppConfig(directory);
2267
+ if (config?.appName) {
2268
+ return config.appName;
2269
+ }
2270
+ const folderName = directory.split("/").pop() || "app";
2271
+ return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
2272
+ }
2273
+ function linkApp(appName, directory = process.cwd()) {
2274
+ writeAppConfig({ appName }, directory);
2275
+ }
2284
2276
 
2285
2277
  // src/commands/deploy.ts
2286
2278
  var DEFAULT_LOCATION = "eu-central-h1";
@@ -3730,19 +3722,33 @@ Error: ${error.message}
3730
3722
  import { Command as Command10 } from "commander";
3731
3723
  import chalk16 from "chalk";
3732
3724
  import ora10 from "ora";
3733
- import { confirm } from "@inquirer/prompts";
3734
- var DEFAULT_VM_SIZE = "standard-2";
3725
+ import { select as select2, confirm } from "@inquirer/prompts";
3726
+ var DEFAULT_VM_SIZE = "burstable-1";
3735
3727
  var DEFAULT_BOOT_IMAGE = "ubuntu-noble";
3728
+ function parseServiceList(output) {
3729
+ const lines = output.trim().split("\n");
3730
+ const services = [];
3731
+ for (const line of lines) {
3732
+ const trimmed = line.trim();
3733
+ if (!trimmed || trimmed.startsWith("NAME") || trimmed.startsWith("Connecting") || trimmed.startsWith("Connected")) {
3734
+ continue;
3735
+ }
3736
+ const svcName = trimmed.split(/\s+/)[0];
3737
+ if (svcName) {
3738
+ services.push(svcName);
3739
+ }
3740
+ }
3741
+ return services;
3742
+ }
3736
3743
  function createScaleCommand() {
3737
3744
  const cmd = new Command10("scale");
3738
- cmd.description("Scale an app by adding or removing nodes").argument("[count]", "Target node count, or +N/-N to add/remove nodes").option("--app <app>", "App name (uses hackerrun.yaml if not specified)").option("--size <size>", `VM size for new nodes (default: ${DEFAULT_VM_SIZE})`).option("--force", "Skip confirmation when removing nodes").action(async (countArg, options) => {
3745
+ cmd.description("Scale a service in your app").argument("<app>", "App name").argument("[service]", "Service name (optional - will prompt if not provided)").argument("[replicas]", "Number of replicas").option("--add-machines", "Automatically add machines if needed").option("--no-add-machine", "Scale on existing machines only").option("--size <size>", `VM size for new machines (default: ${DEFAULT_VM_SIZE})`).action(async (appName, serviceArg, replicasArg, options) => {
3739
3746
  let uncloudRunner = null;
3740
3747
  try {
3741
- const appName = options.app || getAppName();
3742
3748
  const platformToken = getPlatformToken();
3743
3749
  const platformClient = new PlatformClient(platformToken);
3744
- const clusterManager = new ClusterManager(platformClient);
3745
3750
  uncloudRunner = new UncloudRunner(platformClient);
3751
+ const clusterManager = new ClusterManager(platformClient, uncloudRunner);
3746
3752
  const app = await platformClient.getApp(appName);
3747
3753
  if (!app) {
3748
3754
  console.log(chalk16.red(`
@@ -3752,146 +3758,122 @@ App '${appName}' not found
3752
3758
  `);
3753
3759
  process.exit(1);
3754
3760
  }
3755
- const currentNodeCount = app.nodes.length;
3756
- if (!countArg) {
3761
+ let services;
3762
+ const spinner = ora10("Fetching services...").start();
3763
+ try {
3764
+ const output = await uncloudRunner.serviceList(appName);
3765
+ services = parseServiceList(output);
3766
+ spinner.stop();
3767
+ } catch (error) {
3768
+ spinner.fail("Failed to fetch services");
3769
+ console.log(chalk16.yellow("\nMake sure the app is deployed and running.\n"));
3770
+ process.exit(1);
3771
+ }
3772
+ if (services.length === 0) {
3773
+ console.log(chalk16.yellow(`
3774
+ No services found in app '${appName}'`));
3775
+ console.log(`Run ${chalk16.bold("hackerrun deploy")} to deploy your app first.
3776
+ `);
3777
+ process.exit(1);
3778
+ }
3779
+ let serviceName;
3780
+ let replicas;
3781
+ if (!serviceArg) {
3757
3782
  console.log(chalk16.cyan(`
3758
- App '${appName}' Scale Info:
3783
+ Services in '${appName}':
3759
3784
  `));
3760
- console.log(` Current nodes: ${chalk16.bold(currentNodeCount.toString())}`);
3761
- console.log();
3762
- app.nodes.forEach((node, index) => {
3763
- const primaryLabel = node.isPrimary ? chalk16.yellow(" (primary)") : "";
3764
- console.log(` ${index + 1}. ${chalk16.bold(node.name)}${primaryLabel}`);
3765
- console.log(` IPv6: ${node.ipv6 || "pending"}`);
3766
- console.log();
3785
+ serviceName = await select2({
3786
+ message: "Which service would you like to scale?",
3787
+ choices: services.map((s) => ({ name: s, value: s }))
3767
3788
  });
3768
- console.log(chalk16.dim("Usage:"));
3769
- console.log(chalk16.dim(` hackerrun scale 3 # Scale to 3 nodes`));
3770
- console.log(chalk16.dim(` hackerrun scale +1 # Add 1 node`));
3771
- console.log(chalk16.dim(` hackerrun scale -1 # Remove 1 node`));
3772
- console.log();
3773
- return;
3774
- }
3775
- let targetCount;
3776
- if (countArg.startsWith("+")) {
3777
- const delta = parseInt(countArg.slice(1), 10);
3778
- if (isNaN(delta) || delta <= 0) {
3779
- console.log(chalk16.red(`
3780
- Invalid count: ${countArg}
3789
+ if (!replicasArg) {
3790
+ console.log(chalk16.red("\nPlease specify the number of replicas.\n"));
3791
+ console.log(chalk16.dim(`Usage: hackerrun scale ${appName} ${serviceName} <replicas>
3781
3792
  `));
3782
3793
  process.exit(1);
3783
3794
  }
3784
- targetCount = currentNodeCount + delta;
3785
- } else if (countArg.startsWith("-")) {
3786
- const delta = parseInt(countArg.slice(1), 10);
3787
- if (isNaN(delta) || delta <= 0) {
3788
- console.log(chalk16.red(`
3789
- Invalid count: ${countArg}
3795
+ replicas = parseInt(replicasArg, 10);
3796
+ } else if (!replicasArg) {
3797
+ const maybeReplicas = parseInt(serviceArg, 10);
3798
+ if (!isNaN(maybeReplicas)) {
3799
+ replicas = maybeReplicas;
3800
+ console.log(chalk16.cyan(`
3801
+ Services in '${appName}':
3802
+ `));
3803
+ serviceName = await select2({
3804
+ message: "Which service would you like to scale?",
3805
+ choices: services.map((s) => ({ name: s, value: s }))
3806
+ });
3807
+ } else {
3808
+ console.log(chalk16.red("\nPlease specify the number of replicas.\n"));
3809
+ console.log(chalk16.dim(`Usage: hackerrun scale ${appName} ${serviceArg} <replicas>
3790
3810
  `));
3791
3811
  process.exit(1);
3792
3812
  }
3793
- targetCount = currentNodeCount - delta;
3794
3813
  } else {
3795
- targetCount = parseInt(countArg, 10);
3796
- if (isNaN(targetCount)) {
3814
+ serviceName = serviceArg;
3815
+ replicas = parseInt(replicasArg, 10);
3816
+ if (!services.includes(serviceName)) {
3797
3817
  console.log(chalk16.red(`
3798
- Invalid count: ${countArg}
3818
+ Service '${serviceName}' not found in app '${appName}'.
3799
3819
  `));
3820
+ console.log(chalk16.cyan("Available services:"));
3821
+ services.forEach((s) => console.log(` - ${s}`));
3822
+ console.log();
3800
3823
  process.exit(1);
3801
3824
  }
3802
3825
  }
3803
- if (targetCount < 1) {
3804
- console.log(chalk16.red(`
3805
- Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
3806
- `));
3826
+ if (isNaN(replicas) || replicas < 1) {
3827
+ console.log(chalk16.red("\nReplicas must be a positive number.\n"));
3807
3828
  process.exit(1);
3808
3829
  }
3809
- if (targetCount === currentNodeCount) {
3810
- console.log(chalk16.yellow(`
3811
- App already has ${currentNodeCount} node(s). Nothing to do.
3830
+ const currentMachineCount = app.nodes.length;
3831
+ console.log(chalk16.cyan(`
3832
+ Scaling '${serviceName}' to ${replicas} replica(s)...`));
3833
+ console.log(chalk16.dim(`Current machines: ${currentMachineCount}
3812
3834
  `));
3813
- return;
3814
- }
3815
- const vmSize = options.size || DEFAULT_VM_SIZE;
3816
- if (targetCount > currentNodeCount) {
3817
- const nodesToAdd = targetCount - currentNodeCount;
3818
- console.log(chalk16.cyan(`
3819
- Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (+${nodesToAdd})
3835
+ let shouldAddMachines = false;
3836
+ if (replicas > currentMachineCount) {
3837
+ if (options.addMachines) {
3838
+ shouldAddMachines = true;
3839
+ } else if (options.addMachine === false) {
3840
+ shouldAddMachines = false;
3841
+ console.log(chalk16.yellow(`Scaling ${replicas} replicas across ${currentMachineCount} machine(s).
3820
3842
  `));
3821
- for (let i = 0; i < nodesToAdd; i++) {
3822
- console.log(chalk16.cyan(`
3823
- Adding node ${i + 1} of ${nodesToAdd}...`));
3824
- const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
3825
- console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
3826
- }
3827
- const updatedApp = await platformClient.getApp(appName);
3828
- const syncSpinner = ora10("Syncing gateway routes...").start();
3829
- try {
3830
- await platformClient.syncGatewayRoutes(app.location);
3831
- syncSpinner.succeed("Gateway routes synced");
3832
- } catch (error) {
3833
- syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3834
- }
3835
- console.log(chalk16.green(`
3836
- \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3837
- `));
3838
- console.log(chalk16.dim("To deploy to specific nodes, use x-machines in your compose file:"));
3839
- console.log(chalk16.dim(" services:"));
3840
- console.log(chalk16.dim(" web:"));
3841
- console.log(chalk16.dim(" x-machines:"));
3842
- updatedApp?.nodes.forEach((node) => {
3843
- console.log(chalk16.dim(` - ${node.name}`));
3844
- });
3845
- console.log();
3846
- } else {
3847
- const nodesToRemove = currentNodeCount - targetCount;
3848
- const sortedNodes = [...app.nodes].sort((a, b) => {
3849
- const numA = parseInt(a.name.split("-").pop() || "0", 10);
3850
- const numB = parseInt(b.name.split("-").pop() || "0", 10);
3851
- return numB - numA;
3852
- });
3853
- const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
3854
- console.log(chalk16.yellow(`
3855
- Scaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (-${nodesToRemove})
3843
+ } else {
3844
+ console.log(chalk16.yellow(`You want ${replicas} replicas but only have ${currentMachineCount} machine(s).`));
3845
+ console.log(chalk16.dim(`You can either:`));
3846
+ console.log(chalk16.dim(` \u2022 Add ${replicas - currentMachineCount} new machine(s) for dedicated capacity`));
3847
+ console.log(chalk16.dim(` \u2022 Run ${replicas} replicas on your existing ${currentMachineCount} machine(s)
3856
3848
  `));
3857
- console.log(chalk16.yellow("The following nodes will be removed:"));
3858
- nodesToRemoveList.forEach((node) => {
3859
- console.log(` - ${node.name} (${node.ipv6})`);
3860
- });
3861
- console.log();
3862
- if (!options.force) {
3863
- console.log(chalk16.yellow("Warning: All containers on these nodes will be stopped and the VMs deleted."));
3864
- console.log(chalk16.yellow("Data on these nodes will be lost.\n"));
3865
- const confirmed = await confirm({
3866
- message: "Are you sure you want to remove these nodes?",
3849
+ shouldAddMachines = await confirm({
3850
+ message: `Add ${replicas - currentMachineCount} new machine(s)?`,
3867
3851
  default: false
3868
3852
  });
3869
- if (!confirmed) {
3870
- console.log(chalk16.dim("\nScale cancelled.\n"));
3871
- return;
3853
+ if (!shouldAddMachines) {
3854
+ console.log(chalk16.dim(`
3855
+ Scaling ${replicas} replicas across ${currentMachineCount} existing machine(s).
3856
+ `));
3872
3857
  }
3873
3858
  }
3874
- for (const node of nodesToRemoveList) {
3875
- console.log(chalk16.cyan(`
3876
- Removing node '${node.name}'...`));
3859
+ }
3860
+ if (shouldAddMachines && replicas > currentMachineCount) {
3861
+ const machinesToAdd = replicas - currentMachineCount;
3862
+ const vmSize = options.size || DEFAULT_VM_SIZE;
3863
+ console.log(chalk16.cyan(`
3864
+ Adding ${machinesToAdd} machine(s)...
3865
+ `));
3866
+ for (let i = 0; i < machinesToAdd; i++) {
3867
+ console.log(chalk16.cyan(`Adding machine ${i + 1} of ${machinesToAdd}...`));
3877
3868
  try {
3878
- const spinner = ora10("Removing from uncloud cluster...").start();
3879
- await uncloudRunner.removeNode(appName, node.name);
3880
- spinner.succeed("Removed from cluster");
3881
- spinner.start("Deleting VM...");
3882
- await platformClient.deleteVM(app.location, node.name);
3883
- spinner.succeed("VM deleted");
3884
- spinner.start("Updating app state...");
3885
- const updatedNodes = app.nodes.filter((n) => n.name !== node.name);
3886
- app.nodes = updatedNodes;
3887
- await platformClient.saveApp(app);
3888
- spinner.succeed("App state updated");
3889
- console.log(chalk16.green(` Removed: ${node.name}`));
3869
+ const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
3870
+ console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
3890
3871
  } catch (error) {
3891
- console.log(chalk16.red(` Failed to remove ${node.name}: ${error.message}`));
3872
+ console.log(chalk16.red(` Failed to add machine: ${error.message}`));
3873
+ console.log(chalk16.yellow(" Continuing with available machines...\n"));
3874
+ break;
3892
3875
  }
3893
3876
  }
3894
- const updatedApp = await platformClient.getApp(appName);
3895
3877
  const syncSpinner = ora10("Syncing gateway routes...").start();
3896
3878
  try {
3897
3879
  await platformClient.syncGatewayRoutes(app.location);
@@ -3899,10 +3881,22 @@ Removing node '${node.name}'...`));
3899
3881
  } catch (error) {
3900
3882
  syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3901
3883
  }
3902
- console.log(chalk16.green(`
3903
- \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3904
- `));
3905
3884
  }
3885
+ const scaleSpinner = ora10(`Scaling ${serviceName} to ${replicas} replicas...`).start();
3886
+ try {
3887
+ await uncloudRunner.run(appName, "scale", [serviceName, String(replicas)], { stdio: "pipe" });
3888
+ scaleSpinner.succeed(chalk16.green(`Scaled '${serviceName}' to ${replicas} replica(s)`));
3889
+ } catch (error) {
3890
+ scaleSpinner.fail(`Failed to scale service: ${error.message}`);
3891
+ process.exit(1);
3892
+ }
3893
+ const updatedApp = await platformClient.getApp(appName);
3894
+ console.log(chalk16.cyan("\nScale Summary:\n"));
3895
+ console.log(` App: ${chalk16.bold(appName)}`);
3896
+ console.log(` Service: ${chalk16.bold(serviceName)}`);
3897
+ console.log(` Replicas: ${chalk16.bold(String(replicas))}`);
3898
+ console.log(` Machines: ${chalk16.bold(String(updatedApp?.nodes.length || currentMachineCount))}`);
3899
+ console.log();
3906
3900
  } catch (error) {
3907
3901
  console.error(chalk16.red(`
3908
3902
  Error: ${error.message}
@@ -3917,8 +3911,75 @@ Error: ${error.message}
3917
3911
  return cmd;
3918
3912
  }
3919
3913
 
3914
+ // src/commands/services.ts
3915
+ import { Command as Command11 } from "commander";
3916
+ import chalk17 from "chalk";
3917
+ import ora11 from "ora";
3918
+ function createServicesCommand() {
3919
+ const cmd = new Command11("services");
3920
+ cmd.description("List services and their status for an app").argument("[app]", "App name (uses hackerrun.yaml if not specified)").action(async (appArg) => {
3921
+ const platformToken = getPlatformToken();
3922
+ const platformClient = new PlatformClient(platformToken);
3923
+ const uncloudRunner = new UncloudRunner(platformClient);
3924
+ try {
3925
+ const appName = appArg || getAppName();
3926
+ const app = await platformClient.getApp(appName);
3927
+ if (!app) {
3928
+ console.log(chalk17.red(`
3929
+ App '${appName}' not found
3930
+ `));
3931
+ process.exit(1);
3932
+ }
3933
+ const spinner = ora11("Fetching services...").start();
3934
+ try {
3935
+ const output = await uncloudRunner.serviceList(appName);
3936
+ spinner.stop();
3937
+ console.log(chalk17.cyan(`
3938
+ Services in '${appName}':
3939
+ `));
3940
+ const lines = output.trim().split("\n");
3941
+ if (lines.length === 0 || lines.length === 1 && !lines[0].trim()) {
3942
+ console.log(chalk17.yellow(" No services found.\n"));
3943
+ console.log(`Run ${chalk17.bold("hackerrun deploy")} to deploy your app.
3944
+ `);
3945
+ return;
3946
+ }
3947
+ for (const line of lines) {
3948
+ const trimmed = line.trim();
3949
+ if (trimmed.startsWith("Connecting") || trimmed.startsWith("Connected")) {
3950
+ continue;
3951
+ }
3952
+ if (trimmed) {
3953
+ if (trimmed.startsWith("NAME")) {
3954
+ console.log(chalk17.bold(` ${trimmed}`));
3955
+ } else {
3956
+ console.log(` ${trimmed}`);
3957
+ }
3958
+ }
3959
+ }
3960
+ console.log();
3961
+ console.log(chalk17.dim(`Machines: ${app.nodes.length}`));
3962
+ console.log(chalk17.dim(`Scale a service: hackerrun scale ${appName} <service> <replicas>`));
3963
+ console.log();
3964
+ } catch (error) {
3965
+ spinner.fail("Failed to fetch services");
3966
+ console.log(chalk17.yellow("\nMake sure the app is deployed and running.\n"));
3967
+ process.exit(1);
3968
+ }
3969
+ } catch (error) {
3970
+ console.error(chalk17.red(`
3971
+ Error: ${error.message}
3972
+ `));
3973
+ process.exit(1);
3974
+ } finally {
3975
+ uncloudRunner.cleanup();
3976
+ }
3977
+ });
3978
+ return cmd;
3979
+ }
3980
+
3920
3981
  // src/index.ts
3921
- var program = new Command11();
3982
+ var program = new Command12();
3922
3983
  program.name("hackerrun").description("Deploy apps with full control over your infrastructure").version("0.1.0");
3923
3984
  program.addCommand(createLoginCommand());
3924
3985
  program.addCommand(createConfigCommand());
@@ -3930,6 +3991,7 @@ program.addCommand(createEnvCommand());
3930
3991
  program.addCommand(createBuildsCommand());
3931
3992
  program.addCommand(createVPNCommands());
3932
3993
  program.addCommand(createScaleCommand());
3994
+ program.addCommand(createServicesCommand());
3933
3995
  var { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
3934
3996
  program.addCommand(appsCmd);
3935
3997
  program.addCommand(nodesCmd);