hackerrun 0.1.10 → 0.1.11

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
@@ -537,17 +537,592 @@ var TunnelManager = class {
537
537
  }
538
538
  };
539
539
 
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";
548
+ import chalk2 from "chalk";
549
+ function detectOS() {
550
+ const os = platform();
551
+ if (os === "darwin") {
552
+ return { type: "macos" };
553
+ }
554
+ if (os === "win32") {
555
+ return { type: "windows" };
556
+ }
557
+ if (os === "linux") {
558
+ try {
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 };
565
+ }
566
+ }
567
+ } catch {
568
+ }
569
+ return { type: "linux" };
570
+ }
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`;
621
+ }
622
+ default:
623
+ return ` Visit https://www.wireguard.com/install/ for installation instructions`;
624
+ }
625
+ }
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
744
+
745
+ [Interface]
746
+ PrivateKey = ${config.privateKey}
747
+ Address = ${config.address}
748
+
749
+ [Peer]
750
+ PublicKey = ${config.gatewayPublicKey}
751
+ Endpoint = ${config.gatewayEndpoint}
752
+ AllowedIPs = ${config.allowedIPs}
753
+ PersistentKeepalive = 25
754
+ `;
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);
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;
802
+ }
803
+ }
804
+ var VPNManager = class {
805
+ wasConnectedByUs = false;
806
+ /**
807
+ * Ensure VPN is connected if IPv6 is not available
808
+ * Returns true if VPN was established (and should be torn down later)
809
+ */
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;
820
+ }
821
+ console.log(chalk2.yellow("VPN is up but cannot reach target, reconnecting..."));
822
+ vpnDown();
823
+ }
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;
845
+ }
846
+ /**
847
+ * Disconnect VPN if we established it
848
+ */
849
+ disconnect() {
850
+ if (this.wasConnectedByUs && isVPNUp()) {
851
+ console.log(chalk2.dim("Disconnecting VPN..."));
852
+ try {
853
+ vpnDown();
854
+ console.log(chalk2.green("\u2713 VPN disconnected"));
855
+ } catch (error) {
856
+ console.log(chalk2.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
857
+ }
858
+ this.wasConnectedByUs = false;
859
+ }
860
+ }
861
+ };
862
+
863
+ // src/lib/uncloud-runner.ts
864
+ import chalk3 from "chalk";
865
+ var UncloudRunner = class {
866
+ constructor(platformClient) {
867
+ this.platformClient = platformClient;
868
+ this.certManager = new SSHCertManager(platformClient);
869
+ }
870
+ certManager;
871
+ gatewayCache = /* @__PURE__ */ new Map();
872
+ tunnelManager = new TunnelManager();
873
+ vpnManager = new VPNManager();
874
+ tempConfigPath = null;
875
+ vpnEstablishedByUs = false;
876
+ /**
877
+ * Get the connection URL for an app's primary VM
878
+ * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
879
+ */
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
+ };
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..."));
930
+ }
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();
936
+ }
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
+ };
946
+ }
947
+ /**
948
+ * Get VPN configuration from platform
949
+ */
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
+ };
961
+ }
962
+ /**
963
+ * Pre-accept a host's SSH key by running ssh-keyscan
964
+ * This prevents "Host key verification failed" errors from uncloud
965
+ */
966
+ preAcceptHostKey(host, port) {
967
+ try {
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
+ );
973
+ } catch {
974
+ }
975
+ }
976
+ /**
977
+ * Ensure an SSH tunnel exists for gateway fallback
978
+ */
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}`);
983
+ }
984
+ return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
985
+ }
986
+ /**
987
+ * Run an uncloud command on the app's VM
988
+ */
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...`));
993
+ }
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}`);
1003
+ }
1004
+ } else {
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;
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Run 'uc deploy' for an app
1015
+ */
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}`;
1046
+ }
1047
+ const token = execSync3(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
1048
+ return token;
1049
+ }
1050
+ /**
1051
+ * Execute an SSH command on the VM
1052
+ */
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();
1062
+ }
1063
+ /**
1064
+ * Remove a node from the uncloud cluster
1065
+ * Uses 'uc machine rm' to drain containers and remove from cluster
1066
+ */
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})`);
1076
+ }
1077
+ }
1078
+ /**
1079
+ * List machines in the uncloud cluster
1080
+ */
1081
+ async listMachines(appName) {
1082
+ const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
1083
+ return result;
1084
+ }
1085
+ /**
1086
+ * Get gateway info (cached)
1087
+ */
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;
1099
+ }
1100
+ /**
1101
+ * Clean up all SSH sessions, tunnels, and VPN
1102
+ */
1103
+ cleanup() {
1104
+ this.tunnelManager.closeAll();
1105
+ this.certManager.cleanupAll();
1106
+ if (this.vpnEstablishedByUs) {
1107
+ this.vpnManager.disconnect();
1108
+ this.vpnEstablishedByUs = false;
1109
+ }
1110
+ }
1111
+ };
1112
+
540
1113
  // src/lib/cluster.ts
541
- import { execSync as execSync2 } from "child_process";
1114
+ import { execSync as execSync4 } from "child_process";
542
1115
  import ora2 from "ora";
543
- import chalk2 from "chalk";
1116
+ import chalk4 from "chalk";
544
1117
  var platformKeysCache = null;
