diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index b070b7e9a..536add06e 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -38,7 +38,7 @@ const { parseGatewayInference } = require("./lib/inference-config"); const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", + "start", "stop", "status", "reconnect", "debug", "uninstall", "help", "--help", "-h", "--version", "-v", ]); @@ -134,7 +134,7 @@ async function recoverNamedGatewayRuntime() { } const shouldStartGateway = [before.state, after.state].some((state) => - ["named_unhealthy", "named_unreachable", "connected_other"].includes(state) + ["named_unhealthy", "named_unreachable", "connected_other", "missing_named"].includes(state) ); if (shouldStartGateway) { @@ -557,6 +557,25 @@ function listSandboxes() { console.log(""); } +function resolveReconnectSandboxName(requestedName) { + const sandboxName = requestedName || registry.getDefault(); + if (!sandboxName) { + console.error(" No sandbox registered. Run `nemoclaw onboard` to create one first."); + process.exit(1); + } + validateName(sandboxName, "sandbox name"); + + if (requestedName) { + const existingSandbox = registry.getSandbox(sandboxName); + if (!existingSandbox) { + console.error(` Unknown sandbox '${sandboxName}'.`); + console.error(" Use `nemoclaw list` to view registered sandboxes."); + process.exit(1); + } + } + return sandboxName; +} + // ── Sandbox-scoped actions ─────────────────────────────────────── async function sandboxConnect(sandboxName) { @@ -571,6 +590,17 @@ async function sandboxConnect(sandboxName) { exitWithSpawnResult(result); } +async function reconnect(args = []) { + if (args.length > 1) { + console.error(" Too many positional arguments for `reconnect`."); + console.error(" Usage: `nemoclaw reconnect [sandbox-name]`."); + process.exit(1); + } + const sandboxName = resolveReconnectSandboxName(args[0]); + console.log(` Reconnecting to sandbox '${sandboxName}'...`); + await sandboxConnect(sandboxName); +} + // eslint-disable-next-line complexity async function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); @@ -721,6 +751,7 @@ function help() { ${G}Sandbox Management:${R} ${B}nemoclaw list${R} List all sandboxes + nemoclaw reconnect ${D}[name]${R} Recover the default or named sandbox after a reboot nemoclaw connect Shell into a running sandbox nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs @@ -778,6 +809,7 @@ const [cmd, ...args] = process.argv.slice(2); case "start": await start(); break; case "stop": stop(); break; case "status": showStatus(); break; + case "reconnect": await reconnect(args); break; case "debug": debug(args); break; case "uninstall": uninstall(args); break; case "list": listSandboxes(); break; diff --git a/test/cli.test.js b/test/cli.test.js index 7cfb06e0d..3089f143f 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -97,6 +97,322 @@ describe("CLI dispatch", () => { expect(r.out.includes("nemoclaw debug")).toBeTruthy(); }); + it("help mentions reconnect command", () => { + const r = run("help"); + expect(r.code).toBe(0); + expect(r.out.includes("nemoclaw reconnect")).toBeTruthy(); + }); + + it("reconnect uses the default sandbox when no name is provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-default-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + " echo 'Sandbox: alpha'", + " exit 0", + "fi", + "if [ \"$1\" = \"forward\" ] || [ \"$1\" = \"sandbox\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + const iGet = log.indexOf("sandbox get alpha"); + const iForward = log.indexOf("forward start --background 18789 alpha"); + const iConnect = log.indexOf("sandbox connect alpha"); + expect(iGet).toBeGreaterThanOrEqual(0); + expect(iForward).toBeGreaterThan(iGet); + expect(iConnect).toBeGreaterThan(iForward); + }); + + it("reconnect accepts an explicit sandbox name", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-named-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + beta: { + name: "beta", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"beta\" ]; then", + " echo 'Sandbox: beta'", + " exit 0", + "fi", + "if [ \"$1\" = \"forward\" ] || [ \"$1\" = \"sandbox\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'beta'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + const iGet = log.indexOf("sandbox get beta"); + const iForward = log.indexOf("forward start --background 18789 beta"); + const iConnect = log.indexOf("sandbox connect beta"); + expect(iGet).toBeGreaterThanOrEqual(0); + expect(iForward).toBeGreaterThan(iGet); + expect(iConnect).toBeGreaterThan(iForward); + }); + + it("reconnect rejects an explicit sandbox name that is not in the registry", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-unknown-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "printf '%s\\n' \"$*\"", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Unknown sandbox 'beta'")).toBeTruthy(); + expect(r.out.includes("Use `nemoclaw list` to view registered sandboxes.")).toBeTruthy(); + }); + + it("reconnect rejects more than one positional sandbox argument", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-extra-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + beta: { + name: "beta", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "printf '%s\\n' \"$*\"", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect alpha beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Too many positional arguments for `reconnect`.")).toBeTruthy(); + expect(r.out.includes("Usage: `nemoclaw reconnect [sandbox-name]`.")).toBeTruthy(); + }); + + it("reconnect starts the named gateway when it is missing", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-missing-gateway-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + const stateFile = path.join(home, "gateway-state"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync(stateFile, "missing\n"); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + `state_file=${JSON.stringify(stateFile)}`, + "state=$(tr -d '\\n' < \"$state_file\")", + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + " if [ \"$state\" = \"healthy\" ]; then", + " echo 'Sandbox: alpha'", + " exit 0", + " fi", + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + "if [ \"$1\" = \"status\" ]; then", + " if [ \"$state\" = \"healthy\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + " fi", + " echo 'Gateway Status'", + " echo", + " echo ' Status: No gateway configured.'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ] && [ \"$3\" = \"-g\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + " if [ \"$state\" = \"healthy\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + " fi", + " exit 1", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"select\" ] && [ \"$3\" = \"nemoclaw\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"start\" ] && [ \"$3\" = \"--name\" ] && [ \"$4\" = \"nemoclaw\" ]; then", + " printf 'healthy\\n' > \"$state_file\"", + " exit 0", + "fi", + "if [ \"$1\" = \"forward\" ] || [ \"$1\" = \"sandbox\" ]; then", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect alpha", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, 25000); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + const iSelect = log.indexOf("gateway select nemoclaw"); + const iStart = log.indexOf("gateway start --name nemoclaw"); + const iGet = log.lastIndexOf("sandbox get alpha"); + const iForward = log.indexOf("forward start --background 18789 alpha"); + const iConnect = log.indexOf("sandbox connect alpha"); + expect(iSelect).toBeGreaterThanOrEqual(0); + expect(iStart).toBeGreaterThan(iSelect); + expect(iGet).toBeGreaterThan(iStart); + expect(iForward).toBeGreaterThan(iGet); + expect(iConnect).toBeGreaterThan(iForward); + }, 25000); + it("passes --follow through to openshell logs", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-")); const localBin = path.join(home, "bin");