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