Skip to content

Commit ba3f12e

Browse files
authored
refactor(cli): extract OpenShell command helpers (#1544)
## Summary Extract the reusable OpenShell command helpers from `bin/nemoclaw.js` into a typed module under `src/lib/openshell.ts`. ## Why This is one of the small follow-through PRs from #924: shrink the monolithic dispatcher and move reusable CLI logic into typed modules that future command wrappers can share. ## Changes - add `src/lib/openshell.ts` with: - `runOpenshellCommand()` - `captureOpenshellCommand()` - `stripAnsi()` - `parseVersionFromText()` - `versionGte()` - `getInstalledOpenshellVersion()` - add focused tests in `src/lib/openshell.test.ts` - reduce `bin/nemoclaw.js` to thin wrappers around the extracted helpers ## Testing - `npm run build:cli` - `npx vitest run src/lib/openshell.test.ts test/cli.test.js` Relates to #924. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Centralized OpenShell command handling and sandbox inventory/status logic into shared utilities, yielding more consistent command execution, clearer failure/error reporting, and more reliable sandbox status and listing output. * **Tests** * Added comprehensive tests for OpenShell helpers and inventory/status commands, covering version parsing, command execution behaviors, and inventory display scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent ddfff43 commit ba3f12e

File tree

5 files changed

+511
-114
lines changed

5 files changed

+511
-114
lines changed

bin/nemoclaw.js

Lines changed: 38 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ const onboardSession = require("./lib/onboard-session");
4141
const { parseLiveSandboxNames } = require("./lib/runtime-recovery");
4242
const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice");
4343
const { runDebugCommand } = require("../dist/lib/debug-command");
44+
const {
45+
captureOpenshellCommand,
46+
getInstalledOpenshellVersion,
47+
runOpenshellCommand,
48+
stripAnsi,
49+
versionGte,
50+
} = require("../dist/lib/openshell");
51+
const { listSandboxesCommand, showStatusCommand } = require("../dist/lib/inventory-commands");
4452
const { executeDeploy } = require("../dist/lib/deploy");
4553
const { runStartCommand, runStopCommand } = require("../dist/lib/services-command");
4654
const {
@@ -86,30 +94,24 @@ function getOpenshellBinary() {
8694
}
8795

8896
function runOpenshell(args, opts = {}) {
89-
const result = spawnSync(getOpenshellBinary(), args, {
97+
return runOpenshellCommand(getOpenshellBinary(), args, {
9098
cwd: ROOT,
91-
env: { ...process.env, ...opts.env },
92-
encoding: "utf-8",
93-
stdio: opts.stdio ?? "inherit",
99+
env: opts.env,
100+
stdio: opts.stdio,
101+
ignoreError: opts.ignoreError,
102+
errorLine: console.error,
103+
exit: (code) => process.exit(code),
94104
});
95-
if (result.status !== 0 && !opts.ignoreError) {
96-
console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`);
97-
process.exit(result.status || 1);
98-
}
99-
return result;
100105
}
101106

102107
function captureOpenshell(args, opts = {}) {
103-
const result = spawnSync(getOpenshellBinary(), args, {
108+
return captureOpenshellCommand(getOpenshellBinary(), args, {
104109
cwd: ROOT,
105-
env: { ...process.env, ...opts.env },
106-
encoding: "utf-8",
107-
stdio: ["ignore", "pipe", "pipe"],
110+
env: opts.env,
111+
ignoreError: opts.ignoreError,
112+
errorLine: console.error,
113+
exit: (code) => process.exit(code),
108114
});
109-
return {
110-
status: result.status ?? 1,
111-
output: `${result.stdout || ""}${opts.ignoreError ? "" : result.stderr || ""}`.trim(),
112-
};
113115
}
114116

115117
function cleanupGatewayAfterLastSandbox() {
@@ -143,36 +145,10 @@ function getSandboxDeleteOutcome(deleteResult) {
143145
};
144146
}
145147

146-
function parseVersionFromText(value = "") {
147-
const match = String(value || "").match(/([0-9]+\.[0-9]+\.[0-9]+)/);
148-
return match ? match[1] : null;
149-
}
150-
151-
function versionGte(left = "0.0.0", right = "0.0.0") {
152-
const lhs = String(left)
153-
.split(".")
154-
.map((part) => Number.parseInt(part, 10) || 0);
155-
const rhs = String(right)
156-
.split(".")
157-
.map((part) => Number.parseInt(part, 10) || 0);
158-
const length = Math.max(lhs.length, rhs.length);
159-
for (let index = 0; index < length; index += 1) {
160-
const a = lhs[index] || 0;
161-
const b = rhs[index] || 0;
162-
if (a > b) return true;
163-
if (a < b) return false;
164-
}
165-
return true;
166-
}
167-
168-
function getInstalledOpenshellVersion() {
169-
const versionResult = captureOpenshell(["--version"], { ignoreError: true });
170-
return parseVersionFromText(versionResult.output);
171-
}
172-
173-
function stripAnsi(value = "") {
174-
// eslint-disable-next-line no-control-regex
175-
return String(value).replace(/\x1b\[[0-9;]*m/g, "");
148+
function getInstalledOpenshellVersionOrNull() {
149+
return getInstalledOpenshellVersion(getOpenshellBinary(), {
150+
cwd: ROOT,
151+
});
176152
}
177153

178154
// ── Sandbox process health (OpenClaw gateway inside the sandbox) ─────────
@@ -891,76 +867,24 @@ function uninstall(args) {
891867
}
892868

893869
function showStatus() {
894-
// Show sandbox registry
895-
const { sandboxes, defaultSandbox } = registry.listSandboxes();
896-
if (sandboxes.length > 0) {
897-
const live = parseGatewayInference(
898-
captureOpenshell(["inference", "get"], { ignoreError: true }).output,
899-
);
900-
console.log("");
901-
console.log(" Sandboxes:");
902-
for (const sb of sandboxes) {
903-
const def = sb.name === defaultSandbox ? " *" : "";
904-
const model = (live && live.model) || sb.model;
905-
console.log(` ${sb.name}${def}${model ? ` (${model})` : ""}`);
906-
}
907-
console.log("");
908-
}
909-
910-
// Show service status
911870
const { showStatus: showServiceStatus } = require("./lib/services");
912-
showServiceStatus({ sandboxName: defaultSandbox || undefined });
871+
showStatusCommand({
872+
listSandboxes: () => registry.listSandboxes(),
873+
getLiveInference: () =>
874+
parseGatewayInference(captureOpenshell(["inference", "get"], { ignoreError: true }).output),
875+
showServiceStatus,
876+
log: console.log,
877+
});
913878
}
914879

915880
async function listSandboxes() {
916-
const recovery = await recoverRegistryEntries();
917-
const { sandboxes, defaultSandbox } = recovery;
918-
if (sandboxes.length === 0) {
919-
console.log("");
920-
const session = onboardSession.loadSession();
921-
if (session?.sandboxName) {
922-
console.log(
923-
` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`,
924-
);
925-
console.log(
926-
" Retry `nemoclaw <name> connect` or `nemoclaw <name> status` once the gateway/runtime is healthy.",
927-
);
928-
} else {
929-
console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started.");
930-
}
931-
console.log("");
932-
return;
933-
}
934-
935-
// Query live gateway inference once; prefer it over stale registry values.
936-
const live = parseGatewayInference(
937-
captureOpenshell(["inference", "get"], { ignoreError: true }).output,
938-
);
939-
940-
console.log("");
941-
if (recovery.recoveredFromSession) {
942-
console.log(" Recovered sandbox inventory from the last onboard session.");
943-
console.log("");
944-
}
945-
if (recovery.recoveredFromGateway > 0) {
946-
console.log(
947-
` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`,
948-
);
949-
console.log("");
950-
}
951-
console.log(" Sandboxes:");
952-
for (const sb of sandboxes) {
953-
const def = sb.name === defaultSandbox ? " *" : "";
954-
const model = (live && live.model) || sb.model || "unknown";
955-
const provider = (live && live.provider) || sb.provider || "unknown";
956-
const gpu = sb.gpuEnabled ? "GPU" : "CPU";
957-
const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none";
958-
console.log(` ${sb.name}${def}`);
959-
console.log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`);
960-
}
961-
console.log("");
962-
console.log(" * = default sandbox");
963-
console.log("");
881+
await listSandboxesCommand({
882+
recoverRegistryEntries: () => recoverRegistryEntries(),
883+
getLiveInference: () =>
884+
parseGatewayInference(captureOpenshell(["inference", "get"], { ignoreError: true }).output),
885+
loadLastSession: () => onboardSession.loadSession(),
886+
log: console.log,
887+
});
964888
}
965889

966890
// ── Sandbox-scoped actions ───────────────────────────────────────
@@ -1099,7 +1023,7 @@ async function sandboxStatus(sandboxName) {
10991023
}
11001024

11011025
function sandboxLogs(sandboxName, follow) {
1102-
const installedVersion = getInstalledOpenshellVersion();
1026+
const installedVersion = getInstalledOpenshellVersionOrNull();
11031027
if (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) {
11041028
printOldLogsCompatibilityGuidance(installedVersion);
11051029
process.exit(1);

src/lib/inventory-commands.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it, vi } from "vitest";
5+
6+
import { listSandboxesCommand, showStatusCommand } from "./inventory-commands";
7+
8+
describe("inventory commands", () => {
9+
it("prints the empty-state onboarding hint when no sandboxes exist", async () => {
10+
const lines: string[] = [];
11+
await listSandboxesCommand({
12+
recoverRegistryEntries: async () => ({ sandboxes: [], defaultSandbox: null }),
13+
getLiveInference: () => null,
14+
loadLastSession: () => ({ sandboxName: "alpha" }),
15+
log: (message = "") => lines.push(message),
16+
});
17+
18+
expect(lines).toContain(
19+
" No sandboxes registered locally, but the last onboarded sandbox was 'alpha'.",
20+
);
21+
});
22+
23+
it("prints recovered sandbox inventory details", async () => {
24+
const lines: string[] = [];
25+
await listSandboxesCommand({
26+
recoverRegistryEntries: async () => ({
27+
sandboxes: [
28+
{
29+
name: "alpha",
30+
model: "nvidia/nemotron-3-super-120b-a12b",
31+
provider: "nvidia-prod",
32+
gpuEnabled: true,
33+
policies: ["pypi"],
34+
},
35+
],
36+
defaultSandbox: "alpha",
37+
recoveredFromSession: true,
38+
recoveredFromGateway: 1,
39+
}),
40+
getLiveInference: () => null,
41+
loadLastSession: () => null,
42+
log: (message = "") => lines.push(message),
43+
});
44+
45+
expect(lines).toContain(" Recovered sandbox inventory from the last onboard session.");
46+
expect(lines).toContain(" Recovered 1 sandbox entry from the live OpenShell gateway.");
47+
expect(lines).toContain(" alpha *");
48+
expect(lines).toContain(
49+
" model: nvidia/nemotron-3-super-120b-a12b provider: nvidia-prod GPU policies: pypi",
50+
);
51+
});
52+
53+
it("uses live inference only for the default sandbox in list output", async () => {
54+
const lines: string[] = [];
55+
await listSandboxesCommand({
56+
recoverRegistryEntries: async () => ({
57+
sandboxes: [
58+
{
59+
name: "alpha",
60+
model: "configured-alpha",
61+
provider: "configured-provider",
62+
gpuEnabled: true,
63+
policies: [],
64+
},
65+
{
66+
name: "beta",
67+
model: "configured-beta",
68+
provider: "beta-provider",
69+
gpuEnabled: false,
70+
policies: [],
71+
},
72+
],
73+
defaultSandbox: "alpha",
74+
}),
75+
getLiveInference: () => ({ provider: "live-provider", model: "live-model" }),
76+
loadLastSession: () => null,
77+
log: (message = "") => lines.push(message),
78+
});
79+
80+
expect(lines).toContain(
81+
" model: live-model provider: live-provider GPU policies: none",
82+
);
83+
expect(lines).toContain(
84+
" model: configured-beta provider: beta-provider CPU policies: none",
85+
);
86+
});
87+
88+
it("prints the top-level status inventory and delegates service status", () => {
89+
const lines: string[] = [];
90+
const showServiceStatus = vi.fn();
91+
showStatusCommand({
92+
listSandboxes: () => ({
93+
sandboxes: [
94+
{
95+
name: "alpha",
96+
model: "nvidia/nemotron-3-super-120b-a12b",
97+
},
98+
{
99+
name: "beta",
100+
model: "z-ai/glm5",
101+
},
102+
],
103+
defaultSandbox: "alpha",
104+
}),
105+
getLiveInference: () => ({ provider: "nvidia-prod", model: "moonshotai/kimi-k2.5" }),
106+
showServiceStatus,
107+
log: (message = "") => lines.push(message),
108+
});
109+
110+
expect(lines).toContain(" Sandboxes:");
111+
expect(lines).toContain(" alpha * (moonshotai/kimi-k2.5)");
112+
expect(lines).toContain(" beta (z-ai/glm5)");
113+
expect(showServiceStatus).toHaveBeenCalledWith({ sandboxName: "alpha" });
114+
});
115+
});

0 commit comments

Comments
 (0)