hackerrun 0.1.9 → 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
@@ -800,12 +1370,6 @@ var ClusterManager = class {
800
1370
  const setupScript = `#!/bin/bash
801
1371
  set -e
802
1372
 
803
- # Install jq if not present (needed to merge Docker config)
804
- if ! command -v jq &> /dev/null; then
805
- apt-get update -qq
806
- apt-get install -y -qq jq
807
- fi
808
-
809
1373
  # Step 1: Add IPv6 support to Docker daemon config
810
1374
  DAEMON_JSON='/etc/docker/daemon.json'
811
1375
  if [ -f "$DAEMON_JSON" ]; then
@@ -846,21 +1410,18 @@ systemctl restart docker
846
1410
  sleep 3
847
1411
 
848
1412
  # Step 4: Recreate uncloud network with IPv6 enabled
849
- # Must stop containers first - can't remove network while containers are attached
850
1413
  CONTAINERS=$(docker ps -aq --filter network=uncloud 2>/dev/null || true)
851
1414
 
852
- if [ -n "$CONTAINERS" ]; then
853
- echo "Stopping containers on uncloud network..."
854
- docker stop $CONTAINERS 2>/dev/null || true
855
- fi
1415
+ for container in $CONTAINERS; do
1416
+ docker network disconnect uncloud "$container" 2>/dev/null || true
1417
+ done
856
1418
 
857
1419
  docker network rm uncloud 2>/dev/null || true
858
1420
  docker network create --driver bridge --subnet 10.210.0.0/24 --gateway 10.210.0.1 --ipv6 --subnet fd00:a10:210::/64 --gateway fd00:a10:210::1 uncloud
859
1421
 
860
- if [ -n "$CONTAINERS" ]; then
861
- echo "Restarting containers..."
862
- docker start $CONTAINERS 2>/dev/null || true
863
- fi
1422
+ for container in $CONTAINERS; do
1423
+ docker network connect uncloud "$container" 2>/dev/null || true
1424
+ done
864
1425
 
865
1426
  # Step 5: Start the iptables service
866
1427
  systemctl start docker-ipv6-nat64
@@ -877,7 +1438,7 @@ echo "Docker NAT64 configuration complete"
877
1438
  sshHost = "localhost";
878
1439
  sshPortArgs = `-p ${tunnel.localPort}`;
879
1440
  }
880
- 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'
881
1442
  ${setupScript}
882
1443
  REMOTESCRIPT`, {
883
1444
  stdio: "inherit",
@@ -903,13 +1464,13 @@ REMOTESCRIPT`, {
903
1464
  while (Date.now() - startTime < timeoutMs) {
904
1465
  const vm = await this.platformClient.getVM(location, vmName);
905
1466
  if (vm.status && vm.status !== lastStatus) {
906
- console.log(chalk2.dim(` Status: ${vm.status}`));
1467
+ console.log(chalk4.dim(` Status: ${vm.status}`));
907
1468
  lastStatus = vm.status;
908
1469
  }
909
1470
  const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
910
1471
  if (hasRequiredIp) {
911
1472
  const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
912
- console.log(chalk2.green(` IP assigned: ${ipDisplay}`));
1473
+ console.log(chalk4.green(` IP assigned: ${ipDisplay}`));
913
1474
  return vm;
914
1475
  }
915
1476
  const elapsed = Math.floor((Date.now() - startTime) / 1e3);
@@ -938,27 +1499,27 @@ The VM may still be provisioning. You can:
938
1499
  const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
939
1500
  const proxyJump = !canConnectDirect && gatewayIp ? `-J root@${gatewayIp}` : "";
940
1501
  const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
941
- console.log(chalk2.dim(" Waiting for uncloud services..."));
1502
+ console.log(chalk4.dim(" Waiting for uncloud services..."));
942
1503
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
943
1504
  try {
944
- const result = execSync2(
1505
+ const result = execSync4(
945
1506
  `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
946
1507
  { encoding: "utf-8", timeout: 2e4, stdio: ["pipe", "pipe", "pipe"] }
947
1508
  ).trim();
948
1509
  if (result.includes("ready")) {
949
- console.log(chalk2.dim(" Uncloud services ready"));
1510
+ console.log(chalk4.dim(" Uncloud services ready"));
950
1511
  return;
951
1512
  }
952
1513
  } catch (error) {
953
1514
  }
