Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);

Expand Down Expand Up @@ -566,6 +566,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) {
Expand All @@ -578,6 +597,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);
Expand Down Expand Up @@ -728,6 +758,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 <name> connect Shell into a running sandbox
nemoclaw <name> status Sandbox health + NIM status
nemoclaw <name> logs ${D}[--follow]${R} Stream sandbox logs
Expand Down Expand Up @@ -785,6 +816,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;
Expand Down
316 changes: 316 additions & 0 deletions test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,322 @@
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);

Check failure on line 166 in test/cli.test.js

View workflow job for this annotation

GitHub Actions / test-unit

[cli] test/cli.test.js > CLI dispatch > reconnect uses the default sandbox when no name is provided

AssertionError: expected -1 to be greater than 0 ❯ test/cli.test.js:166:22

Check failure on line 166 in test/cli.test.js

View workflow job for this annotation

GitHub Actions / lint

[cli] test/cli.test.js > CLI dispatch > reconnect uses the default sandbox when no name is provided

AssertionError: expected -1 to be greater than 0 ❯ test/cli.test.js:166:22
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);

Check failure on line 230 in test/cli.test.js

View workflow job for this annotation

GitHub Actions / test-unit

[cli] test/cli.test.js > CLI dispatch > reconnect accepts an explicit sandbox name

AssertionError: expected -1 to be greater than 0 ❯ test/cli.test.js:230:22

Check failure on line 230 in test/cli.test.js

View workflow job for this annotation

GitHub Actions / lint

[cli] test/cli.test.js > CLI dispatch > reconnect accepts an explicit sandbox name

AssertionError: expected -1 to be greater than 0 ❯ test/cli.test.js:230:22
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);

Check failure on line 419 in test/cli.test.js

View workflow job for this annotation

GitHub Actions / test-unit

[cli] test/cli.test.js > CLI dispatch > reconnect starts the named gateway when it is missing

AssertionError: expected -1 to be greater than 309 ❯ test/cli.test.js:419:22

Check failure on line 419 in test/cli.test.js

View workflow job for this annotation

GitHub Actions / lint

[cli] test/cli.test.js > CLI dispatch > reconnect starts the named gateway when it is missing

AssertionError: expected -1 to be greater than 309 ❯ test/cli.test.js:419:22
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");
Expand Down
Loading