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