954
1515
  if (attempt % 5 === 0) {
955
- console.log(chalk2.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
1516
+ console.log(chalk4.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
956
1517
  }
957
1518
  if (attempt < maxAttempts) {
958
1519
  await this.sleep(checkInterval);
959
1520
  }
960
1521
  }
961
- console.log(chalk2.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
1522
+ console.log(chalk4.yellow(" Warning: Timeout waiting for unregistry, continuing anyway..."));
962
1523
  }
963
1524
  /**
964
1525
  * Generate a sequential node name: appName-1, appName-2, etc.
@@ -987,21 +1548,21 @@ The VM may still be provisioning. You can:
987
1548
  };
988
1549
 
989
1550
  // src/lib/platform.ts
990
- import { platform } from "os";
991
- import chalk3 from "chalk";
1551
+ import { platform as platform2 } from "os";
1552
+ import chalk5 from "chalk";
992
1553
  var PlatformDetector = class {
993
1554
  /**
994
1555
  * Check if running on Windows
995
1556
  */
996
1557
  static isWindows() {
997
- return platform() === "win32";
1558
+ return platform2() === "win32";
998
1559
  }
999
1560
  /**
1000
1561
  * Check if running inside WSL (Windows Subsystem for Linux)
1001
1562
  */
1002
1563
  static isWSL() {
1003
1564
  if (!this.isWindows()) {
1004
- if (platform() === "linux") {
1565
+ if (platform2() === "linux") {
1005
1566
  try {
1006
1567
  const fs = __require("fs");
1007
1568
  const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
@@ -1018,40 +1579,40 @@ var PlatformDetector = class {
1018
1579
  * Check if platform is supported for hackerrun
1019
1580
  */
1020
1581
  static isSupported() {
1021
- return platform() === "darwin" || platform() === "linux" || this.isWSL();
1582
+ return platform2() === "darwin" || platform2() === "linux" || this.isWSL();
1022
1583
  }
1023
1584
  /**
1024
1585
  * Get platform name for display
1025
1586
  */
1026
1587
  static getPlatformName() {
1027
1588
  if (this.isWSL()) return "WSL (Windows Subsystem for Linux)";
1028
- if (platform() === "win32") return "Windows";
1029
- if (platform() === "darwin") return "macOS";
1030
- if (platform() === "linux") return "Linux";
1031
- return platform();
1589
+ if (platform2() === "win32") return "Windows";
1590
+ if (platform2() === "darwin") return "macOS";
1591
+ if (platform2() === "linux") return "Linux";
1592
+ return platform2();
1032
1593
  }
1033
1594
  /**
1034
1595
  * Ensure platform is supported, exit with helpful message if not
1035
1596
  */
1036
1597
  static ensureSupported() {
1037
1598
  if (this.isWindows() && !this.isWSL()) {
1038
- console.log(chalk3.yellow("\n\u26A0\uFE0F Windows detected\n"));
1599
+ console.log(chalk5.yellow("\n\u26A0\uFE0F Windows detected\n"));
1039
1600
  console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
1040
1601
  console.log("Uncloud and SSH tools work best in a Linux environment.\n");
1041
- console.log(chalk3.cyan("How to set up WSL:\n"));
1602
+ console.log(chalk5.cyan("How to set up WSL:\n"));
1042
1603
  console.log("1. Open PowerShell as Administrator and run:");
1043
- console.log(chalk3.bold(" wsl --install\n"));
1604
+ console.log(chalk5.bold(" wsl --install\n"));
1044
1605
  console.log("2. Restart your computer\n");
1045
1606
  console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
1046
1607
  console.log("4. Open WSL terminal and install hackerrun:");
1047
- console.log(chalk3.bold(" npm install -g hackerrun\n"));
1608
+ console.log(chalk5.bold(" npm install -g hackerrun\n"));
1048
1609
  console.log("Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n");
1049
- 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"));
1050
1611
  process.exit(1);
1051
1612
  }
1052
1613
  if (!this.isSupported()) {
1053
- console.log(chalk3.red(`
1054
- \u274C Platform '${platform()}' is not supported
1614
+ console.log(chalk5.red(`
1615
+ \u274C Platform '${platform2()}' is not supported
1055
1616
  `));
1056
1617
  console.log("Hackerrun supports:");
1057
1618
  console.log(" - macOS");
@@ -1063,9 +1624,9 @@ var PlatformDetector = class {
1063
1624
  };
1064
1625
 
1065
1626
  // src/lib/uncloud.ts
1066
- import { execSync as execSync3 } from "child_process";
1067
- import { platform as platform2 } from "os";
1068
- import chalk4 from "chalk";
1627
+ import { execSync as execSync5 } from "child_process";
1628
+ import { platform as platform3 } from "os";
1629
+ import chalk6 from "chalk";
1069
1630
  import ora3 from "ora";
1070
1631
  var UncloudManager = class {
1071
1632
  /**
@@ -1073,7 +1634,7 @@ var UncloudManager = class {
1073
1634
  */
1074
1635
  static isInstalled() {
1075
1636
  try {
1076
- execSync3("uc --version", { stdio: "pipe" });
1637
+ execSync5("uc --version", { stdio: "pipe" });
1077
1638
  return true;
1078
1639
  } catch {
1079
1640
  return false;
@@ -1084,7 +1645,7 @@ var UncloudManager = class {
1084
1645
  */
1085
1646
  static getVersion() {
1086
1647
  try {
1087
- const version = execSync3("uc --version", { encoding: "utf-8" }).trim();
1648
+ const version = execSync5("uc --version", { encoding: "utf-8" }).trim();
1088
1649
  return version;
1089
1650
  } catch {
1090
1651
  return null;
@@ -1099,34 +1660,34 @@ var UncloudManager = class {
1099
1660
  return;
1100
1661
  }
1101
1662
  if (options?.nonInteractive) {
1102
- 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"));
1103
1664
  console.error("The uncloud CLI must be installed on the build VM.");
1104
1665
  console.error("This is a build infrastructure issue - the uc binary should be pre-installed.\n");
1105
1666
  process.exit(1);
1106
1667
  }
1107
- 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"));
1108
1669
  console.log("Uncloud is required to deploy and manage your apps.");
1109
1670
  console.log("Learn more: https://uncloud.run\n");
1110
- const os = platform2();
1671
+ const os = platform3();
1111
1672
  if (os === "darwin" || os === "linux") {
1112
1673
  this.showInstallInstructions();
1113
- 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): "));
1114
1675
  const answer = await this.promptUser();
1115
1676
  if (answer === "y" || answer === "yes" || answer === "") {
1116
1677
  try {
1117
1678
  await this.autoInstall();
1118
1679
  return;
1119
1680
  } catch (error) {
1120
- console.log(chalk4.red("\nAuto-installation failed. Please install manually.\n"));
1681
+ console.log(chalk6.red("\nAuto-installation failed. Please install manually.\n"));
1121
1682
  process.exit(1);
1122
1683
  }
1123
1684
  } else {
1124
- 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"));
1125
1686
  process.exit(1);
1126
1687
  }
1127
1688
  } else {
1128
1689
  this.showInstallInstructions();
1129
- 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"));
1130
1691
  process.exit(1);
1131
1692
  }
1132
1693
  }
@@ -1134,20 +1695,20 @@ var UncloudManager = class {
1134
1695
  * Show installation instructions based on platform
1135
1696
  */
1136
1697
  static showInstallInstructions() {
1137
- const os = platform2();
1138
- console.log(chalk4.cyan("Installation instructions:\n"));
1698
+ const os = platform3();
1699
+ console.log(chalk6.cyan("Installation instructions:\n"));
1139
1700
  if (os === "darwin" || os === "linux") {
1140
- console.log(chalk4.bold("Option 1: Automated install (Recommended)"));
1141
- console.log(chalk4.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
1142
- console.log(chalk4.bold("Option 2: Homebrew"));
1143
- console.log(chalk4.green(" brew install psviderski/tap/uncloud\n"));
1144
- 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"));
1145
1706
  console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
1146
1707
  console.log(" Extract and move to /usr/local/bin\n");
1147
1708
  } else if (os === "win32") {
1148
- console.log(chalk4.bold("For Windows (via WSL):"));
1709
+ console.log(chalk6.bold("For Windows (via WSL):"));
1149
1710
  console.log(" Open your WSL terminal and run:");
1150
- 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"));
1151
1712
  }
1152
1713
  console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
1153
1714
  }
@@ -1172,27 +1733,27 @@ var UncloudManager = class {
1172
1733
  static async autoInstall() {
1173
1734
  const spinner = ora3("Installing uncloud...").start();
1174
1735
  try {
1175
- execSync3("curl -fsS https://get.uncloud.run/install.sh | sh", {
1736
+ execSync5("curl -fsS https://get.uncloud.run/install.sh | sh", {
1176
1737
  stdio: "inherit"
1177
1738
  });
1178
- spinner.succeed(chalk4.green("Uncloud installed successfully!"));
1739
+ spinner.succeed(chalk6.green("Uncloud installed successfully!"));
1179
1740
  if (this.isInstalled()) {
1180
1741
  const version = this.getVersion();
1181
- console.log(chalk4.cyan(`\u2713 Uncloud ${version} is ready to use
1742
+ console.log(chalk6.cyan(`\u2713 Uncloud ${version} is ready to use
1182
1743
  `));
1183
1744
  } else {
1184
- spinner.warn(chalk4.yellow("Installation completed but uc command not found in PATH"));
1185
- 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:"));
1186
1747
  console.log(" 1. Restart your terminal");
1187
1748
  console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
1188
1749
  throw new Error("Please restart your terminal and try again");
1189
1750
  }
1190
1751
  } catch (error) {
1191
- spinner.fail(chalk4.red("Failed to install uncloud"));
1192
- console.log(chalk4.red(`
1752
+ spinner.fail(chalk6.red("Failed to install uncloud"));
1753
+ console.log(chalk6.red(`
1193
1754
  Error: ${error.message}
1194
1755
  `));
1195
- console.log(chalk4.yellow("Please try manual installation:"));
1756
+ console.log(chalk6.yellow("Please try manual installation:"));
1196
1757
  console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
1197
1758
  throw error;
1198
1759
  }
@@ -1200,16 +1761,16 @@ Error: ${error.message}
1200
1761
  };
1201
1762
 
1202
1763
  // src/lib/platform-auth.ts
1203
- import chalk5 from "chalk";
1764
+ import chalk7 from "chalk";
1204
1765
  function getPlatformToken() {
1205
1766
  const configManager = new ConfigManager();
1206
1767
  try {
1207
1768
  const config = configManager.load();
1208
1769
  return config.apiToken;
1209
1770
  } catch (error) {
1210
- console.error(chalk5.red("\n Not logged in"));
1211
- console.log(chalk5.cyan("\nPlease login first:\n"));
1212
- 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")}
1213
1774
  `);
1214
1775
  process.exit(1);
1215
1776
  }
@@ -1421,243 +1982,12 @@ var PlatformClient = class {
1421
1982
  await this.request("POST", "/api/uncloud/config", { config: configYaml });
1422
1983
  }
1423
1984
  /**
1424
- * Download uncloud config from platform
1425
- */
1426
- async downloadUncloudConfig() {
1427
- try {
1428
- const { config } = await this.request("GET", "/api/uncloud/config");
1429
- return config;
1430
- } catch (error) {
1431
- if (error.message.includes("404") || error.message.includes("not found")) {
1432
- return null;
1433
- }
1434
- throw error;
1435
- }
1436
- }
1437
- // ==================== Gateway Management ====================
1438
- /**
1439
- * Get gateway info for a location
1440
- */
1441
- async getGateway(location) {
1442
- try {
1443
- const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`, void 0, { operation: `Get gateway for '${location}'` });
1444
- return gateway;
1445
- } catch (error) {
1446
- if (error.message.includes("404") || error.message.includes("not found")) {
1447
- return null;
1448
- }
1449
- throw error;
1450
- }
1451
- }
1452
- // ==================== Route Management ====================
1453
- /**
1454
- * Register a route for an app (maps hostname to VM IPv6)
1455
- */
1456
- async registerRoute(appName, backendIpv6, backendPort = 443) {
1457
- const { route } = await this.request("POST", "/api/routes", {
1458
- appName,
1459
- backendIpv6,
1460
- backendPort
1461
- }, { operation: `Register route for '${appName}'` });
1462
- return route;
1463
- }
1464
- /**
1465
- * Delete routes for an app
1466
- */
1467
- async deleteRoute(appName) {
1468
- await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
1469
- }
1470
- // ==================== Gateway Route Sync ====================
1471
- /**
1472
- * Sync gateway Caddy configuration for a location
1473
- * This updates the gateway's reverse proxy config with current routes
1474
- */
1475
- async syncGatewayRoutes(location) {
1476
- const result = await this.request("POST", "/api/gateway/sync", { location }, { operation: `Sync gateway routes for '${location}'` });
1477
- return { routeCount: result.routeCount };
1478
- }
1479
- // ==================== SSH Certificate Management ====================
1480
- /**
1481
- * Get platform SSH keys (CA public key + platform public key for VM creation)
1482
- */
1483
- async getPlatformSSHKeys() {
1484
- return this.request("GET", "/api/platform/ssh-keys", void 0, { operation: "Get platform SSH keys" });
1485
- }
1486
- /**
1487
- * Initialize platform SSH keys (creates them if they don't exist)
1488
- */
1489
- async initPlatformSSHKeys() {
1490
- return this.request("POST", "/api/platform/ssh-keys");
1491
- }
1492
- /**
1493
- * Request a signed SSH certificate for accessing an app's VMs
1494
- * Returns a short-lived certificate (5 min) signed by the platform CA
1495
- */
1496
- async requestSSHCertificate(appName, publicKey) {
1497
- return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
1498
- }
1499
- // ==================== VM Setup ====================
1500
- /**
1501
- * Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
1502
- * This runs all configuration using the platform SSH key
1503
- */
1504
- async setupVM(vmIp, location, appName) {
1505
- await this.request("POST", "/api/vms/setup", {
1506
- vmIp,
1507
- location,
1508
- appName
1509
- }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3e3 });
1510
- }
1511
- // ==================== Environment Variables ====================
1512
- /**
1513
- * Set a single environment variable
1514
- */
1515
- async setEnvVar(appName, key, value) {
1516
- await this.request("POST", `/api/apps/${appName}/env`, { key, value });
1517
- }
1518
- /**
1519
- * Set multiple environment variables at once
1520
- */
1521
- async setEnvVars(appName, vars) {
1522
- await this.request("POST", `/api/apps/${appName}/env`, { vars });
1523
- }
1524
- /**
1525
- * List environment variables (values are masked)
1526
- */
1527
- async listEnvVars(appName) {
1528
- const { envVars } = await this.request("GET", `/api/apps/${appName}/env`);
1529
- return envVars;
1530
- }
1531
- /**
1532
- * Remove an environment variable
1533
- */
1534
- async unsetEnvVar(appName, key) {
1535
- await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
1536
- }
1537
- // ==================== GitHub Connection ====================
1538
- /**
1539
- * Create app metadata without creating VM (for connect-before-deploy flow)
1540
- */
1541
- async createAppMetadata(appName, location) {
1542
- const { app } = await this.request("POST", "/api/apps", {
1543
- appName,
1544
- location: location || "eu-central-h1",
1545
- metadataOnly: true
1546
- // Signal that we don't want to create VM yet
1547
- });
1548
- return app;
1549
- }
1550
- /**
1551
- * Initiate GitHub App installation flow
1552
- */
1553
- async initiateGitHubConnect() {
1554
- return this.request("POST", "/api/github/connect");
1555
- }
1556
- /**
1557
- * Poll for GitHub App installation completion
1558
- */
1559
- async pollGitHubConnect(stateToken) {
1560
- return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
1561
- }
1562
- /**
1563
- * Get current user's GitHub App installation
1564
- */
1565
- async getGitHubInstallation() {
1566
- try {
1567
- const { installation } = await this.request("GET", "/api/github/installation");
1568
- return installation;
1569
- } catch (error) {
1570
- if (error.message.includes("404") || error.message.includes("not found")) {
1571
- return null;
1572
- }
1573
- throw error;
1574
- }
1575
- }
1576
- /**
1577
- * Delete current user's GitHub App installation record
1578
- * Used when the installation becomes stale/invalid
1579
- */
1580
- async deleteGitHubInstallation() {
1581
- await this.request("DELETE", "/api/github/installation");
1582
- }
1583
- /**
1584
- * List repositories accessible via GitHub App installation
1585
- */
1586
- async listAccessibleRepos() {
1587
- const { repos } = await this.request("GET", "/api/github/repos");
1588
- return repos;
1589
- }
1590
- /**
1591
- * Connect a repository to an app
1592
- */
1593
- async connectRepo(appName, repoFullName, branch) {
1594
- await this.request("POST", `/api/apps/${appName}/repo`, {
1595
- repoFullName,
1596
- branch: branch || "main"
1597
- });
1598
- }
1599
- /**
1600
- * Get connected repository for an app
1601
- */
1602
- async getConnectedRepo(appName) {
1603
- try {
1604
- const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
1605
- return repo;
1606
- } catch (error) {
1607
- if (error.message.includes("404") || error.message.includes("not found")) {
1608
- return null;
1609
- }
1610
- throw error;
1611
- }
1612
- }
1613
- /**
1614
- * Disconnect repository from an app
1615
- */
1616
- async disconnectRepo(appName) {
1617
- await this.request("DELETE", `/api/apps/${appName}/repo`);
1618
- }
1619
- // ==================== Builds ====================
1620
- /**
1621
- * List builds for an app
1622
- */
1623
- async listBuilds(appName, limit = 10) {
1624
- const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
1625
- return builds;
1626
- }
1627
- /**
1628
- * Get build details
1629
- */
1630
- async getBuild(appName, buildId) {
1631
- const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
1632
- return build;
1633
- }
1634
- /**
1635
- * Get build events (for live streaming)
1636
- */
1637
- async getBuildEvents(appName, buildId, after) {
1638
- let url = `/api/apps/${appName}/builds/${buildId}/events`;
1639
- if (after) {
1640
- url += `?after=${encodeURIComponent(after.toISOString())}`;
1641
- }
1642
- const { events } = await this.request("GET", url);
1643
- return events;
1644
- }
1645
- // ==================== VPN Management ====================
1646
- /**
1647
- * Register VPN peer with the platform
1648
- * This is idempotent - if already registered, returns existing config
1649
- */
1650
- async registerVPNPeer(publicKey, location) {
1651
- const { vpnConfig } = await this.request("POST", "/api/vpn/register", { publicKey, location }, { operation: "Register VPN peer" });
1652
- return vpnConfig;
1653
- }
1654
- /**
1655
- * Get existing VPN config if registered
1985
+ * Download uncloud config from platform
1656
1986
  */
1657
- async getVPNConfig(location) {
1987
+ async downloadUncloudConfig() {
1658
1988
  try {
1659
- const { vpnConfig } = await this.request("GET", `/api/vpn/config?location=${encodeURIComponent(location)}`);
1660
- return vpnConfig;
1989
+ const { config } = await this.request("GET", "/api/uncloud/config");
1990
+ return config;
1661
1991
  } catch (error) {
1662
1992
  if (error.message.includes("404") || error.message.includes("not found")) {
1663
1993
  return null;
@@ -1665,631 +1995,289 @@ var PlatformClient = class {
1665
1995
  throw error;
1666
1996
  }
1667
1997
  }
1998
+ // ==================== Gateway Management ====================
1668
1999
  /**
1669
- * Unregister VPN peer
2000
+ * Get gateway info for a location
1670
2001
  */
1671
- async unregisterVPNPeer() {
1672
- await this.request("DELETE", "/api/vpn/unregister");
1673
- }
1674
- };
1675
-
1676
- // src/lib/app-config.ts
1677
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1678
- import { join as join3 } from "path";
1679
- import { parse, stringify } from "yaml";
1680
- var CONFIG_FILENAME = "hackerrun.yaml";
1681
- function getConfigPath(directory = process.cwd()) {
1682
- return join3(directory, CONFIG_FILENAME);
1683
- }
1684
- function hasAppConfig(directory = process.cwd()) {
1685
- return existsSync3(getConfigPath(directory));
1686
- }
1687
- function readAppConfig(directory = process.cwd()) {
1688
- const configPath = getConfigPath(directory);
1689
- if (!existsSync3(configPath)) {
1690
- return null;
1691
- }
1692
- try {
1693
- const content = readFileSync3(configPath, "utf-8");
1694
- const config = parse(content);
1695
- if (!config.appName) {
1696
- return null;
1697
- }
1698
- return config;
1699
- } catch (error) {
1700
- console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
1701
- return null;
1702
- }
1703
- }
1704
- function writeAppConfig(config, directory = process.cwd()) {
1705
- const configPath = getConfigPath(directory);
1706
- const content = stringify(config);
1707
- writeFileSync3(configPath, content, "utf-8");
1708
- }
1709
- function getAppName(directory = process.cwd()) {
1710
- const config = readAppConfig(directory);
1711
- if (config?.appName) {
1712
- return config.appName;
1713
- }
1714
- const folderName = directory.split("/").pop() || "app";
1715
- return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
1716
- }
1717
- function linkApp(appName, directory = process.cwd()) {
1718
- writeAppConfig({ appName }, directory);
1719
- }
1720
-
1721
- // src/lib/uncloud-runner.ts
1722
- import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
1723
-
1724
- // src/lib/vpn.ts
1725
- import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
1726
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, unlinkSync } from "fs";
1727
- import { homedir as homedir2, platform as platform3 } from "os";
1728
- import { join as join4 } from "path";
1729
- import chalk6 from "chalk";
1730
- function detectOS() {
1731
- const os = platform3();
1732
- if (os === "darwin") {
1733
- return { type: "macos" };
1734
- }
1735
- if (os === "win32") {
1736
- return { type: "windows" };
1737
- }
1738
- if (os === "linux") {
2002
+ async getGateway(location) {
1739
2003
  try {
1740
- if (existsSync4("/etc/os-release")) {
1741
- const osRelease = readFileSync4("/etc/os-release", "utf-8");
1742
- const idMatch = osRelease.match(/^ID=(.*)$/m);
1743
- if (idMatch) {
1744
- const distro = idMatch[1].replace(/"/g, "").toLowerCase();
1745
- return { type: "linux", distro };
1746
- }
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;
1747
2009
  }
1748
- } catch {
2010
+ throw error;
1749
2011
  }
1750
- return { type: "linux" };
1751
- }
1752
- return { type: "unknown" };
1753
- }
1754
- function getWireGuardInstallInstructions() {
1755
- const os = detectOS();
1756
- switch (os.type) {
1757
- case "macos":
1758
- return ` ${chalk6.cyan("macOS:")} brew install wireguard-tools`;
1759
- case "windows":
1760
- return ` ${chalk6.cyan("Windows:")} Download from https://www.wireguard.com/install/
1761
- Or using winget: winget install WireGuard.WireGuard`;
1762
- case "linux":
1763
- switch (os.distro) {
1764
- case "arch":
1765
- case "manjaro":
1766
- case "endeavouros":
1767
- case "artix":
1768
- return ` ${chalk6.cyan("Arch Linux:")} sudo pacman -S wireguard-tools`;
1769
- case "ubuntu":
1770
- case "debian":
1771
- case "linuxmint":
1772
- case "pop":
1773
- case "elementary":
1774
- case "zorin":
1775
- return ` ${chalk6.cyan("Ubuntu/Debian:")} sudo apt install wireguard`;
1776
- case "fedora":
1777
- return ` ${chalk6.cyan("Fedora:")} sudo dnf install wireguard-tools`;
1778
- case "rhel":
1779
- case "centos":
1780
- case "rocky":
1781
- case "almalinux":
1782
- return ` ${chalk6.cyan("RHEL/CentOS:")} sudo dnf install wireguard-tools
1783
- (may need EPEL: sudo dnf install epel-release)`;
1784
- case "opensuse":
1785
- case "opensuse-leap":
1786
- case "opensuse-tumbleweed":
1787
- return ` ${chalk6.cyan("openSUSE:")} sudo zypper install wireguard-tools`;
1788
- case "gentoo":
1789
- return ` ${chalk6.cyan("Gentoo:")} sudo emerge net-vpn/wireguard-tools`;
1790
- case "void":
1791
- return ` ${chalk6.cyan("Void Linux:")} sudo xbps-install wireguard-tools`;
1792
- case "alpine":
1793
- return ` ${chalk6.cyan("Alpine:")} sudo apk add wireguard-tools`;
1794
- case "nixos":
1795
- return ` ${chalk6.cyan("NixOS:")} Add wireguard-tools to environment.systemPackages`;
1796
- default:
1797
- return ` ${chalk6.cyan("Linux:")} Install wireguard-tools using your package manager:
1798
- Arch: sudo pacman -S wireguard-tools
1799
- Ubuntu/Debian: sudo apt install wireguard
1800
- Fedora: sudo dnf install wireguard-tools
1801
- openSUSE: sudo zypper install wireguard-tools`;
1802
- }
1803
- default:
1804
- return ` Visit https://www.wireguard.com/install/ for installation instructions`;
1805
- }
1806
- }
1807
- var CONFIG_DIR = join4(homedir2(), ".config", "hackerrun");
1808
- var PRIVATE_KEY_FILE = join4(CONFIG_DIR, "wg-private-key");
1809
- var WG_INTERFACE = "hackerrun";
1810
- var WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
1811
- function isWireGuardInstalled() {
1812
- try {
1813
- execSync4("which wg", { stdio: "ignore" });
1814
- execSync4("which wg-quick", { stdio: "ignore" });
1815
- return true;
1816
- } catch {
1817
- return false;
1818
- }
1819
- }
1820
- function ensureConfigDir() {
1821
- if (!existsSync4(CONFIG_DIR)) {
1822
- mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
1823
- }
1824
- }
1825
- function generateKeyPair() {
1826
- const privateKey = execSync4("wg genkey", { encoding: "utf-8" }).trim();
1827
- const publicKey = execSync4("wg pubkey", {
1828
- input: privateKey,
1829
- encoding: "utf-8"
1830
- }).trim();
1831
- return { privateKey, publicKey };
1832
- }
1833
- function getOrCreateKeyPair() {
1834
- ensureConfigDir();
1835
- if (existsSync4(PRIVATE_KEY_FILE)) {
1836
- const privateKey2 = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1837
- const publicKey2 = execSync4("wg pubkey", {
1838
- input: privateKey2,
1839
- encoding: "utf-8"
1840
- }).trim();
1841
- return { privateKey: privateKey2, publicKey: publicKey2, isNew: false };
1842
- }
1843
- const { privateKey, publicKey } = generateKeyPair();
1844
- writeFileSync4(PRIVATE_KEY_FILE, privateKey + "\n", { mode: 384 });
1845
- return { privateKey, publicKey, isNew: true };
1846
- }
1847
- function getPublicKey() {
1848
- if (!existsSync4(PRIVATE_KEY_FILE)) {
1849
- return null;
1850
- }
1851
- const privateKey = readFileSync4(PRIVATE_KEY_FILE, "utf-8").trim();
1852
- return execSync4("wg pubkey", {
1853
- input: privateKey,
1854
- encoding: "utf-8"
1855
- }).trim();
1856
- }
1857
- function isVPNUp() {
1858
- try {
1859
- const result = execSync4(`ip link show ${WG_INTERFACE}`, {
1860
- encoding: "utf-8",
1861
- stdio: ["pipe", "pipe", "pipe"]
1862
- });
1863
- return result.includes(WG_INTERFACE);
1864
- } catch {
1865
- return false;
1866
- }
1867
- }
1868
- function isRoutedViaVPN(ipv6Address) {
1869
- if (!isVPNUp()) {
1870
- return false;
1871
- }
1872
- try {
1873
- const result = execSync4(`ip -6 route get ${ipv6Address}`, {
1874
- encoding: "utf-8",
1875
- stdio: ["pipe", "pipe", "pipe"]
1876
- });
1877
- return result.includes(`dev ${WG_INTERFACE}`);
1878
- } catch {
1879
- return false;
1880
- }
1881
- }
1882
- function getVPNStatus() {
1883
- if (!isVPNUp()) {
1884
- return { connected: false };
1885
2012
  }
1886
- try {
1887
- const output = execSync4(`sudo wg show ${WG_INTERFACE}`, {
1888
- encoding: "utf-8",
1889
- stdio: ["pipe", "pipe", "pipe"]
1890
- });
1891
- const lines = output.split("\n");
1892
- let endpoint;
1893
- let latestHandshake;
1894
- let transferRx;
1895
- let transferTx;
1896
- for (const line of lines) {
1897
- if (line.includes("endpoint:")) {
1898
- endpoint = line.split("endpoint:")[1]?.trim();
1899
- }
1900
- if (line.includes("latest handshake:")) {
1901
- latestHandshake = line.split("latest handshake:")[1]?.trim();
1902
- }
1903
- if (line.includes("transfer:")) {
1904
- const transfer = line.split("transfer:")[1]?.trim();
1905
- const parts = transfer?.split(",");
1906
- transferRx = parts?.[0]?.trim();
1907
- transferTx = parts?.[1]?.trim();
1908
- }
1909
- }
1910
- return {
1911
- connected: true,
1912
- interface: WG_INTERFACE,
1913
- endpoint,
1914
- latestHandshake,
1915
- transferRx,
1916
- transferTx
1917
- };
1918
- } catch {
1919
- 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;
1920
2024
  }
1921
- }
1922
- function generateWireGuardConfig(config) {
1923
- return `# HackerRun VPN - Auto-generated
1924
- # Do not edit manually
1925
-
1926
- [Interface]
1927
- PrivateKey = ${config.privateKey}
1928
- Address = ${config.address}
1929
-
1930
- [Peer]
1931
- PublicKey = ${config.gatewayPublicKey}
1932
- Endpoint = ${config.gatewayEndpoint}
1933
- AllowedIPs = ${config.allowedIPs}
1934
- PersistentKeepalive = 25
1935
- `;
1936
- }
1937
- function writeWireGuardConfig(config) {
1938
- const configContent = generateWireGuardConfig(config);
1939
- const tempFile = join4(CONFIG_DIR, "wg-temp.conf");
1940
- writeFileSync4(tempFile, configContent, { mode: 384 });
1941
- try {
1942
- execSync4("sudo mkdir -p /etc/wireguard", { stdio: "inherit" });
1943
- execSync4(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1944
- execSync4(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: "inherit" });
1945
- } catch (error) {
1946
- if (existsSync4(tempFile)) {
1947
- unlinkSync(tempFile);
1948
- }
1949
- throw error;
2025
+ /**
2026
+ * Delete routes for an app
2027
+ */
2028
+ async deleteRoute(appName) {
2029
+ await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
1950
2030
  }
1951
- }
1952
- function vpnUp() {
1953
- if (isVPNUp()) {
1954
- 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 };
1955
2039
  }
1956
- const result = spawnSync2("sudo", ["wg-quick", "up", WG_INTERFACE], {
1957
- stdio: "inherit"
1958
- });
1959
- if (result.status !== 0) {
1960
- 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" });
1961
2046
  }
1962
- }
1963
- function vpnDown() {
1964
- if (!isVPNUp()) {
1965
- 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");
1966
2052
  }
1967
- const result = spawnSync2("sudo", ["wg-quick", "down", WG_INTERFACE], {
1968
- stdio: "inherit"
1969
- });
1970
- if (result.status !== 0) {
1971
- 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}'` });
1972
2059
  }
1973
- }
1974
- function testIPv6Connectivity2(ipv6Address, timeoutSeconds = 3) {
1975
- try {
1976
- execSync4(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
1977
- stdio: "ignore",
1978
- timeout: (timeoutSeconds + 2) * 1e3
1979
- });
1980
- return true;
1981
- } catch {
1982
- 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 });
1983
2071
  }
1984
- }
1985
- var VPNManager = class {
1986
- wasConnectedByUs = false;
2072
+ // ==================== Environment Variables ====================
1987
2073
  /**
1988
- * Ensure VPN is connected if IPv6 is not available
1989
- * Returns true if VPN was established (and should be torn down later)
2074
+ * Set a single environment variable
1990
2075
  */
1991
- async ensureConnected(targetIPv6, getVPNConfig) {
1992
- if (testIPv6Connectivity2(targetIPv6)) {
1993
- console.log(chalk6.green("\u2713 Direct IPv6 connectivity available"));
1994
- return false;
1995
- }
1996
- console.log(chalk6.yellow("IPv6 not available, checking VPN..."));
1997
- if (isVPNUp()) {
1998
- if (testIPv6Connectivity2(targetIPv6)) {
1999
- console.log(chalk6.green("\u2713 VPN already connected"));
2000
- return false;
2001
- }
2002
- console.log(chalk6.yellow("VPN is up but cannot reach target, reconnecting..."));
2003
- vpnDown();
2004
- }
2005
- if (!isWireGuardInstalled()) {
2006
- const instructions = getWireGuardInstallInstructions();
2007
- throw new Error(
2008
- "WireGuard is not installed.\n\nWireGuard is needed for IPv6 connectivity to your app VMs.\nPlease install it and try again:\n\n" + instructions
2009
- );
2010
- }
2011
- console.log(chalk6.cyan("Establishing VPN tunnel (requires sudo)..."));
2012
- const { publicKey, isNew } = getOrCreateKeyPair();
2013
- if (isNew) {
2014
- console.log(chalk6.dim("Generated new WireGuard keypair"));
2015
- }
2016
- const vpnConfig = await getVPNConfig();
2017
- writeWireGuardConfig(vpnConfig);
2018
- vpnUp();
2019
- if (!testIPv6Connectivity2(targetIPv6)) {
2020
- vpnDown();
2021
- throw new Error("VPN established but cannot reach target. Please try again.");
2022
- }
2023
- console.log(chalk6.green("\u2713 VPN connected"));
2024
- this.wasConnectedByUs = true;
2025
- return true;
2076
+ async setEnvVar(appName, key, value) {
2077
+ await this.request("POST", `/api/apps/${appName}/env`, { key, value });
2026
2078
  }
2027
2079
  /**
2028
- * Disconnect VPN if we established it
2080
+ * Set multiple environment variables at once
2029
2081
  */
2030
- disconnect() {
2031
- if (this.wasConnectedByUs && isVPNUp()) {
2032
- console.log(chalk6.dim("Disconnecting VPN..."));
2033
- try {
2034
- vpnDown();
2035
- console.log(chalk6.green("\u2713 VPN disconnected"));
2036
- } catch (error) {
2037
- console.log(chalk6.yellow(`Warning: Failed to disconnect VPN: ${error.message}`));
2038
- }
2039
- this.wasConnectedByUs = false;
2040
- }
2082
+ async setEnvVars(appName, vars) {
2083
+ await this.request("POST", `/api/apps/${appName}/env`, { vars });
2041
2084
  }
2042
- };
2043
-
2044
- // src/lib/uncloud-runner.ts
2045
- import chalk7 from "chalk";
2046
- var UncloudRunner = class {
2047
- constructor(platformClient) {
2048
- this.platformClient = platformClient;
2049
- 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;
2050
2091
  }
2051
- certManager;
2052
- gatewayCache = /* @__PURE__ */ new Map();
2053
- tunnelManager = new TunnelManager();
2054
- vpnManager = new VPNManager();
2055
- tempConfigPath = null;
2056
- vpnEstablishedByUs = false;
2057
2092
  /**
2058
- * Get the connection URL for an app's primary VM
2059
- * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
2093
+ * Remove an environment variable
2060
2094
  */
2061
- async getConnectionInfo(appName) {
2062
- const app = await this.platformClient.getApp(appName);
2063
- if (!app) {
2064
- throw new Error(`App '${appName}' not found`);
2065
- }
2066
- const primaryNode = app.nodes.find((n) => n.isPrimary);
2067
- if (!primaryNode?.ipv6) {
2068
- throw new Error(`App '${appName}' has no primary node with IPv6 address`);
2069
- }
2070
- const vmIp = primaryNode.ipv6;
2071
- await this.certManager.getSession(appName, vmIp);
2072
- const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
2073
- if (canConnectDirect) {
2074
- const viaVPN = isRoutedViaVPN(vmIp);
2075
- return {
2076
- url: `ssh://root@${vmIp}`,
2077
- vmIp,
2078
- viaGateway: false,
2079
- viaVPN
2080
- };
2081
- }
2082
- const vpnAvailable = isWireGuardInstalled();
2083
- if (vpnAvailable) {
2084
- console.log(chalk7.yellow("Direct IPv6 not available, trying VPN..."));
2085
- try {
2086
- if (isVPNUp() && testIPv6Connectivity2(vmIp, 3)) {
2087
- console.log(chalk7.green("\u2713 VPN already connected"));
2088
- return {
2089
- url: `ssh://root@${vmIp}`,
2090
- vmIp,
2091
- viaGateway: false,
2092
- viaVPN: true
2093
- };
2094
- }
2095
- this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
2096
- vmIp,
2097
- async () => this.getVPNConfig(app.location)
2098
- );
2099
- if (testIPv6Connectivity2(vmIp, 5)) {
2100
- console.log(chalk7.green("\u2713 Connected via VPN"));
2101
- return {
2102
- url: `ssh://root@${vmIp}`,
2103
- vmIp,
2104
- viaGateway: false,
2105
- viaVPN: true
2106
- };
2107
- }
2108
- } catch (error) {
2109
- console.log(chalk7.yellow(`VPN setup failed: ${error.message}`));
2110
- console.log(chalk7.dim("Falling back to SSH tunnel..."));
2111
- }
2112
- } else {
2113
- console.log(chalk7.yellow("Direct IPv6 not available on your network."));
2114
- console.log(chalk7.dim("For faster deploys, install WireGuard:"));
2115
- console.log(chalk7.dim(getWireGuardInstallInstructions()));
2116
- console.log();
2117
- }
2118
- console.log(chalk7.yellow("Using SSH tunnel (may be slower for large transfers)..."));
2119
- const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
2120
- return {
2121
- url: `ssh://root@localhost:${tunnelInfo.localPort}`,
2122
- vmIp,
2123
- viaGateway: true,
2124
- viaVPN: false,
2125
- localPort: tunnelInfo.localPort
2126
- };
2095
+ async unsetEnvVar(appName, key) {
2096
+ await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
2127
2097
  }
2098
+ // ==================== GitHub Connection ====================
2128
2099
  /**
2129
- * Get VPN configuration from platform
2100
+ * Create app metadata without creating VM (for connect-before-deploy flow)
2130
2101
  */
2131
- async getVPNConfig(location) {
2132
- const { privateKey, publicKey } = getOrCreateKeyPair();
2133
- const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
2134
- return {
2135
- privateKey,
2136
- publicKey,
2137
- address: vpnConfigResponse.address,
2138
- gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
2139
- gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
2140
- allowedIPs: vpnConfigResponse.allowedIPs
2141
- };
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;
2142
2110
  }
2143
2111
  /**
2144
- * Pre-accept a host's SSH key by running ssh-keyscan
2145
- * This prevents "Host key verification failed" errors from uncloud
2112
+ * Initiate GitHub App installation flow
2146
2113
  */
2147
- 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() {
2148
2127
  try {
2149
- const portArg = port ? `-p ${port}` : "";
2150
- execSync5(
2151
- `ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
2152
- { stdio: "pipe", timeout: 1e4 }
2153
- );
2154
- } 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;
2155
2135
  }
2156
2136
  }
2157
2137
  /**
2158
- * 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
2159
2146
  */
2160
- async ensureTunnel(appName, vmIp, location) {
2161
- const gateway = await this.getGateway(location);
2162
- if (!gateway) {
2163
- throw new Error(`No gateway found for location ${location}`);
2164
- }
2165
- return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
2147
+ async listAccessibleRepos() {
2148
+ const { repos } = await this.request("GET", "/api/github/repos");
2149
+ return repos;
2166
2150
  }
2167
2151
  /**
2168
- * Run an uncloud command on the app's VM
2152
+ * Connect a repository to an app
2169
2153
  */
2170
- async run(appName, command, args = [], options = {}) {
2171
- const connInfo = await this.getConnectionInfo(appName);
2172
- if (connInfo.viaGateway) {
2173
- console.log(chalk7.dim(`Connecting via gateway...`));
2174
- }
2175
- const fullArgs = ["--connect", connInfo.url, command, ...args];
2176
- if (options.stdio === "inherit") {
2177
- const result = spawnSync3("uc", fullArgs, {
2178
- cwd: options.cwd,
2179
- stdio: "inherit",
2180
- timeout: options.timeout
2181
- });
2182
- if (result.status !== 0) {
2183
- 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;
2184
2170
  }
2185
- } else {
2186
- const result = execSync5(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
2187
- cwd: options.cwd,
2188
- encoding: "utf-8",
2189
- timeout: options.timeout
2190
- });
2191
- return result;
2171
+ throw error;
2192
2172
  }
2193
2173
  }
2194
2174
  /**
2195
- * Run 'uc deploy' for an app
2175
+ * Disconnect repository from an app
2196
2176
  */
2197
- async deploy(appName, cwd) {
2198
- await this.run(appName, "deploy", ["--yes"], { cwd, stdio: "inherit" });
2177
+ async disconnectRepo(appName) {
2178
+ await this.request("DELETE", `/api/apps/${appName}/repo`);
2199
2179
  }
2180
+ // ==================== Builds ====================
2200
2181
  /**
2201
- * Run 'uc service logs' for an app
2182
+ * List builds for an app
2202
2183
  */
2203
- async logs(appName, serviceName, options = {}) {
2204
- const args = [serviceName];
2205
- if (options.follow) args.push("-f");
2206
- if (options.tail) args.push("--tail", String(options.tail));
2207
- 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;
2208
2187
  }
2209
2188
  /**
2210
- * Run 'uc service ls' for an app
2189
+ * Get build details
2211
2190
  */
2212
- async serviceList(appName) {
2213
- const result = await this.run(appName, "service", ["ls"], { stdio: "pipe" });
2214
- return result;
2191
+ async getBuild(appName, buildId) {
2192
+ const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
2193
+ return build;
2215
2194
  }
2216
2195
  /**
2217
- * Run 'uc machine token' on remote VM via SSH
2218
- * This is different - it runs on the VM directly, not via uncloud connector
2196
+ * Get build events (for live streaming)
2219
2197
  */
2220
- async getMachineToken(appName) {
2221
- const connInfo = await this.getConnectionInfo(appName);
2222
- let sshCmd2;
2223
- if (connInfo.viaGateway && connInfo.localPort) {
2224
- sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
2225
- } else {
2226
- 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())}`;
2227
2202
  }
2228
- const token = execSync5(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
2229
- return token;
2203
+ const { events } = await this.request("GET", url);
2204
+ return events;
2230
2205
  }
2206
+ // ==================== VPN Management ====================
2231
2207
  /**
2232
- * Execute an SSH command on the VM
2208
+ * Register VPN peer with the platform
2209
+ * This is idempotent - if already registered, returns existing config
2233
2210
  */
2234
- async sshExec(appName, command) {
2235
- const connInfo = await this.getConnectionInfo(appName);
2236
- let sshCmd2;
2237
- if (connInfo.viaGateway && connInfo.localPort) {
2238
- sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
2239
- } else {
2240
- sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
2241
- }
2242
- 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;
2243
2214
  }
2244
2215
  /**
2245
- * Remove a node from the uncloud cluster
2246
- * Uses 'uc machine rm' to drain containers and remove from cluster
2216
+ * Get existing VPN config if registered
2247
2217
  */
2248
- async removeNode(appName, nodeName) {
2249
- const connInfo = await this.getConnectionInfo(appName);
2250
- const result = spawnSync3("uc", ["--connect", connInfo.url, "machine", "rm", nodeName, "--yes"], {
2251
- stdio: "inherit",
2252
- timeout: 3e5
2253
- // 5 minute timeout for container drainage
2254
- });
2255
- if (result.status !== 0) {
2256
- 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;
2257
2227
  }
2258
2228
  }
2259
2229
  /**
2260
- * List machines in the uncloud cluster
2230
+ * Unregister VPN peer
2261
2231
  */
2262
- async listMachines(appName) {
2263
- const result = await this.run(appName, "machine", ["ls"], { stdio: "pipe" });
2264
- return result;
2232
+ async unregisterVPNPeer() {
2233
+ await this.request("DELETE", "/api/vpn/unregister");
2265
2234
  }
2266
- /**
2267
- * Get gateway info (cached)
2268
- */
2269
- async getGateway(location) {
2270
- if (this.gatewayCache.has(location)) {
2271
- return this.gatewayCache.get(location) || null;
2272
- }
2273
- const gateway = await this.platformClient.getGateway(location);
2274
- if (gateway) {
2275
- this.gatewayCache.set(location, { ipv4: gateway.ipv4, ipv6: gateway.ipv6 });
2276
- return { ipv4: gateway.ipv4, ipv6: gateway.ipv6 };
2277
- }
2278
- 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)) {
2279
2251
  return null;
2280
2252
  }
2281
- /**
2282
- * Clean up all SSH sessions, tunnels, and VPN
2283
- */
2284
- cleanup() {
2285
- this.tunnelManager.closeAll();
2286
- this.certManager.cleanupAll();
2287
- if (this.vpnEstablishedByUs) {
2288
- this.vpnManager.disconnect();
2289
- this.vpnEstablishedByUs = false;
2253
+ try {
2254
+ const content = readFileSync4(configPath, "utf-8");
2255
+ const config = parse(content);
2256
+ if (!config.appName) {
2257
+ return null;
2290
2258
  }
2259
+ return config;
2260
+ } catch (error) {
2261
+ console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
2262
+ return null;
2291
2263
  }
2292
- };
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
+ }
2293
2281
 
2294
2282
  // src/commands/deploy.ts
2295
2283
  var DEFAULT_LOCATION = "eu-central-h1";
@@ -3739,19 +3727,33 @@ Error: ${error.message}
3739
3727
  import { Command as Command10 } from "commander";
3740
3728
  import chalk16 from "chalk";
3741
3729
  import ora10 from "ora";
3742
- import { confirm } from "@inquirer/prompts";
3743
- var DEFAULT_VM_SIZE = "standard-2";
3730
+ import { select as select2, confirm } from "@inquirer/prompts";
3731
+ var DEFAULT_VM_SIZE = "burstable-1";
3744
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
+ }
3745
3748
  function createScaleCommand() {
3746
3749
  const cmd = new Command10("scale");
3747
- 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) => {
3748
3751
  let uncloudRunner = null;
3749
3752
  try {
3750
- const appName = options.app || getAppName();
3751
3753
  const platformToken = getPlatformToken();
3752
3754
  const platformClient = new PlatformClient(platformToken);
3753
- const clusterManager = new ClusterManager(platformClient);
3754
3755
  uncloudRunner = new UncloudRunner(platformClient);
3756
+ const clusterManager = new ClusterManager(platformClient, uncloudRunner);
3755
3757
  const app = await platformClient.getApp(appName);
3756
3758
  if (!app) {
3757
3759
  console.log(chalk16.red(`
@@ -3761,146 +3763,122 @@ App '${appName}' not found
3761
3763
  `);
3762
3764
  process.exit(1);
3763
3765
  }
3764
- const currentNodeCount = app.nodes.length;
3765
- 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) {
3766
3787
  console.log(chalk16.cyan(`
3767
- App '${appName}' Scale Info:
3788
+ Services in '${appName}':
3768
3789
  `));
3769
- console.log(` Current nodes: ${chalk16.bold(currentNodeCount.toString())}`);
3770
- console.log();
3771
- app.nodes.forEach((node, index) => {
3772
- const primaryLabel = node.isPrimary ? chalk16.yellow(" (primary)") : "";
3773
- console.log(` ${index + 1}. ${chalk16.bold(node.name)}${primaryLabel}`);
3774
- console.log(` IPv6: ${node.ipv6 || "pending"}`);
3775
- console.log();
3790
+ serviceName = await select2({
3791
+ message: "Which service would you like to scale?",
3792
+ choices: services.map((s) => ({ name: s, value: s }))
3776
3793
  });
3777
- console.log(chalk16.dim("Usage:"));
3778
- console.log(chalk16.dim(` hackerrun scale 3 # Scale to 3 nodes`));
3779
- console.log(chalk16.dim(` hackerrun scale +1 # Add 1 node`));
3780
- console.log(chalk16.dim(` hackerrun scale -1 # Remove 1 node`));
3781
- console.log();
3782
- return;
3783
- }
3784
- let targetCount;
3785
- 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}
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>
3790
3797
  `));
3791
3798
  process.exit(1);
3792
3799
  }
3793
- targetCount = currentNodeCount + delta;
3794
- } else if (countArg.startsWith("-")) {
3795
- const delta = parseInt(countArg.slice(1), 10);
3796
- if (isNaN(delta) || delta <= 0) {
3797
- console.log(chalk16.red(`
3798
- 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>
3799
3815
  `));
3800
3816
  process.exit(1);
3801
3817
  }
3802
- targetCount = currentNodeCount - delta;
3803
3818
  } else {
3804
- targetCount = parseInt(countArg, 10);
3805
- if (isNaN(targetCount)) {
3819
+ serviceName = serviceArg;
3820
+ replicas = parseInt(replicasArg, 10);
3821
+ if (!services.includes(serviceName)) {
3806
3822
  console.log(chalk16.red(`
3807
- Invalid count: ${countArg}
3823
+ Service '${serviceName}' not found in app '${appName}'.
3808
3824
  `));
3825
+ console.log(chalk16.cyan("Available services:"));
3826
+ services.forEach((s) => console.log(` - ${s}`));
3827
+ console.log();
3809
3828
  process.exit(1);
3810
3829
  }
3811
3830
  }
3812
- if (targetCount < 1) {
3813
- console.log(chalk16.red(`
3814
- Cannot scale below 1 node. Use 'hackerrun destroy' to remove the app.
3815
- `));
3831
+ if (isNaN(replicas) || replicas < 1) {
3832
+ console.log(chalk16.red("\nReplicas must be a positive number.\n"));
3816
3833
  process.exit(1);
3817
3834
  }
3818
- if (targetCount === currentNodeCount) {
3819
- console.log(chalk16.yellow(`
3820
- 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}
3821
3839
  `));
3822
- return;
3823
- }
3824
- const vmSize = options.size || DEFAULT_VM_SIZE;
3825
- if (targetCount > currentNodeCount) {
3826
- const nodesToAdd = targetCount - currentNodeCount;
3827
- console.log(chalk16.cyan(`
3828
- 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).
3829
3847
  `));
3830
- for (let i = 0; i < nodesToAdd; i++) {
3831
- console.log(chalk16.cyan(`
3832
- Adding node ${i + 1} of ${nodesToAdd}...`));
3833
- const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
3834
- console.log(chalk16.green(` Added: ${newNode.name} (${newNode.ipv6})`));
3835
- }
3836
- const updatedApp = await platformClient.getApp(appName);
3837
- const syncSpinner = ora10("Syncing gateway routes...").start();
3838
- try {
3839
- await platformClient.syncGatewayRoutes(app.location);
3840
- syncSpinner.succeed("Gateway routes synced");
3841
- } catch (error) {
3842
- syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3843
- }
3844
- console.log(chalk16.green(`
3845
- \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3846
- `));
3847
- console.log(chalk16.dim("To deploy to specific nodes, use x-machines in your compose file:"));
3848
- console.log(chalk16.dim(" services:"));
3849
- console.log(chalk16.dim(" web:"));
3850
- console.log(chalk16.dim(" x-machines:"));
3851
- updatedApp?.nodes.forEach((node) => {
3852
- console.log(chalk16.dim(` - ${node.name}`));
3853
- });
3854
- console.log();
3855
- } else {
3856
- const nodesToRemove = currentNodeCount - targetCount;
3857
- const sortedNodes = [...app.nodes].sort((a, b) => {
3858
- const numA = parseInt(a.name.split("-").pop() || "0", 10);
3859
- const numB = parseInt(b.name.split("-").pop() || "0", 10);
3860
- return numB - numA;
3861
- });
3862
- const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
3863
- console.log(chalk16.yellow(`
3864
- 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)
3865
3853
  `));
3866
- console.log(chalk16.yellow("The following nodes will be removed:"));
3867
- nodesToRemoveList.forEach((node) => {
3868
- console.log(` - ${node.name} (${node.ipv6})`);
3869
- });
3870
- console.log();
3871
- if (!options.force) {
3872
- console.log(chalk16.yellow("Warning: All containers on these nodes will be stopped and the VMs deleted."));
3873
- console.log(chalk16.yellow("Data on these nodes will be lost.\n"));
3874
- const confirmed = await confirm({
3875
- message: "Are you sure you want to remove these nodes?",
3854
+ shouldAddMachines = await confirm({
3855
+ message: `Add ${replicas - currentMachineCount} new machine(s)?`,
3876
3856
  default: false
3877
3857
  });
3878
- if (!confirmed) {
3879
- console.log(chalk16.dim("\nScale cancelled.\n"));
3880
- return;
3858
+ if (!shouldAddMachines) {
3859
+ console.log(chalk16.dim(`
3860
+ Scaling ${replicas} replicas across ${currentMachineCount} existing machine(s).
3861
+ `));
3881
3862
  }
3882
3863
  }
3883
- for (const node of nodesToRemoveList) {
3884
- console.log(chalk16.cyan(`
3885
- 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}...`));
3886
3873
  try {
3887
- const spinner = ora10("Removing from uncloud cluster...").start();
3888
- await uncloudRunner.removeNode(appName, node.name);
3889
- spinner.succeed("Removed from cluster");
3890
- spinner.start("Deleting VM...");
3891
- await platformClient.deleteVM(app.location, node.name);
3892
- spinner.succeed("VM deleted");
3893
- spinner.start("Updating app state...");
3894
- const updatedNodes = app.nodes.filter((n) => n.name !== node.name);
3895
- app.nodes = updatedNodes;
3896
- await platformClient.saveApp(app);
3897
- spinner.succeed("App state updated");
3898
- 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})`));
3899
3876
  } catch (error) {
3900
- 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;
3901
3880
  }
3902
3881
  }
3903
- const updatedApp = await platformClient.getApp(appName);
3904
3882
  const syncSpinner = ora10("Syncing gateway routes...").start();
3905
3883
  try {
3906
3884
  await platformClient.syncGatewayRoutes(app.location);
@@ -3908,10 +3886,22 @@ Removing node '${node.name}'...`));
3908
3886
  } catch (error) {
3909
3887
  syncSpinner.warn(`Gateway sync failed: ${error.message}`);
3910
3888
  }
3911
- console.log(chalk16.green(`
3912
- \u2713 Scaled to ${updatedApp?.nodes.length} nodes
3913
- `));
3914
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();
3915
3905
  } catch (error) {
3916
3906
  console.error(chalk16.red(`
3917
3907
  Error: ${error.message}