545
1118
  var ClusterManager = class {
546
- constructor(platformClient) {
1119
+ constructor(platformClient, uncloudRunner) {
547
1120
  this.platformClient = platformClient;
548
1121
  this.sshCertManager = new SSHCertManager(platformClient);
1122
+ this.uncloudRunner = uncloudRunner || new UncloudRunner(platformClient);
549
1123
  }
550
1124
  sshCertManager;
1125
+ uncloudRunner;
551
1126
  /**
552
1127
  * Get platform SSH keys (cached for the session)
553
1128
  * Returns CA public key and platform public key for VM creation
@@ -600,7 +1175,7 @@ var ClusterManager = class {
600
1175
  });
601
1176
  spinner.text = `Waiting for VM to be ready...`;
602
1177
  spinner.stop();
603
- console.log(chalk2.cyan("\nWaiting for VM to get an IPv6 address..."));
1178
+ console.log(chalk4.cyan("\nWaiting for VM to get an IPv6 address..."));
604
1179
  const vmWithIp = await this.waitForVM(location, vmName, 600, false);
605
1180
  spinner = ora2("Setting up VM...").start();
606
1181
  spinner.text = "Waiting for SSH to be ready...";
@@ -623,17 +1198,17 @@ var ClusterManager = class {
623
1198
  const savedCluster = await this.platformClient.saveApp(cluster);
624
1199
  spinner.text = "Installing Docker and Uncloud...";
625
1200
  spinner.stop();
626
- console.log(chalk2.cyan("\nInitializing uncloud (this may take a few minutes)..."));
1201
+ console.log(chalk4.cyan("\nInitializing uncloud (this may take a few minutes)..."));
627
1202
  await this.initializeUncloud(vmWithIp.ip6, appName, gateway?.ipv4);
628
1203
  spinner = ora2("Configuring Docker for NAT64...").start();
629
1204
  await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
630
1205
  spinner.text = "Waiting for uncloud services to be ready...";
631
1206
  await this.waitForUncloudReady(vmWithIp.ip6, gateway?.ipv4);
632
1207
  this.sshCertManager.cleanupAll();
633
- spinner.succeed(chalk2.green(`Cluster initialized successfully`));
1208
+ spinner.succeed(chalk4.green(`Cluster initialized successfully`));
634
1209
  return savedCluster;
635
1210
  } catch (error) {
636
- spinner.fail(chalk2.red("Failed to initialize cluster"));
1211
+ spinner.fail(chalk4.red("Failed to initialize cluster"));
637
1212
  throw error;
638
1213
  }
639
1214
  }
@@ -669,7 +1244,7 @@ var ClusterManager = class {
669
1244
  });
670
1245
  spinner.text = `Waiting for VM to be ready...`;
671
1246
  spinner.stop();
672
- console.log(chalk2.cyan("\nWaiting for VM to get an IPv6 address..."));
1247
+ console.log(chalk4.cyan("\nWaiting for VM to get an IPv6 address..."));
673
1248
  const vmWithIp = await this.waitForVM(cluster.location, vmName, 600, false);
674
1249
  spinner = ora2("Setting up VM...").start();
675
1250
  await this.sleep(3e4);
@@ -677,11 +1252,11 @@ var ClusterManager = class {
677
1252
  await this.platformClient.setupVM(vmWithIp.ip6, cluster.location, appName);
678
1253
  spinner.text = "Joining uncloud cluster...";
679
1254
  spinner.stop();
680
- console.log(chalk2.cyan("\nJoining uncloud cluster..."));
681
- await this.joinUncloudCluster(vmWithIp.ip6, existingNode.ipv6, appName);
1255
+ console.log(chalk4.cyan("\nJoining uncloud cluster..."));
1256
+ await this.joinUncloudCluster(vmWithIp.ip6, appName);
682
1257
  spinner = ora2("Configuring Docker for NAT64...").start();
683
1258
  await this.configureDockerNAT64(vmWithIp.ip6, gateway?.ipv4);
684
- spinner.succeed(chalk2.green(`Node '${vmName}' added successfully`));
1259
+ spinner.succeed(chalk4.green(`Node '${vmName}' added successfully`));
685
1260
  const newNode = {
686
1261
  name: vmName,
687
1262
  id: vm.id,
@@ -692,7 +1267,7 @@ var ClusterManager = class {
692
1267
  await this.platformClient.saveApp(cluster);
693
1268
  return newNode;
694
1269
  } catch (error) {
695
- spinner.fail(chalk2.red("Failed to add node"));
1270
+ spinner.fail(chalk4.red("Failed to add node"));
696
1271
  throw error;
697
1272
  }
698
1273
  }
@@ -716,32 +1291,32 @@ var ClusterManager = class {
716
1291
  if (!gatewayIp) {
717
1292
  throw new Error("No direct IPv6 connectivity and no gateway available");
718
1293
  }
719
- console.log(chalk2.dim("No direct IPv6 connectivity, tunneling through gateway..."));
1294
+ console.log(chalk4.dim("No direct IPv6 connectivity, tunneling through gateway..."));
720
1295
  tunnel = await createTunnel(vmIp, gatewayIp);
721
1296
  targetHost = `localhost:${tunnel.localPort}`;
722
1297
  }
723
1298
  let initSucceeded = false;
724
1299
  try {
725
- execSync2(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
1300
+ execSync4(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
726
1301
  stdio: "inherit",
727
1302
  timeout: 6e5
728
1303
  // 10 min timeout
729
1304
  });
730
1305
  initSucceeded = true;
731
1306
  } catch (error) {
732
- console.log(chalk2.yellow("\nInitial setup had errors, will attempt to recover..."));
1307
+ console.log(chalk4.yellow("\nInitial setup had errors, will attempt to recover..."));
733
1308
  }
734
1309
  if (!initSucceeded) {
735
1310
  const maxCaddyAttempts = 5;
736
1311
  for (let attempt = 1; attempt <= maxCaddyAttempts; attempt++) {
737
- console.log(chalk2.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
1312
+ console.log(chalk4.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
738
1313
  await new Promise((resolve) => setTimeout(resolve, 1e4));
739
1314
  try {
740
- execSync2(`yes | uc -c "${contextName}" caddy deploy`, {
1315
+ execSync4(`yes | uc -c "${contextName}" caddy deploy`, {
741
1316
  stdio: "inherit",
742
1317
  timeout: 12e4
743
1318
  });
744
- console.log(chalk2.green("Caddy deployed successfully"));
1319
+ console.log(chalk4.green("Caddy deployed successfully"));
745
1320
  break;
746
1321
  } catch (caddyError) {
747
1322
  if (attempt >= maxCaddyAttempts) {
@@ -750,7 +1325,7 @@ var ClusterManager = class {
750
1325
  }
751
1326
  }
752
1327
  }
753
- console.log(chalk2.dim("Uncloud initialization complete."));
1328
+ console.log(chalk4.dim("Uncloud initialization complete."));
754
1329
  } catch (error) {
755
1330
  throw new Error(`Failed to initialize uncloud: ${error.message}`);
756
1331
  } finally {
@@ -761,20 +1336,15 @@ var ClusterManager = class {
761
1336
  }
762
1337
  /**
763
1338
  * Join a new node to an existing uncloud cluster
764
- * Uses --connect ssh:// to avoid local context dependency
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
765
1344
  */
766
- async joinUncloudCluster(newVmIp, primaryVmIp, contextName) {
1345
+ async joinUncloudCluster(newVmIp, appName) {
767
1346
  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");
776
- }
777
- execSync2(`uc --connect ssh://root@${primaryVmIp} machine add root@${newVmIp} --token "${token}"`, {
1347
+ await this.uncloudRunner.run(appName, "machine", ["add", `root@${newVmIp}`, "--no-caddy"], {
778
1348
  stdio: "inherit",
779
1349
  timeout: 6e5
780
1350
  // 10 min timeout
@@ -868,7 +1438,7 @@ echo "Docker NAT64 configuration complete"
868
1438
  sshHost = "localhost";
869
1439
  sshPortArgs = `-p ${tunnel.localPort}`;
870
1440
  }
871
- execSync2(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
1441
+ execSync4(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
872
1442
  ${setupScript}
873
1443
  REMOTESCRIPT`, {
874
1444
  stdio: "inherit",
@@ -894,13 +1464,13 @@ REMOTESCRIPT`, {
894
1464
  while (Date.now() - startTime < timeoutMs) {
895
1465
  const vm = await this.platformClient.getVM(location, vmName);
896
1466
  if (vm.status && vm.status !== lastStatus) {
897
- console.log(chalk2.dim(` Status: ${vm.status}`));
1467
+ console.log(chalk4.dim(` Status: ${vm.status}`));
898
1468
  lastStatus = vm.status;
899
1469
  }
900
1470
  const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
901
1471
  if (hasRequiredIp) {
902
1472
  const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
903
- console.log(chalk2.green(` IP assigned: ${ipDisplay}`));
1473
+ console.log(chalk4.green(` IP assigned: ${ipDisplay}`));
904
1474
  return vm;
905
1475
  }
906
1476
  const elapsed = Math.floor((Date.now() - startTime) / 1e3);
@@ -929,27 +1499,27 @@ The VM may still be provisioning. You can:
929
1499
  const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
930
1500
  const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
931
1501
  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..."));
1502
+ console.log(chalk4.dim(" Waiting for uncloud services..."));
933
1503
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
934
1504
  try {
935
- const result = execSync2(
1505
+ const result = execSync4(
936
1506
  `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
937
1507
  { encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
938
1508
  ).trim();
939
1509
  if (result.includes("ready")) {
940
- console.log(chalk2.dim(" Uncloud services ready"));
1510
+ console.log(chalk4.dim(" Uncloud services ready"));
941
1511
  return;
942
1512
  }
943
1513
  } catch (error) {
944
1514
  }
945
1515
  if (attempt % 5 === 0) {
946
- console.log(chalk2.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
1516
+ console.log(chalk4.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
947
1517
  }
948
1518
  if (attempt < maxAttempts) {
949
1519
  await this.sleep(checkInterval);
950
1520
  }
951
1521
  }
952
- console.log(chalk2.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
1522
+ console.log(chalk4.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
953
1523
  }
954
1524
  /**
955
1525
  * Generate a sequential node name: appName-1, appName-2, etc.
@@ -978,21 +1548,21 @@ The VM may still be provisioning. You can:
978
1548
  };
979
1549
 
980
1550
  // src/lib/platform.ts
981
- import { platform } from "os";
982
- import chalk3 from "chalk";
1551
+ import { platform as platform2 } from "os";
1552
+ import chalk5 from "chalk";
983
1553
  var PlatformDetector = class {
984
1554
  /**
985
1555
  * Check if running on Windows
986
1556
  */
987
1557
  static isWindows() {
988
- return platform() === "win32";
1558
+ return platform2() === "win32";
989
1559
  }
990
1560
  /**
991
1561
  * Check if running inside WSL (Windows Subsystem for Linux)
992
1562
  */
993
1563
  static isWSL() {
994
1564
  if (!this.isWindows()) {
995
- if (platform() === "linux") {
1565
+ if (platform2() === "linux") {
996
1566
  try {
997
1567
  const fs = __require("fs");
998
1568
  const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
@@ -1009,40 +1579,40 @@ var PlatformDetector = class {
1009
1579
  * Check if platform is supported for hackerrun
1010
1580
  */
1011
1581
  static isSupported() {
1012
- return platform() === "darwin" || platform() === "linux" || this.isWSL();
1582
+ return platform2() === "darwin" || platform2() === "linux" || this.isWSL();
1013
1583
  }
1014
1584
  /**
1015
1585
  * Get platform name for display
1016
1586
  */
1017
1587
  static getPlatformName() {
1018
1588
  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();
1589
+ if (platform2() === "win32") return "Windows";
1590
+ if (platform2() === "darwin") return "macOS";
1591
+ if (platform2() === "linux") return "Linux";
1592
+ return platform2();
1023
1593
  }
1024
1594
  /**
1025
1595
  * Ensure platform is supported, exit with helpful message if not
1026
1596
  */
1027
1597
  static ensureSupported() {
1028
1598
  if (this.isWindows() && !this.isWSL()) {
1029
- console.log(chalk3.yellow("\n\u26A0\uFE0F Windows detected\n"));
1599
+ console.log(chalk5.yellow("\n\u26A0\uFE0F Windows detected\n"));
1030
1600
  console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
1031
1601
  console.log("Uncloud and SSH tools work best in a Linux environment.\n");
1032
- console.log(chalk3.cyan("How to set up WSL:\n"));
1602
+ console.log(chalk5.cyan("How to set up WSL:\n"));
1033
1603
  console.log("1. Open PowerShell as Administrator and run:");
1034
- console.log(chalk3.bold(" wsl --install\n"));
1604
+ console.log(chalk5.bold(" wsl --install\n"));
1035
1605
  console.log("2. Restart your computer\n");
1036
1606
  console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
1037
1607
  console.log("4. Open WSL terminal and install hackerrun:");
1038
- console.log(chalk3.bold(" npm install -g hackerrun\n"));
1608
+ console.log(chalk5.bold(" npm install -g hackerrun\n"));
1039
1609
  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"));
1610
+ console.log(chalk5.red("Please install WSL and run hackerrun from WSL terminal.\n"));
1041
1611
  process.exit(1);
1042
1612
  }
1043
1613
  if (!this.isSupported()) {
1044
- console.log(chalk3.red(`
1045
- \u274C Platform '${platform()}' is not supported
1614
+ console.log(chalk5.red(`
1615
+ \u274C Platform '${platform2()}' is not supported
1046
1616
  `));
1047
1617
  console.log("Hackerrun supports:");
1048
1618
  console.log(" - macOS");
@@ -1054,9 +1624,9 @@ var PlatformDetector = class {
1054
1624
  };
1055
1625
 
1056
1626
  // src/lib/uncloud.ts
1057
- import { execSync as execSync3 } from "child_process";
1058
- import { platform as platform2 } from "os";
1059
- import chalk4 from "chalk";
1627
+ import { execSync as execSync5 } from "child_process";
1628
+ import { platform as platform3 } from "os";
1629
+ import chalk6 from "chalk";
1060
1630
  import ora3 from "ora";
1061
1631
  var UncloudManager = class {
1062
1632
  /**
@@ -1064,7 +1634,7 @@ var UncloudManager = class {
1064
1634
  */
1065
1635
  static isInstalled() {
1066
1636
  try {
1067
- execSync3("uc --version", { stdio: "pipe" });
1637
+ execSync5("uc --version", { stdio: "pipe" });
1068
1638
  return true;
1069
1639
  } catch {
1070
1640
  return false;
@@ -1075,7 +1645,7 @@ var UncloudManager = class {
1075
1645
  */
1076
1646
  static getVersion() {
1077
1647
  try {
1078
- const version = execSync3("uc --version", { encoding: "utf-8" }).trim();
1648
+ const version = execSync5("uc --version", { encoding: "utf-8" }).trim();
1079
1649
  return version;
1080
1650
  } catch {
1081
1651
  return null;
@@ -1090,34 +1660,34 @@ var UncloudManager = class {
1090
1660
  return;
1091
1661
  }
1092
1662
  if (options?.nonInteractive) {
1093
- console.error(chalk4.red("\n\u274C Uncloud CLI (uc) not found\n"));
1663
+ console.error(chalk6.red("\n\u274C Uncloud CLI (uc) not found\n"));
1094
1664
  console.error("The uncloud CLI must be installed on the build VM.");
1095
1665
  console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
1096
1666
  process.exit(1);
1097
1667
  }
1098
- console.log(chalk4.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
1668
+ console.log(chalk6.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
1099
1669
  console.log("Uncloud is required to deploy and manage your apps.");
1100
1670
  console.log("Learn more: https://uncloud.run\n");
1101
- const os = platform2();
1671
+ const os = platform3();
1102
1672
  if (os === "darwin" || os === "linux") {
1103
1673
  this.showInstallInstructions();
1104
- console.log(chalk4.cyan("Would you like to install uncloud now? (Y/n): "));
1674
+ console.log(chalk6.cyan("Would you like to install uncloud now? (Y/n): "));
1105
1675
  const answer = await this.promptUser();
1106
1676
  if (answer === "y" || answer === "yes" || answer === "") {
1107
1677
  try {
1108
1678
  await this.autoInstall();
1109
1679
  return;
1110
1680
  } catch (error) {
1111
- console.log(chalk4.red("\nAuto-installation failed. Please install manually.\n"));
1681
+ console.log(chalk6.red("\nAuto-installation failed. Please install manually.\n"));
1112
1682
  process.exit(1);
1113
1683
  }
1114
1684
  } else {
1115
- console.log(chalk4.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
1685
+ console.log(chalk6.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
1116
1686
  process.exit(1);
1117
1687
  }
1118
1688
  } else {
1119
1689
  this.showInstallInstructions();
1120
- console.log(chalk4.red("Please install uncloud and run hackerrun again.\n"));
1690
+ console.log(chalk6.red("Please install uncloud and run hackerrun again.\n"));
1121
1691
  process.exit(1);
1122
1692
  }
1123
1693
  }
@@ -1125,20 +1695,20 @@ var UncloudManager = class {
1125
1695
  * Show installation instructions based on platform
1126
1696
  */
1127
1697
  static showInstallInstructions() {
1128
- const os = platform2();
1129
- console.log(chalk4.cyan("Installation instructions:\n"));
1698
+ const os = platform3();
1699
+ console.log(chalk6.cyan("Installation instructions:\n"));
1130
1700
  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"));
1701
+ console.log(chalk6.bold("Option 1: Automated install (Recommended)"));
1702
+ console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1703
+ console.log(chalk6.bold("Option 2: Homebrew"));
1704
+ console.log(chalk6.green(" brew install psviderski/tap/uncloud\n"));
1705
+ console.log(chalk6.bold("Option 3: Manual download"));
1136
1706
  console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
1137
1707
  console.log(" Extract and move to /usr/local/bin\n");
1138
1708
  } else if (os === "win32") {
1139
- console.log(chalk4.bold("For Windows (via WSL):"));
1709
+ console.log(chalk6.bold("For Windows (via WSL):"));
1140
1710
  console.log(" Open your WSL terminal and run:");
1141
- console.log(chalk4.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1711
+ console.log(chalk6.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1142
1712
  }
1143
1713
  console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
1144
1714
  }
@@ -1163,27 +1733,27 @@ var UncloudManager = class {
1163
1733
  static async autoInstall() {
1164
1734
  const spinner = ora3("Installing uncloud...").start();
1165
1735
  try {
1166
- execSync3("curl -fsS https://get.uncloud.run/install.sh | sh", {
1736
+ execSync5("curl -fsS https://get.uncloud.run/install.sh | sh", {
1167
1737
  stdio: "inherit"
1168
1738
  });
1169
- spinner.succeed(chalk4.green("Uncloud installed successfully!"));
1739
+ spinner.succeed(chalk6.green("Uncloud installed successfully!"));
1170
1740
  if (this.isInstalled()) {
1171
1741
  const version = this.getVersion();
1172
- console.log(chalk4.cyan(`\u2713 Uncloud ${version} is ready to use
1742
+ console.log(chalk6.cyan(`\u2713 Uncloud ${version} is ready to use
1173
1743
  `));
1174
1744
  } else {
1175
- spinner.warn(chalk4.yellow("Installation completed but uc command not found in PATH"));
1176
- console.log(chalk4.yellow("\nYou may need to:"));
1745
+ spinner.warn(chalk6.yellow("Installation completed but uc command not found in PATH"));
1746
+ console.log(chalk6.yellow("\nYou may need to:"));
1177
1747
  console.log(" 1. Restart your terminal");
1178
1748
  console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
1179
1749
  throw new Error("Please restart your terminal and try again");
1180
1750
  }
1181
1751
  } catch (error) {
1182
- spinner.fail(chalk4.red("Failed to install uncloud"));
1183
- console.log(chalk4.red(`
1752
+ spinner.fail(chalk6.red("Failed to install uncloud"));
1753
+ console.log(chalk6.red(`
1184
1754
  Error: ${error.message}
1185
1755
  `));
1186
- console.log(chalk4.yellow("Please try manual installation:"));
1756
+ console.log(chalk6.yellow("Please try manual installation:"));
1187
1757
  console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
1188
1758
  throw error;
1189
1759
  }
@@ -1191,16 +1761,16 @@ Error: ${error.message}
1191
1761
  };
1192
1762
 
1193
1763
  // src/lib/platform-auth.ts
1194
- import chalk5 from "chalk";
1764
+ import chalk7 from "chalk";
1195
1765
  function getPlatformToken() {
1196
1766
  const configManager = new ConfigManager();
1197
1767
  try {
1198
1768
  const config = configManager.load();
1199
1769
  return config.apiToken;
1200
1770
  } 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")}
1771
+ console.error(chalk7.red("\n Not logged in"));
1772
+ console.log(chalk7.cyan("\nPlease login first:\n"));
1773
+ console.log(` ${chalk7.bold("hackerrun login")}
1204
1774
  `);
1205
1775
  process.exit(1);
1206
1776
  }
@@ -1412,243 +1982,12 @@ var PlatformClient = class {
1412
1982
  await this.request("POST", "/api/uncloud/config", { config: configYaml });
1413
1983
  }
1414
1984
  /**
1415
- * Download uncloud config from platform
1416
- */
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;
1424
- }
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;
1439
- }
1440
- throw error;
1441
- }
1442
- }
1443
- // ==================== Route Management ====================
1444
- /**
1445
- * Register a route for an app (maps hostname to VM IPv6)
1446
- */
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;
1454
- }
1455
- /**
1456
- * Delete routes for an app
1457
- */
1458
- async deleteRoute(appName) {
1459
- await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
1460
- }
1461
- // ==================== Gateway Route Sync ====================
1462
- /**
1463
- * Sync gateway Caddy configuration for a location
1464
- * This updates the gateway's reverse proxy config with current routes
1465
- */
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 };
1469
- }
1470
- // ==================== SSH Certificate Management ====================
1471
- /**
1472
- * Get platform SSH keys (CA public key + platform public key for VM creation)
1473
- */
1474
- async getPlatformSSHKeys() {
1475
- return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1476
- }
1477
- /**
1478
- * Initialize platform SSH keys (creates them if they don't exist)
1479
- */
1480
- async initPlatformSSHKeys() {
1481
- return this.request("POST", "/api/platform/ssh-keys");
1482
- }
1483
- /**
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
1486
- */
1487
- async requestSSHCertificate(appName, publicKey) {
1488
- return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1489
- }
1490
- // ==================== VM Setup ====================
1491
- /**
1492
- * Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
1493
- * This runs all configuration using the platform SSH key
1494
- */
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 });
1501
- }
1502
- // ==================== Environment Variables ====================
1503
- /**
1504
- * Set a single environment variable
1505
- */
1506
- async setEnvVar(appName, key, value) {
1507
- await this.request("POST", `/api/apps/${appName}/env`, { key, value });
1508
- }
1509
- /**
1510
- * Set multiple environment variables at once
1511
- */
1512
- async setEnvVars(appName, vars) {
1513
- await this.request("POST", `/api/apps/${appName}/env`, { vars });
1514
- }
1515
- /**
1516
- * List environment variables (values are masked)
1517
- */
1518
- async listEnvVars(appName) {
1519
- const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
1520
- return envVars;
1521
- }
1522
- /**
1523
- * Remove an environment variable
1524
- */
1525
- async unsetEnvVar(appName, key) {
1526
- await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
1527
- }
1528
- // ==================== GitHub Connection ====================
1529
- /**
1530
- * Create app metadata without creating VM (for connect-before-deploy flow)
1531
- */
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;
1540
- }
1541
- /**
1542
- * Initiate GitHub App installation flow
1543
- */
1544
- async initiateGitHubConnect() {
1545
- return this.request("POST", "/api/github/connect");
1546
- }
1547
- /**
1548
- * Poll for GitHub App installation completion
1549
- */
1550
- async pollGitHubConnect(stateToken) {
1551
- return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
1552
- }
1553
- /**
1554
- * Get current user's GitHub App installation
1555
- */
1556
- async getGitHubInstallation() {
1557
- try {
1558
- const { installation } = await this.request("GET", "/api/github/installation");
1559
- return installation;
1560
- } catch (error) {
1561
- if (error.message.includes("404") || error.message.includes("not found")) {
1562
- return null;
1563
- }
1564
- throw error;
1565
- }
1566
- }
1567
- /**
1568
- * Delete current user's GitHub App installation record
1569
- * Used when the installation becomes stale/invalid
1570
- */
1571
- async deleteGitHubInstallation() {
1572
- await this.request("DELETE", "/api/github/installation");
1573
- }
1574
- /**
1575
- * List repositories accessible via GitHub App installation
1576
- */
1577
- async listAccessibleRepos() {
1578
- const { repos } = await this.request("GET", "/api/github/repos");
1579
- return repos;
1580
- }
1581
- /**
1582
- * Connect a repository to an app
1583
- */
1584
- async connectRepo(appName, repoFullName, branch) {
1585
- await this.request("POST", `/api/apps/${appName}/repo`, {
1586
- repoFullName,
1587
- branch: branch || "main"
1588
- });
1589
- }
1590
- /**
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
1606
- */
1607
- async disconnectRepo(appName) {
1608
- await this.request("DELETE", `/api/apps/${appName}/repo`);
1609
- }
1610
- // ==================== Builds ====================
1611
- /**
1612
- * List builds for an app
1613
- */
1614
- async listBuilds(appName, limit = 10) {
1615
- const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
1616
- return builds;
1617
- }
1618
- /**
1619
- * Get build details
1620
- */
1621
- async getBuild(appName, buildId) {
1622
- const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
1623
- return build;
1624
- }
1625
- /**
1626
- * Get build events (for live streaming)
1627
- */
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;
1635
- }
1636
- // ==================== VPN Management ====================
1637
- /**
1638
- * Register VPN peer with the platform
1639
- * This is idempotent - if already registered, returns existing config
1640
- */
1641
- async registerVPNPeer(publicKey, location) {
1642
- const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
1643
- return vpnConfig;
1644
- }
1645
- /**
1646
- * Get existing VPN config if registered
1985
+ * Download uncloud config from platform
1647
1986
  */
1648
- async getVPNConfig(location) {
1987
+ async downloadUncloudConfig() {
1649
1988
  try {
1650
- const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
1651
- return vpnConfig;
1989
+ const { config } = await this.request("GET", "/api/uncloud/config");
1990
+ return config;
1652
1991
  } catch (error) {
1653
1992
  if (error.message.includes("404") || error.message.includes("not found")) {
1654
1993
  return null;
@@ -1656,631 +1995,289 @@ var PlatformClient = class {
1656
1995
  throw error;
1657
1996
  }
1658
1997
  }
1998
+ // ==================== Gateway Management ====================
1659
1999
  /**
1660
- * Unregister VPN peer
2000
+ * Get gateway info for a location
1661
2001
  */
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" };
1725
- }
1726
- if (os === "win32") {
1727
- return { type: "windows" };
1728
- }
1729
- if (os === "linux") {
2002
+ async getGateway(location) {
1730
2003
  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
- }
2004
+ const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
2005
+ return gateway;
2006
+ } catch (error) {
2007
+ if (error.message.includes("404") || error.message.includes("not found")) {
2008
+ return null;
1738
2009
  }
1739
- } catch {
2010
+ throw error;
1740
2011
  }
1741
- return { type: "linux" };
1742
- }
1743
- return { type: "unknown" };
1744
- }
1745
- function getWireGuardInstallInstructions() {
1746
- const os = detectOS();
1747
- switch (os.type) {
1748
- case "macos":
1749
- return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
1750
- case "windows":
1751
- return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
1752
- Or using winget: winget install WireGuard.WireGuard`;
1753
- case "linux":
1754
- switch (os.distro) {
1755
- case "arch":
1756
- case "manjaro":
1757
- case "endeavouros":
1758
- case "artix":
1759
- return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
1760
- case "ubuntu":
1761
- case "debian":
1762
- case "linuxmint":
1763
- case "pop":
1764
- case "elementary":
1765
- case "zorin":
1766
- return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
1767
- case "fedora":
1768
- return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
1769
- case "rhel":
1770
- case "centos":
1771
- case "rocky":
1772
- case "almalinux":
1773
- return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
1774
- (may need EPEL: sudo dnf install epel-release)`;
1775
- case "opensuse":
1776
- case "opensuse-leap":
1777
- case "opensuse-tumbleweed":
1778
- return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
1779
- case "gentoo":
1780
- return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
1781
- case "void":
1782
- return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
1783
- case "alpine":
1784
- return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
1785
- case "nixos":
1786
- return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
1787
- default:
1788
- return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
1789
- Arch: sudo pacman -S wireguard-tools
1790
- Ubuntu/Debian: sudo apt install wireguard
1791
- Fedora: sudo dnf install wireguard-tools
1792
- openSUSE: sudo zypper install wireguard-tools`;
1793
- }
1794
- default:
1795
- return ` Visit https://www.wireguard.com/install/ for installation instructions`;
1796
- }
1797
- }
1798
- var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
1799
- var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
1800
- var WG_INTERFACE = "hackerrun";
1801
- var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
1802
- function isWireGuardInstalled() {
1803
- try {
1804
- execSync4("which wg", { stdio: "ignore" });
1805
- execSync4("which wg-quick", { stdio: "ignore" });
1806
- return true;
1807
- } catch {
1808
- return false;
1809
- }
1810
- }
1811
- function ensureConfigDir() {
1812
- if (!existsSync4(CONFIG_DIR)) {
1813
- mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
1814
- }
1815
- }
1816
- function generateKeyPair() {
1817
- const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
1818
- const publicKey = execSync4("wg pubkey", {
1819
- input: privateKey,
1820
- encoding: "utf-8"
1821
- }).trim();
1822
- return { privateKey, publicKey };
1823
- }
1824
- function getOrCreateKeyPair() {
1825
- ensureConfigDir();
1826
- if (existsSync4(PRIVATE_KEY_FILE)) {
1827
- const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1828
- const publicKey2 = execSync4("wg pubkey", {
1829
- input: privateKey2,
1830
- encoding: "utf-8"
1831
- }).trim();
1832
- return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
1833
- }
1834
- const { privateKey, publicKey } = generateKeyPair();
1835
- writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
1836
- return { privateKey, publicKey, isNew: true };
1837
- }
1838
- function getPublicKey() {
1839
- if (!existsSync4(PRIVATE_KEY_FILE)) {
1840
- return null;
1841
- }
1842
- const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1843
- return execSync4("wg pubkey", {
1844
- input: privateKey,
1845
- encoding: "utf-8"
1846
- }).trim();
1847
- }
1848
- function isVPNUp() {
1849
- try {
1850
- const result = execSync4(`ip link show ${WG_INTERFACE}`, {
1851
- encoding: "utf-8",
1852
- stdio: ["pipe", "pipe", "pipe"]
1853
- });
1854
- return result.includes(WG_INTERFACE);
1855
- } catch {
1856
- return false;
1857
- }
1858
- }
1859
- function isRoutedViaVPN(ipv6Address) {
1860
- if (!isVPNUp()) {
1861
- return false;
1862
- }
1863
- try {
1864
- const result = execSync4(`ip -6 route get ${ipv6Address}`, {
1865
- encoding: "utf-8",
1866
- stdio: ["pipe", "pipe", "pipe"]
1867
- });
1868
- return result.includes(`dev ${WG_INTERFACE}`);
1869
- } catch {
1870
- return false;
1871
- }
1872
- }
1873
- function getVPNStatus() {
1874
- if (!isVPNUp()) {
1875
- return { connected: false };
1876
2012
  }
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 };
2013
+ // ==================== Route Management ====================
2014
+ /**
2015
+ * Register a route for an app (maps hostname to VM IPv6)
2016
+ */
2017
+ async registerRoute(appName, backendIpv6, backendPort = 443) {
2018
+ const { route } = await this.request("POST", "/api/routes", {
2019
+ appName,
2020
+ backendIpv6,
2021
+ backendPort
2022
+ }, { operation: `Register route for '${appName}'` });
2023
+ return route;
1911
2024
  }
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;
2025
+ /**
2026
+ * Delete routes for an app
2027
+ */
2028
+ async deleteRoute(appName) {
2029
+ await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
1941
2030
  }
1942
- }
1943
- function vpnUp() {
1944
- if (isVPNUp()) {
1945
- return;
2031
+ // ==================== Gateway Route Sync ====================
2032
+ /**
2033
+ * Sync gateway Caddy configuration for a location
2034
+ * This updates the gateway's reverse proxy config with current routes
2035
+ */
2036
+ async syncGatewayRoutes(location) {
2037
+ const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
2038
+ return { routeCount: result.routeCount };
1946
2039
  }
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})`);
2040
+ // ==================== SSH Certificate Management ====================
2041
+ /**
2042
+ * Get platform SSH keys (CA public key + platform public key for VM creation)
2043
+ */
2044
+ async getPlatformSSHKeys() {
2045
+ return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1952
2046
  }
1953
- }
1954
- function vpnDown() {
1955
- if (!isVPNUp()) {
1956
- return;
2047
+ /**
2048
+ * Initialize platform SSH keys (creates them if they don't exist)
2049
+ */
2050
+ async initPlatformSSHKeys() {
2051
+ return this.request("POST", "/api/platform/ssh-keys");
1957
2052
  }
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})`);
2053
+ /**
2054
+ * Request a signed SSH certificate for accessing an app's VMs
2055
+ * Returns a short-lived certificate (5 min) signed by the platform CA
2056
+ */
2057
+ async requestSSHCertificate(appName, publicKey) {
2058
+ return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1963
2059
  }
1964
- }
1965
- function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
1966
- try {
1967
- execSync4(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
1968
- stdio: "ignore",
1969
- timeout: (timeoutSeconds + 2) * 1e3
1970
- });
1971
- return true;
1972
- } catch {
1973
- return false;
2060
+ // ==================== VM Setup ====================
2061
+ /**
2062
+ * Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
2063
+ * This runs all configuration using the platform SSH key
2064
+ */
2065
+ async setupVM(vmIp, location, appName) {
2066
+ await this.request("POST", "/api/vms/setup", {
2067
+ vmIp,
2068
+ location,
2069
+ appName
2070
+ }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
1974
2071
  }
1975
- }
1976
- var VPNManager = class {
1977
- wasConnectedByUs = false;
2072
+ // ==================== Environment Variables ====================
1978
2073
  /**
1979
- * Ensure VPN is connected if IPv6 is not available
1980
- * Returns true if VPN was established (and should be torn down later)
2074
+ * Set a single environment variable
1981
2075
  */
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;
2076
+ async setEnvVar(appName, key, value) {
2077
+ await this.request("POST", `/api/apps/${appName}/env`, { key, value });
2017
2078
  }
2018
2079
  /**
2019
- * Disconnect VPN if we established it
2080
+ * Set multiple environment variables at once
2020
2081
  */
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
- }
2082
+ async setEnvVars(appName, vars) {
2083
+ await this.request("POST", `/api/apps/${appName}/env`, { vars });
2032
2084
  }
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);
2085
+ /**
2086
+ * List environment variables (values are masked)
2087
+ */
2088
+ async listEnvVars(appName) {
2089
+ const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
2090
+ return envVars;
2041
2091
  }
2042
- certManager;
2043
- gatewayCache = /* @__PURE__ */ new Map();
2044
- tunnelManager = new TunnelManager();
2045
- vpnManager = new VPNManager();
2046
- tempConfigPath = null;
2047
- vpnEstablishedByUs = false;
2048
2092
  /**
2049
- * Get the connection URL for an app's primary VM
2050
- * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
2093
+ * Remove an environment variable
2051
2094
  */
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..."));
2102
- }
2103
- } else {
2104
- console.log(chalk7.yellow("Direct IPv6 not available on your network."));
2105
- console.log(chalk7.dim("For faster deploys, install WireGuard:"));
2106
- console.log(chalk7.dim(getWireGuardInstallInstructions()));
2107
- console.log();
2108
- }
2109
- console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
2110
- const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
2111
- return {
2112
- url: `ssh://root@localhost:${tunnelInfo.localPort}`,
2113
- vmIp,
2114
- viaGateway: true,
2115
- viaVPN: false,
2116
- localPort: tunnelInfo.localPort
2117
- };
2095
+ async unsetEnvVar(appName, key) {
2096
+ await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
2118
2097
  }
2098
+ // ==================== GitHub Connection ====================
2119
2099
  /**
2120
- * Get VPN configuration from platform
2100
+ * Create app metadata without creating VM (for connect-before-deploy flow)
2121
2101
  */
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
- };
2102
+ async createAppMetadata(appName, location) {
2103
+ const { app } = await this.request("POST", "/api/apps", {
2104
+ appName,
2105
+ location: location || "eu-central-h1",
2106
+ metadataOnly: true
2107
+ // Signal that we don't want to create VM yet
2108
+ });
2109
+ return app;
2133
2110
  }
2134
2111
  /**
2135
- * Pre-accept a host's SSH key by running ssh-keyscan
2136
- * This prevents "Host key verification failed" errors from uncloud
2112
+ * Initiate GitHub App installation flow
2137
2113
  */
2138
- preAcceptHostKey(host, port) {
2114
+ async initiateGitHubConnect() {
2115
+ return this.request("POST", "/api/github/connect");
2116
+ }
2117
+ /**
2118
+ * Poll for GitHub App installation completion
2119
+ */
2120
+ async pollGitHubConnect(stateToken) {
2121
+ return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
2122
+ }
2123
+ /**
2124
+ * Get current user's GitHub App installation
2125
+ */
2126
+ async getGitHubInstallation() {
2139
2127
  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 {
2128
+ const { installation } = await this.request("GET", "/api/github/installation");
2129
+ return installation;
2130
+ } catch (error) {
2131
+ if (error.message.includes("404") || error.message.includes("not found")) {
2132
+ return null;
2133
+ }
2134
+ throw error;
2146
2135
  }
2147
2136
  }
2148
2137
  /**
2149
- * Ensure an SSH tunnel exists for gateway fallback
2138
+ * Delete current user's GitHub App installation record
2139
+ * Used when the installation becomes stale/invalid
2140
+ */
2141
+ async deleteGitHubInstallation() {
2142
+ await this.request("DELETE", "/api/github/installation");
2143
+ }
2144
+ /**
2145
+ * List repositories accessible via GitHub App installation
2150
2146
  */
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);
2147
+ async listAccessibleRepos() {
2148
+ const { repos } = await this.request("GET", "/api/github/repos");
2149
+ return repos;
2157
2150
  }
2158
2151
  /**
2159
- * Run an uncloud command on the app's VM
2152
+ * Connect a repository to an app
2160
2153
  */
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}`);
2154
+ async connectRepo(appName, repoFullName, branch) {
2155
+ await this.request("POST", `/api/apps/${appName}/repo`, {
2156
+ repoFullName,
2157
+ branch: branch || "main"
2158
+ });
2159
+ }
2160
+ /**
2161
+ * Get connected repository for an app
2162
+ */
2163
+ async getConnectedRepo(appName) {
2164
+ try {
2165
+ const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
2166
+ return repo;
2167
+ } catch (error) {
2168
+ if (error.message.includes("404") || error.message.includes("not found")) {
2169
+ return null;
2175
2170
  }
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;
2171
+ throw error;
2183
2172
  }
2184
2173
  }
2185
2174
  /**
2186
- * Run 'uc deploy' for an app
2175
+ * Disconnect repository from an app
2187
2176
  */
2188
- async deploy(appName, cwd) {
2189
- await this.run(appName, "deploy", ["--yes"], { cwd, stdio: "inherit" });
2177
+ async disconnectRepo(appName) {
2178
+ await this.request("DELETE", `/api/apps/${appName}/repo`);
2190
2179
  }
2180
+ // ==================== Builds ====================
2191
2181
  /**
2192
- * Run 'uc service logs' for an app
2182
+ * List builds for an app
2193
2183
  */
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" });
2184
+ async listBuilds(appName, limit = 10) {
2185
+ const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
2186
+ return builds;
2199
2187
  }
2200
2188
  /**
2201
- * Run 'uc service ls' for an app
2189
+ * Get build details
2202
2190
  */
2203
- async serviceList(appName) {
2204
- const result = await this.run(appName, "service", ["ls"], { stdio: "pipe" });
2205
- return result;
2191
+ async getBuild(appName, buildId) {
2192
+ const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
2193
+ return build;
2206
2194
  }
2207
2195
  /**
2208
- * Run 'uc machine token' on remote VM via SSH
2209
- * This is different - it runs on the VM directly, not via uncloud connector
2196
+ * Get build events (for live streaming)
2210
2197
  */
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}`;
2198
+ async getBuildEvents(appName, buildId, after) {
2199
+ let url = `/api/apps/${appName}/builds/${buildId}/events`;
2200
+ if (after) {
2201
+ url += `?after=${encodeURIComponent(after.toISOString())}`;
2218
2202
  }
2219
- const token = execSync5(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
2220
- return token;
2203
+ const { events } = await this.request("GET", url);
2204
+ return events;
2221
2205
  }
2206
+ // ==================== VPN Management ====================
2222
2207
  /**
2223
- * Execute an SSH command on the VM
2208
+ * Register VPN peer with the platform
2209
+ * This is idempotent - if already registered, returns existing config
2224
2210
  */
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();
2211
+ async registerVPNPeer(publicKey, location) {
2212
+ const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
2213
+ return vpnConfig;
2234
2214
  }
2235
2215
  /**
2236
- * Remove a node from the uncloud cluster
2237
- * Uses 'uc machine rm' to drain containers and remove from cluster
2216
+ * Get existing VPN config if registered
2238
2217
  */
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})`);
2218
+ async getVPNConfig(location) {
2219
+ try {
2220
+ const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
2221
+ return vpnConfig;
2222
+ } catch (error) {
2223
+ if (error.message.includes("404") || error.message.includes("not found")) {
2224
+ return null;
2225
+ }
2226
+ throw error;
2248
2227
  }
2249
2228
  }
2250
2229
  /**
2251
- * List machines in the uncloud cluster
2230
+ * Unregister VPN peer
2252
2231
  */
2253
- async listMachines(appName) {
2254
- const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
2255
- return result;
2232
+ async unregisterVPNPeer() {
2233
+ await this.request("DELETE", "/api/vpn/unregister");
2256
2234
  }
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);
2235
+ };
2236
+
2237
+ // src/lib/app-config.ts
2238
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
2239
+ import { join as join4 } from "path";
2240
+ import { parse, stringify } from "yaml";
2241
+ var CONFIG_FILENAME = "hackerrun.yaml";
2242
+ function getConfigPath(directory = process.cwd()) {
2243
+ return join4(directory, CONFIG_FILENAME);
2244
+ }
2245
+ function hasAppConfig(directory = process.cwd()) {
2246
+ return existsSync4(getConfigPath(directory));
2247
+ }
2248
+ function readAppConfig(directory = process.cwd()) {
2249
+ const configPath = getConfigPath(directory);
2250
+ if (!existsSync4(configPath)) {
2270
2251
  return null;
2271
2252
  }
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;
2253
+ try {
2254
+ const content = readFileSync4(configPath, "utf-8");
2255
+ const config = parse(content);
2256
+ if (!config.appName) {
2257
+ return null;
2281
2258
  }
2259
+ return config;
2260
+ } catch (error) {
2261
+ console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
2262
+ return null;
2282
2263
  }
2283
- };
2264
+ }
2265
+ function writeAppConfig(config, directory = process.cwd()) {
2266
+ const configPath = getConfigPath(directory);
2267
+ const content = stringify(config);
2268
+ writeFileSync4(configPath, content, "utf-8");
2269
+ }
2270
+ function getAppName(directory = process.cwd()) {
2271
+ const config = readAppConfig(directory);
2272
+ if (config?.appName) {
2273
+ return config.appName;
2274
+ }
2275
+ const folderName = directory.split("/").pop() || "app";
2276
+ return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
2277
+ }
2278
+ function linkApp(appName, directory = process.cwd()) {
2279
+ writeAppConfig({ appName }, directory);
2280
+ }
2284
2281
 
2285
2282
  // src/commands/deploy.ts
2286
2283
  var DEFAULT_LOCATION = "eu-central-h1";
@@ -3730,19 +3727,33 @@ Error: ${error.message}
3730
3727
  import { Command as Command10 } from "commander";
3731
3728
  import chalk16 from "chalk";
3732
3729
  import ora10 from "ora";
3733
- import { confirm } from "@inquirer/prompts";
3734
- var DEFAULT_VM_SIZE = "standard-2";
3730
+ import { select as select2, confirm } from "@inquirer/prompts";
3731
+ var DEFAULT_VM_SIZE = "burstable-1";
3735
3732
  var DEFAULT_BOOT_IMAGE = "ubuntu-noble";
3733
+ function parseServiceList(output) {
3734
+ const lines = output.trim().split("\n");
3735
+ const services = [];
3736
+ for (const line of lines) {
3737
+ const trimmed = line.trim();
3738
+ if (!trimmed || trimmed.startsWith("NAME") || trimmed.startsWith("Connecting") || trimmed.startsWith("Connected")) {
3739
+ continue;
3740
+ }
3741
+ const svcName = trimmed.split(/\s+/)[0];
3742
+ if (svcName) {
3743
+ services.push(svcName);
3744
+ }
3745
+ }
3746
+ return services;
3747
+ }
3736
3748
  function createScaleCommand() {
3737
3749
  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) => {
3750
+ 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
3751
  let uncloudRunner = null;
3740
3752
  try {
3741
- const appName = options.app || getAppName();
3742
3753
  const platformToken = getPlatformToken();
3743
3754
  const platformClient = new PlatformClient(platformToken);
3744
- const clusterManager = new ClusterManager(platformClient);
3745
3755
  uncloudRunner = new UncloudRunner(platformClient);
3756
+ const clusterManager = new ClusterManager(platformClient, uncloudRunner);
3746
3757
  const app = await platformClient.getApp(appName);
3747
3758
  if (!app) {
3748
3759
  console.log(chalk16.red(`
@@ -3752,146 +3763,122 @@ App '${appName}' not found
3752
3763
  `);
3753
3764
  process.exit(1);
3754
3765
  }
3755
- const currentNodeCount = app.nodes.length;
3756
- if (!countArg) {
3766
+ let services;
3767
+ const spinner = ora10("Fetching services...").start();
3768
+ try {
3769
+ const output = await uncloudRunner.serviceList(appName);
3770
+ services = parseServiceList(output);
3771
+ spinner.stop();
3772
+ } catch (error) {
3773
+ spinner.fail("Failed to fetch services");
3774
+ console.log(chalk16.yellow("\nMake sure the app is deployed and running.\n"));
3775
+ process.exit(1);
3776
+ }
3777
+ if (services.length === 0) {
3778
+ console.log(chalk16.yellow(`
3779
+ No services found in app '${appName}'`));
3780
+ console.log(`Run ${chalk16.bold("hackerrun deploy")} to deploy your app first.
3781
+ `);
3782
+ process.exit(1);
3783
+ }
3784
+ let serviceName;
3785
+ let replicas;
3786
+ if (!serviceArg) {
3757
3787
  console.log(chalk16.cyan(`
3758
- App '${appName}' Scale Info:
3788
+ Services in '${appName}':
3759
3789
  `));
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();
3790
+ serviceName = await select2({
3791
+ message: "Which service would you like to scale?",
3792
+ choices: services.map((s) => ({ name: s, value: s }))
3767
3793
  });
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}
3794
+ if (!replicasArg) {
3795
+ console.log(chalk16.red("\nPlease specify the number of replicas.\n"));
3796
+ console.log(chalk16.dim(`Usage: hackerrun scale ${appName} ${serviceName} <replicas>
3781
3797
  `));
3782
3798
  process.exit(1);
3783
3799
  }
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}
3800
+ replicas = parseInt(replicasArg, 10);
3801
+ } else if (!replicasArg) {
3802
+ const maybeReplicas = parseInt(serviceArg, 10);
3803
+ if (!isNaN(maybeReplicas)) {
3804
+ replicas = maybeReplicas;
3805
+ console.log(chalk16.cyan(`
3806
+ Services in '${appName}':
3807
+ `));
3808
+ serviceName = await select2({
3809
+ message: "Which service would you like to scale?",
3810
+ choices: services.map((s) => ({ name: s, value: s }))
3811
+ });
3812
+ } else {
3813
+ console.log(chalk16.red("\nPlease specify the number of replicas.\n"));
3814
+ console.log(chalk16.dim(`Usage: hackerrun scale ${appName} ${serviceArg} <replicas>
3790
3815
  `));
3791
3816
  process.exit(1);
3792
3817
  }
3793
- targetCount = currentNodeCount - delta;
3794
3818
  } else {
3795
- targetCount = parseInt(countArg, 10);
3796
- if (isNaN(targetCount)) {
3819
+ serviceName = serviceArg;
3820
+ replicas = parseInt(replicasArg, 10);
3821
+ if (!services.includes(serviceName)) {
3797
3822
  console.log(chalk16.red(`
3798
- Invalid count: ${countArg}
3823
+ Service '${serviceName}' not found in app '${appName}'.
3799
3824
  `));
3825
+ console.log(chalk16.cyan("Available services:"));
3826
+ services.forEach((s) => console.log(` - ${s}`));
3827
+ console.log();
3800
3828
  process.exit(1);
3801
3829
  }
3802
3830
  }
3803
- if (targetCount < 1) {
3804
- console.log(chalk16.red(`
3805
- Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
3806
- `));
3831
+ if (isNaN(replicas) || replicas < 1) {
3832
+ console.log(chalk16.red("\nReplicas must be a positive number.\n"));
3807
3833
  process.exit(1);
3808
3834
  }
3809
- if (targetCount === currentNodeCount) {
3810
- console.log(chalk16.yellow(`
3811
- App already has ${currentNodeCount} node(s). Nothing to do.
3835
+ const currentMachineCount = app.nodes.length;
3836
+ console.log(chalk16.cyan(`
3837
+ Scaling '${serviceName}' to ${replicas} replica(s)...`));
3838
+ console.log(chalk16.dim(`Current machines: ${currentMachineCount}
3812
3839
  `));
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})
3840
+ let shouldAddMachines = false;
3841
+ if (replicas > currentMachineCount) {
3842
+ if (options.addMachines) {
3843
+ shouldAddMachines = true;
3844
+ } else if (options.addMachine === false) {
3845
+ shouldAddMachines = false;
3846
+ console.log(chalk16.yellow(`Scaling ${replicas} replicas across ${currentMachineCount} machine(s).
3820
3847
  `));
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})
3848
+ } else {
3849
+ console.log(chalk16.yellow(`You want ${replicas} replicas but only have ${currentMachineCount} machine(s).`));
3850
+ console.log(chalk16.dim(`You can either:`));
3851
+ console.log(chalk16.dim(` \u2022 Add ${replicas - currentMachineCount} new machine(s) for dedicated capacity`));
3852
+ console.log(chalk16.dim(` \u2022 Run ${replicas} replicas on your existing ${currentMachineCount} machine(s)
3856
3853
  `));
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?",
3854
+ shouldAddMachines = await confirm({
3855
+ message: `Add ${replicas - currentMachineCount} new machine(s)?`,
3867
3856
  default: false
3868
3857
  });
3869
- if (!confirmed) {
3870
- console.log(chalk16.dim("\nScale cancelled.\n"));
3871
- return;
3858
+ if (!shouldAddMachines) {
3859
+ console.log(chalk16.dim(`
3860
+ Scaling ${replicas} replicas across ${currentMachineCount} existing machine(s).
3861
+ `));
3872
3862
  }
3873
3863
  }
3874
- for (const node of nodesToRemoveList) {
3875
- console.log(chalk16.cyan(`
3876
- Removing node '${node.name}'...`));
3864
+ }
3865
+ if (shouldAddMachines && replicas > currentMachineCount) {
3866
+ const machinesToAdd = replicas - currentMachineCount;
3867
+ const vmSize = options.size || DEFAULT_VM_SIZE;
3868
+ console.log(chalk16.cyan(`
3869
+ Adding ${machinesToAdd} machine(s)...
3870
+ `));
3871
+ for (let i = 0; i < machinesToAdd; i++) {
3872
+ console.log(chalk16.cyan(`Adding machine ${i + 1} of ${machinesToAdd}...`));
3877
3873
  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}`));
3874
+ const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
3875
+ console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
3890
3876
  } catch (error) {
3891
- console.log(chalk16.red(` Failed to remove ${node.name}: ${error.message}`));
3877
+ console.log(chalk16.red(` Failed to add machine: ${error.message}`));
3878
+ console.log(chalk16.yellow(" Continuing with available machines...\n"));
3879
+ break;
3892
3880
  }
3893
3881
  }
3894
- const updatedApp = await platformClient.getApp(appName);
3895
3882
  const syncSpinner = ora10("Syncing gateway routes...").start();
3896
3883
  try {
3897
3884
  await platformClient.syncGatewayRoutes(app.location);
@@ -3899,10 +3886,22 @@ Removing node '${node.name}'...`));
3899
3886
  } catch (error) {
3900
3887
  syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3901
3888
  }
3902
- console.log(chalk16.green(`
3903
- \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3904
- `));
3905
3889
  }
3890
+ const scaleSpinner = ora10(`Scaling ${serviceName} to ${replicas} replicas...`).start();
3891
+ try {
3892
+ await uncloudRunner.run(appName, "scale", [serviceName, String(replicas)], { stdio: "pipe" });
3893
+ scaleSpinner.succeed(chalk16.green(`Scaled '${serviceName}' to ${replicas} replica(s)`));
3894
+ } catch (error) {
3895
+ scaleSpinner.fail(`Failed to scale service: ${error.message}`);
3896
+ process.exit(1);
3897
+ }
3898
+ const updatedApp = await platformClient.getApp(appName);
3899
+ console.log(chalk16.cyan("\nScale Summary:\n"));
3900
+ console.log(` App: ${chalk16.bold(appName)}`);
3901
+ console.log(` Service: ${chalk16.bold(serviceName)}`);
3902
+ console.log(` Replicas: ${chalk16.bold(String(replicas))}`);
3903
+ console.log(` Machines: ${chalk16.bold(String(updatedApp?.nodes.length || currentMachineCount))}`);
3904
+ console.log();
3906
3905
  } catch (error) {
3907
3906
  console.error(chalk16.red(`
3908
3907
  Error: ${error.message}