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 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) && !claimed.has(port)) {
447
- claimed.add(port);
448
- continue;
449
- }
450
- let altPort = null;
451
- for (let offset = 1; offset <= 50; offset++) {
452
- const candidate = port + offset;
453
- if (!claimed.has(candidate) && await isPortFree(candidate)) {
454
- altPort = candidate;
455
- break;
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
- resolved[key] = altPort;
460
- claimed.add(altPort);
461
- const blocker = await getPortBlocker(port);
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
- if (action === "kill" && blocker) {
477
- try {
478
- process.kill(blocker.pid, "SIGTERM");
479
- await new Promise((r) => setTimeout(r, 1e3));
480
- if (!await isPortFree(port)) {
481
- process.kill(blocker.pid, "SIGKILL");
482
- await new Promise((r) => setTimeout(r, 500));
483
- }
484
- claimed.add(port);
485
- console.log(chalk5.green(` \u2713 Killed ${desc}, port ${port} is now free`));
486
- } catch {
487
- console.log(chalk5.red(` \u2717 Failed to kill PID ${blocker.pid}. Try: sudo kill ${blocker.pid}`));
488
- process.exit(1);
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
- } else {
491
- console.log(chalk5.dim(" Init aborted."));
492
- process.exit(0);
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 = 6e5) {
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
- await new Promise((r) => setTimeout(r, 5e3));
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 = 36e5) {
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
- await new Promise((r) => setTimeout(r, 5e3));
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 = 72e5) {
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
- await new Promise((r) => setTimeout(r, 2e4));
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 2 hours");
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.7",
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 && npm run bundle"
22
+ "prepublishOnly": "npm run build"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=18"
Binary file