buildwithnexus 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +139 -67
- package/package.json +2 -2
- package/dist/nexus-release.tar.gz +0 -0
package/dist/bin.js
CHANGED
|
@@ -341,9 +341,9 @@ var init_secrets = __esm({
|
|
|
341
341
|
"src/core/secrets.ts"() {
|
|
342
342
|
"use strict";
|
|
343
343
|
init_dlp();
|
|
344
|
-
NEXUS_HOME2 = path2.join(process.env.HOME || "~", ".buildwithnexus");
|
|
345
|
-
CONFIG_PATH = path2.join(NEXUS_HOME2, "config.json");
|
|
346
|
-
KEYS_PATH = path2.join(NEXUS_HOME2, ".env.keys");
|
|
344
|
+
NEXUS_HOME2 = process.env.NEXUS_HOME || path2.join(process.env.HOME || "~", ".buildwithnexus");
|
|
345
|
+
CONFIG_PATH = process.env.NEXUS_CONFIG_PATH || path2.join(NEXUS_HOME2, "config.json");
|
|
346
|
+
KEYS_PATH = process.env.NEXUS_KEYS_PATH || path2.join(NEXUS_HOME2, ".env.keys");
|
|
347
347
|
}
|
|
348
348
|
});
|
|
349
349
|
|
|
@@ -438,59 +438,59 @@ async function getPortBlocker(port) {
|
|
|
438
438
|
return null;
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
|
+
async function findFreePort(preferred, max = 20) {
|
|
442
|
+
for (let offset = 0; offset < max; offset++) {
|
|
443
|
+
if (await isPortFree(preferred + offset)) return preferred + offset;
|
|
444
|
+
}
|
|
445
|
+
throw new Error(`No free port found near ${preferred}`);
|
|
446
|
+
}
|
|
441
447
|
async function resolvePortConflicts(ports) {
|
|
442
448
|
const labels = { ssh: "SSH", http: "HTTP", https: "HTTPS" };
|
|
443
449
|
const resolved = { ...ports };
|
|
444
|
-
const claimed = /* @__PURE__ */ new Set();
|
|
445
450
|
for (const [key, port] of Object.entries(ports)) {
|
|
446
|
-
if (await isPortFree(port)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
451
|
+
if (await isPortFree(port)) continue;
|
|
452
|
+
const blocker = await getPortBlocker(port);
|
|
453
|
+
const desc = blocker ? `${blocker.process} (PID ${blocker.pid})` : "unknown process";
|
|
454
|
+
const altPort = await findFreePort(port + 1).catch(() => null);
|
|
455
|
+
const choices = [];
|
|
456
|
+
if (blocker) {
|
|
457
|
+
choices.push({
|
|
458
|
+
name: `Kill ${desc} and use port ${port}`,
|
|
459
|
+
value: "kill"
|
|
460
|
+
});
|
|
457
461
|
}
|
|
458
462
|
if (altPort) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const desc = blocker ? `${blocker.process} (PID ${blocker.pid})` : "another process";
|
|
463
|
-
console.log(chalk5.yellow(` \u26A0 Port ${port} (${labels[key]}) in use by ${desc} \u2192 using ${altPort}`));
|
|
464
|
-
} else {
|
|
465
|
-
const blocker = await getPortBlocker(port);
|
|
466
|
-
const desc = blocker ? `${blocker.process} (PID ${blocker.pid})` : "unknown process";
|
|
467
|
-
console.log("");
|
|
468
|
-
console.log(chalk5.red(` \u2717 Port ${port} (${labels[key]}) is in use by ${desc} and no free port found nearby`));
|
|
469
|
-
const action = await select({
|
|
470
|
-
message: `No free ${labels[key]} port found. Kill ${desc} to free port ${port}?`,
|
|
471
|
-
choices: [
|
|
472
|
-
...blocker ? [{ name: `Kill ${desc} and use port ${port}`, value: "kill" }] : [],
|
|
473
|
-
{ name: "Abort init", value: "abort" }
|
|
474
|
-
]
|
|
463
|
+
choices.push({
|
|
464
|
+
name: `Use alternate port ${altPort} instead`,
|
|
465
|
+
value: "alt"
|
|
475
466
|
});
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
467
|
+
}
|
|
468
|
+
choices.push({ name: "Abort init", value: "abort" });
|
|
469
|
+
console.log("");
|
|
470
|
+
console.log(chalk5.yellow(` \u26A0 Port ${port} (${labels[key]}) is in use by ${desc}`));
|
|
471
|
+
const action = await select({
|
|
472
|
+
message: `How would you like to resolve the ${labels[key]} port conflict?`,
|
|
473
|
+
choices
|
|
474
|
+
});
|
|
475
|
+
if (action === "kill" && blocker) {
|
|
476
|
+
try {
|
|
477
|
+
process.kill(blocker.pid, "SIGTERM");
|
|
478
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
479
|
+
if (!await isPortFree(port)) {
|
|
480
|
+
process.kill(blocker.pid, "SIGKILL");
|
|
481
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
489
482
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
483
|
+
console.log(chalk5.green(` \u2713 Killed ${desc}, port ${port} is now free`));
|
|
484
|
+
} catch {
|
|
485
|
+
console.log(chalk5.red(` \u2717 Failed to kill PID ${blocker.pid}. Try: sudo kill ${blocker.pid}`));
|
|
486
|
+
process.exit(1);
|
|
493
487
|
}
|
|
488
|
+
} else if (action === "alt" && altPort) {
|
|
489
|
+
resolved[key] = altPort;
|
|
490
|
+
console.log(chalk5.green(` \u2713 Using port ${altPort} for ${labels[key]}`));
|
|
491
|
+
} else {
|
|
492
|
+
console.log(chalk5.dim(" Init aborted."));
|
|
493
|
+
process.exit(0);
|
|
494
494
|
}
|
|
495
495
|
}
|
|
496
496
|
return resolved;
|
|
@@ -671,11 +671,13 @@ function addSshConfig(port) {
|
|
|
671
671
|
fs4.writeFileSync(sshConfigPath, block, { mode: 384 });
|
|
672
672
|
}
|
|
673
673
|
}
|
|
674
|
-
async function waitForSsh(port, timeoutMs =
|
|
674
|
+
async function waitForSsh(port, timeoutMs = 3e5) {
|
|
675
675
|
const start = Date.now();
|
|
676
|
+
let attempt = 0;
|
|
677
|
+
const backoffMs = (n) => Math.min(3e3 * Math.pow(2, n), 3e4);
|
|
676
678
|
while (Date.now() - start < timeoutMs) {
|
|
679
|
+
const ssh = new NodeSSH();
|
|
677
680
|
try {
|
|
678
|
-
const ssh = new NodeSSH();
|
|
679
681
|
await ssh.connect({
|
|
680
682
|
host: "localhost",
|
|
681
683
|
port,
|
|
@@ -687,7 +689,14 @@ async function waitForSsh(port, timeoutMs = 6e5) {
|
|
|
687
689
|
ssh.dispose();
|
|
688
690
|
return true;
|
|
689
691
|
} catch {
|
|
690
|
-
|
|
692
|
+
try {
|
|
693
|
+
ssh.dispose();
|
|
694
|
+
} catch {
|
|
695
|
+
}
|
|
696
|
+
const delay = backoffMs(attempt++);
|
|
697
|
+
const remaining = timeoutMs - (Date.now() - start);
|
|
698
|
+
if (remaining <= 0) break;
|
|
699
|
+
await new Promise((r) => setTimeout(r, Math.min(delay, remaining)));
|
|
691
700
|
}
|
|
692
701
|
}
|
|
693
702
|
return false;
|
|
@@ -776,6 +785,13 @@ var log = {
|
|
|
776
785
|
},
|
|
777
786
|
detail(label, value) {
|
|
778
787
|
console.log(chalk3.dim(" " + label + ": ") + value);
|
|
788
|
+
},
|
|
789
|
+
progress(current, total, label) {
|
|
790
|
+
const pct = Math.round(current / total * 100);
|
|
791
|
+
const filled = Math.round(current / total * 20);
|
|
792
|
+
const bar = chalk3.cyan("\u2588".repeat(filled)) + chalk3.dim("\u2591".repeat(20 - filled));
|
|
793
|
+
process.stdout.write(`\r [${bar}] ${chalk3.bold(`${pct}%`)} ${chalk3.dim(label)}`);
|
|
794
|
+
if (current >= total) process.stdout.write("\n");
|
|
779
795
|
}
|
|
780
796
|
};
|
|
781
797
|
|
|
@@ -938,13 +954,13 @@ async function renderCloudInit(data, templateContent) {
|
|
|
938
954
|
}
|
|
939
955
|
const safeData = { ...data, sshPubKey: yamlEscape(trimmedPubKey), keys: safeKeys };
|
|
940
956
|
const rendered = ejs.render(templateContent, safeData);
|
|
941
|
-
const outputPath = path5.join(CONFIGS_DIR, "user-data");
|
|
957
|
+
const outputPath = path5.join(CONFIGS_DIR, "user-data.yaml");
|
|
942
958
|
fs5.writeFileSync(outputPath, rendered, { mode: 384 });
|
|
943
|
-
audit("cloudinit_rendered", "user-data written");
|
|
959
|
+
audit("cloudinit_rendered", "user-data.yaml written");
|
|
944
960
|
return outputPath;
|
|
945
961
|
}
|
|
946
962
|
async function createCloudInitIso(userDataPath) {
|
|
947
|
-
const metaDataPath = path5.join(CONFIGS_DIR, "meta-data");
|
|
963
|
+
const metaDataPath = path5.join(CONFIGS_DIR, "meta-data.yaml");
|
|
948
964
|
fs5.writeFileSync(metaDataPath, "instance-id: nexus-vm-1\nlocal-hostname: nexus-vm\n", { mode: 384 });
|
|
949
965
|
const isoPath = path5.join(IMAGES_DIR2, "init.iso");
|
|
950
966
|
const env = scrubEnv();
|
|
@@ -1005,7 +1021,7 @@ async function createCloudInitIso(userDataPath) {
|
|
|
1005
1021
|
fs5.unlinkSync(metaDataPath);
|
|
1006
1022
|
} catch {
|
|
1007
1023
|
}
|
|
1008
|
-
audit("cloudinit_plaintext_deleted", "user-data and meta-data removed");
|
|
1024
|
+
audit("cloudinit_plaintext_deleted", "user-data.yaml and meta-data.yaml removed");
|
|
1009
1025
|
}
|
|
1010
1026
|
}
|
|
1011
1027
|
|
|
@@ -1017,7 +1033,12 @@ async function checkHealth(port, vmRunning) {
|
|
|
1017
1033
|
sshReady: false,
|
|
1018
1034
|
dockerReady: false,
|
|
1019
1035
|
serverHealthy: false,
|
|
1020
|
-
tunnelUrl: null
|
|
1036
|
+
tunnelUrl: null,
|
|
1037
|
+
dockerVersion: null,
|
|
1038
|
+
serverVersion: null,
|
|
1039
|
+
diskUsagePercent: null,
|
|
1040
|
+
uptimeSeconds: null,
|
|
1041
|
+
lastChecked: (/* @__PURE__ */ new Date()).toISOString()
|
|
1021
1042
|
};
|
|
1022
1043
|
if (!vmRunning) return status;
|
|
1023
1044
|
try {
|
|
@@ -1027,13 +1048,33 @@ async function checkHealth(port, vmRunning) {
|
|
|
1027
1048
|
return status;
|
|
1028
1049
|
}
|
|
1029
1050
|
try {
|
|
1030
|
-
const { code } = await sshExec(port, "docker version --format '{{.Server.Version}}'");
|
|
1031
|
-
status.dockerReady = code === 0;
|
|
1051
|
+
const { stdout, code } = await sshExec(port, "docker version --format '{{.Server.Version}}'");
|
|
1052
|
+
status.dockerReady = code === 0 && stdout.trim().length > 0;
|
|
1053
|
+
if (status.dockerReady) status.dockerVersion = stdout.trim();
|
|
1032
1054
|
} catch {
|
|
1033
1055
|
}
|
|
1034
1056
|
try {
|
|
1035
1057
|
const { stdout, code } = await sshExec(port, "curl -sf http://localhost:4200/health");
|
|
1036
1058
|
status.serverHealthy = code === 0 && stdout.includes("ok");
|
|
1059
|
+
if (status.serverHealthy) {
|
|
1060
|
+
try {
|
|
1061
|
+
const parsed = JSON.parse(stdout);
|
|
1062
|
+
if (typeof parsed.version === "string") status.serverVersion = parsed.version;
|
|
1063
|
+
} catch {
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
} catch {
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
const { stdout } = await sshExec(port, "df / --output=pcent | tail -1 | tr -dc '0-9'");
|
|
1070
|
+
const pct = parseInt(stdout.trim(), 10);
|
|
1071
|
+
if (!isNaN(pct)) status.diskUsagePercent = pct;
|
|
1072
|
+
} catch {
|
|
1073
|
+
}
|
|
1074
|
+
try {
|
|
1075
|
+
const { stdout } = await sshExec(port, "awk '{print int($1)}' /proc/uptime 2>/dev/null");
|
|
1076
|
+
const up = parseInt(stdout.trim(), 10);
|
|
1077
|
+
if (!isNaN(up)) status.uptimeSeconds = up;
|
|
1037
1078
|
} catch {
|
|
1038
1079
|
}
|
|
1039
1080
|
try {
|
|
@@ -1045,9 +1086,11 @@ async function checkHealth(port, vmRunning) {
|
|
|
1045
1086
|
}
|
|
1046
1087
|
return status;
|
|
1047
1088
|
}
|
|
1048
|
-
async function waitForServer(port, timeoutMs =
|
|
1089
|
+
async function waitForServer(port, timeoutMs = 9e5) {
|
|
1049
1090
|
const start = Date.now();
|
|
1050
1091
|
let lastLog = 0;
|
|
1092
|
+
let attempt = 0;
|
|
1093
|
+
const backoffMs = (n) => Math.min(3e3 * Math.pow(2, n), 3e4);
|
|
1051
1094
|
while (Date.now() - start < timeoutMs) {
|
|
1052
1095
|
try {
|
|
1053
1096
|
const { stdout, code } = await sshExec(port, "curl -sf http://localhost:4200/health");
|
|
@@ -1065,13 +1108,18 @@ async function waitForServer(port, timeoutMs = 36e5) {
|
|
|
1065
1108
|
} catch {
|
|
1066
1109
|
}
|
|
1067
1110
|
}
|
|
1068
|
-
|
|
1111
|
+
const delay = backoffMs(attempt++);
|
|
1112
|
+
const remaining = timeoutMs - (Date.now() - start);
|
|
1113
|
+
if (remaining <= 0) break;
|
|
1114
|
+
await new Promise((r) => setTimeout(r, Math.min(delay, remaining)));
|
|
1069
1115
|
}
|
|
1070
1116
|
return false;
|
|
1071
1117
|
}
|
|
1072
|
-
async function waitForCloudInit(port, timeoutMs =
|
|
1118
|
+
async function waitForCloudInit(port, timeoutMs = 18e5) {
|
|
1073
1119
|
const start = Date.now();
|
|
1074
1120
|
let lastLog = 0;
|
|
1121
|
+
let attempt = 0;
|
|
1122
|
+
const backoffMs = (n) => Math.min(3e3 * Math.pow(2, n), 3e4);
|
|
1075
1123
|
while (Date.now() - start < timeoutMs) {
|
|
1076
1124
|
try {
|
|
1077
1125
|
const { code } = await sshExec(port, "test -f /var/lib/cloud/instance/boot-finished");
|
|
@@ -1089,7 +1137,10 @@ async function waitForCloudInit(port, timeoutMs = 72e5) {
|
|
|
1089
1137
|
} catch {
|
|
1090
1138
|
}
|
|
1091
1139
|
}
|
|
1092
|
-
|
|
1140
|
+
const delay = backoffMs(attempt++);
|
|
1141
|
+
const remaining = timeoutMs - (Date.now() - start);
|
|
1142
|
+
if (remaining <= 0) break;
|
|
1143
|
+
await new Promise((r) => setTimeout(r, Math.min(delay, remaining)));
|
|
1093
1144
|
}
|
|
1094
1145
|
return false;
|
|
1095
1146
|
}
|
|
@@ -1305,8 +1356,6 @@ var phases = [
|
|
|
1305
1356
|
name: "VM Provisioning",
|
|
1306
1357
|
run: async (ctx, spinner) => {
|
|
1307
1358
|
const { config, keys, tarballPath } = ctx;
|
|
1308
|
-
const pinPath = path6.join(process.env.HOME || "~", ".buildwithnexus", "ssh", "vm_host_key.pin");
|
|
1309
|
-
if (fs6.existsSync(pinPath)) fs6.unlinkSync(pinPath);
|
|
1310
1359
|
spinner.text = "Waiting for SSH...";
|
|
1311
1360
|
spinner.start();
|
|
1312
1361
|
const sshReady = await waitForSsh(config.sshPort);
|
|
@@ -1339,7 +1388,7 @@ var phases = [
|
|
|
1339
1388
|
spinner.start();
|
|
1340
1389
|
const cloudInitDone = await waitForCloudInit(config.sshPort);
|
|
1341
1390
|
if (!cloudInitDone) {
|
|
1342
|
-
fail(spinner, "Cloud-init timed out after
|
|
1391
|
+
fail(spinner, "Cloud-init timed out after 30 minutes");
|
|
1343
1392
|
log.warn("Check progress: buildwithnexus ssh \u2192 tail -f /var/log/cloud-init-output.log");
|
|
1344
1393
|
throw new Error("Cloud-init timed out");
|
|
1345
1394
|
}
|
|
@@ -1564,9 +1613,13 @@ var statusCommand = new Command4("status").description("Check NEXUS runtime heal
|
|
|
1564
1613
|
console.log("");
|
|
1565
1614
|
console.log(` ${check(health.vmRunning)} VM ${health.vmRunning ? chalk7.green("running") + chalk7.dim(` (PID ${getVmPid()})`) : chalk7.red("stopped")}`);
|
|
1566
1615
|
console.log(` ${check(health.sshReady)} SSH ${health.sshReady ? chalk7.green("connected") + chalk7.dim(` (port ${config.sshPort})`) : chalk7.red("unreachable")}`);
|
|
1567
|
-
console.log(` ${check(health.dockerReady)} Docker ${health.dockerReady ? chalk7.green("ready") : chalk7.red("not ready")}`);
|
|
1568
|
-
console.log(` ${check(health.serverHealthy)} Server ${health.serverHealthy ? chalk7.green("healthy") + chalk7.dim(` (port ${config.httpPort})`) : chalk7.red("unhealthy")}`);
|
|
1616
|
+
console.log(` ${check(health.dockerReady)} Docker ${health.dockerReady ? chalk7.green("ready") + (health.dockerVersion ? chalk7.dim(` v${health.dockerVersion}`) : "") : chalk7.red("not ready")}`);
|
|
1617
|
+
console.log(` ${check(health.serverHealthy)} Server ${health.serverHealthy ? chalk7.green("healthy") + chalk7.dim(` (port ${config.httpPort})`) + (health.serverVersion ? chalk7.dim(` v${health.serverVersion}`) : "") : chalk7.red("unhealthy")}`);
|
|
1569
1618
|
console.log(` ${check(!!health.tunnelUrl)} Tunnel ${health.tunnelUrl ? chalk7.green(health.tunnelUrl) : chalk7.dim("not active")}`);
|
|
1619
|
+
if (health.diskUsagePercent !== null) {
|
|
1620
|
+
const diskOk = health.diskUsagePercent < 85;
|
|
1621
|
+
console.log(` ${check(diskOk)} Disk ${diskOk ? chalk7.green(`${health.diskUsagePercent}% used`) : chalk7.yellow(`${health.diskUsagePercent}% used \u2014 consider cleanup`)}`);
|
|
1622
|
+
}
|
|
1570
1623
|
console.log("");
|
|
1571
1624
|
if (health.serverHealthy) {
|
|
1572
1625
|
log.success(`NEXUS CLI ready \u2014 connect via: buildwithnexus ssh`);
|
|
@@ -2398,9 +2451,18 @@ var EventStream = class {
|
|
|
2398
2451
|
lastId = "0";
|
|
2399
2452
|
onEvent;
|
|
2400
2453
|
pollInterval = null;
|
|
2454
|
+
consecutiveErrors = 0;
|
|
2455
|
+
lastError = null;
|
|
2401
2456
|
constructor(onEvent) {
|
|
2402
2457
|
this.onEvent = onEvent;
|
|
2403
2458
|
}
|
|
2459
|
+
getStatus() {
|
|
2460
|
+
return {
|
|
2461
|
+
active: this.active,
|
|
2462
|
+
consecutiveErrors: this.consecutiveErrors,
|
|
2463
|
+
lastError: this.lastError
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2404
2466
|
async start() {
|
|
2405
2467
|
this.active = true;
|
|
2406
2468
|
const config = loadConfig();
|
|
@@ -2413,13 +2475,23 @@ var EventStream = class {
|
|
|
2413
2475
|
`curl -sf -H 'Last-Event-ID: ${this.lastId}' http://localhost:4200/events?timeout=1 2>/dev/null || true`
|
|
2414
2476
|
);
|
|
2415
2477
|
if (code === 0 && stdout.trim()) {
|
|
2478
|
+
this.consecutiveErrors = 0;
|
|
2479
|
+
this.lastError = null;
|
|
2416
2480
|
const events = parseSSEData(stdout);
|
|
2417
2481
|
for (const event of events) {
|
|
2418
2482
|
if (event.id) this.lastId = event.id;
|
|
2419
2483
|
this.onEvent(event);
|
|
2420
2484
|
}
|
|
2421
2485
|
}
|
|
2422
|
-
} catch {
|
|
2486
|
+
} catch (err) {
|
|
2487
|
+
this.consecutiveErrors++;
|
|
2488
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
2489
|
+
if (this.consecutiveErrors >= 5) {
|
|
2490
|
+
this.onEvent({
|
|
2491
|
+
type: "error",
|
|
2492
|
+
content: `Event stream disconnected after ${this.consecutiveErrors} attempts: ${this.lastError}`
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2423
2495
|
}
|
|
2424
2496
|
}, 2e3);
|
|
2425
2497
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildwithnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Launch an autonomous AI runtime with triple-nested VM isolation in one command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"test": "vitest run",
|
|
20
20
|
"test:watch": "vitest",
|
|
21
21
|
"test:coverage": "vitest run --coverage",
|
|
22
|
-
"prepublishOnly": "npm run build
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
25
|
"node": ">=18"
|
|
Binary file
|