refactor(onboard): finish oclif flag migration (#5916)

<!-- markdownlint-disable MD041 -->
## Summary

Finish the onboarding oclif migration by passing parsed flags through a
typed command boundary instead of reconstructing argv and parsing it
again. This removes the legacy parser and alias-specific action plumbing
while preserving domain validation and the public sandbox-first
dispatcher.

## Changes

- replace the `oclif flags -> string[] -> legacy parser` round trip with
typed option resolution
- move GPU flag exclusivity and device dependencies into oclif metadata
while retaining file, agent-alias, manifest, and notice validation in
the onboarding boundary
- route `setup` and `setup-spark` through the shared onboard action and
oclif's native, alias-aware deprecation support
- delete the manual parser, manual usage renderer, and deprecated alias
action facade, for 804 net lines removed
- replace parser-internal tests with typed resolver, command-boundary,
integration, package-contract, and alias-branding coverage
- cap every function in the affected production cluster at cognitive
complexity 10

## Type of Change

- [x] Code change (feature, bug fix, or refactor)
- [ ] Code change with doc updates
- [ ] Doc only (prose changes, no code sample modifications)
- [ ] Doc only (includes code sample changes)

## Quality Gates
<!-- Check all that apply. For any "covered by existing tests", "not
applicable", or waiver entry, add a brief justification on the same line
or in the Changes section. -->
- [x] Tests added or updated for changed behavior
- [x] Existing tests cover changed behavior — justification: the full
CLI integration suite continues to cover onboarding, deprecated aliases,
custom Dockerfiles, resume behavior, and agent-specific launchers
- [ ] Tests not applicable — justification:
- [ ] Docs updated for user-facing behavior changes
- [x] Docs not applicable — justification: public commands, flags, and
documented semantics are unchanged; existing docs already state that
aliases share onboard flags and GPU device selection requires
`--sandbox-gpu`
- [x] Sensitive paths changed (security, policy, credentials, preflight,
onboarding, inference, runner, sandbox, or messaging)
- [x] Sensitive-path review completed or maintainer-approved waiver
recorded — reviewer/approval link/justification: reviewed the typed
mapping against every legacy parser branch; targeted, coverage-ratchet,
and full-matrix verification passed
- [ ] Non-success, skipped, or missing CI check accepted by maintainer —
check name, approval link, and follow-up issue:

## Verification
<!-- Check each item you ran and confirmed. Leave unchecked items you
skipped. Doc-only changes do not require npm test unless you ran it. -->
- [x] PR description includes the DCO sign-off declaration and every
commit appears as `Verified` in GitHub
- [x] Git hooks passed during commit and push, or `npx prek run
--from-ref main --to-ref HEAD` passes
- [x] Targeted tests pass for changed behavior
- [x] Full `npm test` passes (broad runtime changes only)
- [x] Quality Gates section completed with required justifications or
waivers
- [x] No secrets, API keys, or credentials committed
- [ ] `npm run docs` builds without warnings (doc changes only)
- [ ] Doc pages follow the [style
guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md)
(doc changes only)
- [ ] New doc pages include SPDX header and frontmatter (new pages only)

---
<!-- DCO sign-off is required in this PR description, and every commit
must appear as Verified in GitHub. Run: git config user.name && git
config user.email -->
Signed-off-by: Carlos Villela <cvillela@nvidia.com>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Onboarding now uses a unified, typed flag flow across commands,
improving handling for GPU options, agent selection, and
`--from`/`--agents` inputs.
* **Bug Fixes**
* Strengthened validation for incompatible GPU and sandbox GPU device
combinations, improved errors for missing/invalid agent manifests and
invalid Dockerfile `--from` paths, and tightened `--control-ui-port`
checks.
* **Chores**
* Updated deprecated-alias messaging and help for `setup` and
`setup-spark` to consistently direct to `onboard`.
* **Tests**
* Updated/expanded onboarding and compatibility test coverage to match
the new typed-flag behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Carlos Villela 2026-06-27 18:25:54 -07:00 committed by GitHub
parent 30261d5d1b
commit 047cc89e8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 583 additions and 1387 deletions

View file

@ -182,6 +182,28 @@
}
}
},
{
"includes": [
"src/commands/onboard.ts",
"src/commands/setup.ts",
"src/commands/setup-spark.ts",
"src/lib/actions/onboard.ts",
"src/lib/onboard/command.ts",
"src/lib/onboard/command-support.ts"
],
"linter": {
"rules": {
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": {
"maxAllowedComplexity": 10
}
}
}
}
}
},
{
"includes": ["nemoclaw/src/**/*.ts"],
"linter": {

View file

@ -14,8 +14,6 @@ const mocks = vi.hoisted(() => ({
runInferenceGet: vi.fn(),
runInferenceSet: vi.fn(),
runOnboardAction: vi.fn(),
runSetupAction: vi.fn(),
runSetupSparkAction: vi.fn(),
runUpgradeSandboxesAction: vi.fn(),
showStatusCommand: vi.fn(),
}));
@ -39,8 +37,6 @@ vi.mock("../lib/actions/global", () => ({
runBackupAllAction: mocks.runBackupAllAction,
runGarbageCollectImagesAction: mocks.runGarbageCollectImagesAction,
runOnboardAction: mocks.runOnboardAction,
runSetupAction: mocks.runSetupAction,
runSetupSparkAction: mocks.runSetupSparkAction,
runUpgradeSandboxesAction: mocks.runUpgradeSandboxesAction,
}));
@ -70,16 +66,16 @@ vi.mock("../lib/actions/inference-get", () => ({
import { InferenceGetError } from "../lib/actions/inference-get";
import { InferenceSetError } from "../lib/actions/inference-set";
import BackupAllCommand from "./backup-all";
import GarbageCollectImagesCommand from "./gc";
import InferenceGetCommand from "./inference/get";
import InferenceSetCommand from "./inference/set";
import ListCommand from "./list";
import BackupAllCommand from "./backup-all";
import GarbageCollectImagesCommand from "./gc";
import UpgradeSandboxesCommand from "./upgrade-sandboxes";
import OnboardCliCommand from "./onboard";
import SetupCliCommand from "./setup";
import SetupSparkCliCommand from "./setup-spark";
import StatusCommand from "./status";
import UpgradeSandboxesCommand from "./upgrade-sandboxes";
const rootDir = process.cwd();
@ -211,14 +207,20 @@ describe("global oclif command adapters", () => {
});
});
it("maps onboard-family flags into the compatibility action arguments", async () => {
it("maps onboard-family flags directly into the shared typed action", async () => {
await OnboardCliCommand.run(["--name", "alpha", "--resume"], rootDir);
await SetupCliCommand.run(["--fresh"], rootDir);
await SetupCliCommand.run(["--name", "alpha", "--resume"], rootDir);
await SetupSparkCliCommand.run(["--control-ui-port", "18080"], rootDir);
expect(mocks.runOnboardAction).toHaveBeenCalledWith(["--resume", "--name", "alpha"]);
expect(mocks.runSetupAction).toHaveBeenCalledWith(["--fresh"]);
expect(mocks.runSetupSparkAction).toHaveBeenCalledWith(["--control-ui-port", "18080"]);
expect(mocks.runOnboardAction).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: "alpha", resume: true }),
);
expect(mocks.runOnboardAction.mock.calls[1]).toEqual(mocks.runOnboardAction.mock.calls[0]);
expect(mocks.runOnboardAction).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ "control-ui-port": 18080 }),
);
});
it("maps inference set flags into the inference action", async () => {

View file

@ -8,8 +8,6 @@ import OnboardCliCommand from "./onboard";
vi.mock("../lib/actions/global", () => ({
runOnboardAction: vi.fn().mockResolvedValue(undefined),
runSetupAction: vi.fn().mockResolvedValue(undefined),
runSetupSparkAction: vi.fn().mockResolvedValue(undefined),
}));
const rootDir = process.cwd();
@ -27,50 +25,68 @@ describe("onboard oclif command", () => {
expect(runOnboardAction).not.toHaveBeenCalled();
});
it("accepts --yes and forwards it to the legacy onboard action", async () => {
it("accepts --yes and forwards typed flags to the onboard action", async () => {
await OnboardCliCommand.run(
["--non-interactive", "--yes", "--yes-i-accept-third-party-software"],
rootDir,
);
expect(runOnboardAction).toHaveBeenCalledWith([
"--non-interactive",
"--yes",
"--yes-i-accept-third-party-software",
]);
expect(runOnboardAction).toHaveBeenCalledWith(
expect.objectContaining({
"non-interactive": true,
yes: true,
"yes-i-accept-third-party-software": true,
}),
);
});
it("accepts -y as the short form for --yes", async () => {
await OnboardCliCommand.run(["--non-interactive", "-y"], rootDir);
expect(runOnboardAction).toHaveBeenCalledWith(["--non-interactive", "--yes"]);
expect(runOnboardAction).toHaveBeenCalledWith(
expect.objectContaining({ "non-interactive": true, yes: true }),
);
});
it("forwards sandbox GPU flags to legacy onboard parsing", async () => {
it("forwards typed sandbox GPU flags", async () => {
await OnboardCliCommand.run(
["--non-interactive", "--yes", "--sandbox-gpu", "--sandbox-gpu-device", "nvidia.com/gpu=0"],
rootDir,
);
expect(runOnboardAction).toHaveBeenCalledWith([
"--non-interactive",
"--sandbox-gpu",
"--sandbox-gpu-device",
"nvidia.com/gpu=0",
"--yes",
]);
expect(runOnboardAction).toHaveBeenCalledWith(
expect.objectContaining({
"non-interactive": true,
"sandbox-gpu": true,
"sandbox-gpu-device": "nvidia.com/gpu=0",
yes: true,
}),
);
});
it("forwards --no-gpu to the legacy onboard action", async () => {
it("forwards --no-gpu to the onboard action", async () => {
await OnboardCliCommand.run(["--non-interactive", "--no-gpu"], rootDir);
expect(runOnboardAction).toHaveBeenCalledWith(["--non-interactive", "--no-gpu"]);
expect(runOnboardAction).toHaveBeenCalledWith(
expect.objectContaining({ "non-interactive": true, "no-gpu": true }),
);
});
it("rejects mutually exclusive gpu and no-gpu flags before dispatch", async () => {
await expect(OnboardCliCommand.run(["--gpu", "--no-gpu"], rootDir)).rejects.toThrow(
/gpu|no-gpu/,
);
it.each([
["--gpu", "--no-gpu"],
["--sandbox-gpu", "--no-sandbox-gpu"],
["--gpu", "--no-sandbox-gpu"],
["--no-gpu", "--sandbox-gpu"],
])("rejects incompatible GPU flags %s and %s before dispatch", async (left, right) => {
await expect(OnboardCliCommand.run([left, right], rootDir)).rejects.toThrow(/gpu/i);
expect(runOnboardAction).not.toHaveBeenCalled();
});
it("rejects a sandbox GPU device without explicit sandbox GPU mode", async () => {
await expect(
OnboardCliCommand.run(["--sandbox-gpu-device", "nvidia.com/gpu=0"], rootDir),
).rejects.toThrow(/sandbox-gpu/);
expect(runOnboardAction).not.toHaveBeenCalled();
});

View file

@ -8,7 +8,6 @@ import {
type OnboardFlags,
onboardExamples,
onboardUsage,
toLegacyOnboardArgs,
} from "../lib/onboard/command-support";
export default class OnboardCliCommand extends NemoClawCommand {
@ -22,6 +21,6 @@ export default class OnboardCliCommand extends NemoClawCommand {
public async run(): Promise<void> {
const { flags } = await this.parse(OnboardCliCommand);
await runOnboardAction(toLegacyOnboardArgs(flags as OnboardFlags));
await runOnboardAction(flags as OnboardFlags);
}
}

View file

@ -1,30 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { runOnboardAction } from "../lib/actions/global";
import { CLI_NAME } from "../lib/cli/branding";
import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command";
import { runSetupSparkAction } from "../lib/actions/global";
import {
buildOnboardFlags,
type OnboardFlags,
toLegacyOnboardArgs,
} from "../lib/onboard/command-support";
import { buildOnboardFlags, type OnboardFlags } from "../lib/onboard/command-support";
export default class SetupSparkCliCommand extends NemoClawCommand {
static id = "setup-spark";
static strict = true;
static summary = "Deprecated alias for nemoclaw onboard";
static summary = "Deprecated alias for onboard";
static description = "Deprecated alias for onboard.";
static usage = ["setup-spark [flags]"];
static examples = ["<%= config.bin %> setup-spark --name alpha"];
static state = "deprecated" as const;
static deprecationOptions = {
message: `Deprecated: '${CLI_NAME} setup-spark' is now '${CLI_NAME} onboard'; current OpenShell releases handle the old DGX Spark cgroup issue. See '${CLI_NAME} help'.`,
};
static flags = buildOnboardFlags();
public async run(): Promise<void> {
if (this.argv.includes("--help") || this.argv.includes("-h")) {
await runSetupSparkAction(["--help"]);
return;
}
const { flags } = await this.parse(SetupSparkCliCommand);
await runSetupSparkAction(toLegacyOnboardArgs(flags as OnboardFlags));
await runOnboardAction(flags as OnboardFlags);
}
}

View file

@ -1,30 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { runOnboardAction } from "../lib/actions/global";
import { CLI_NAME } from "../lib/cli/branding";
import { NemoClawCommand } from "../lib/cli/nemoclaw-oclif-command";
import { runSetupAction } from "../lib/actions/global";
import {
buildOnboardFlags,
type OnboardFlags,
toLegacyOnboardArgs,
} from "../lib/onboard/command-support";
import { buildOnboardFlags, type OnboardFlags } from "../lib/onboard/command-support";
export default class SetupCliCommand extends NemoClawCommand {
static id = "setup";
static strict = true;
static summary = "Deprecated alias for nemoclaw onboard";
static summary = "Deprecated alias for onboard";
static description = "Deprecated alias for onboard.";
static usage = ["setup [flags]"];
static examples = ["<%= config.bin %> setup --name alpha"];
static state = "deprecated" as const;
static deprecationOptions = {
message: `Deprecated: '${CLI_NAME} setup' is now '${CLI_NAME} onboard'. See '${CLI_NAME} help'.`,
};
static flags = buildOnboardFlags();
public async run(): Promise<void> {
if (this.argv.includes("--help") || this.argv.includes("-h")) {
await runSetupAction(["--help"]);
return;
}
const { flags } = await this.parse(SetupCliCommand);
await runSetupAction(toLegacyOnboardArgs(flags as OnboardFlags));
await runOnboardAction(flags as OnboardFlags);
}
}

View file

@ -11,8 +11,6 @@ const mocks = vi.hoisted(() => ({
runDeployAction: vi.fn().mockResolvedValue(undefined),
runOnboardAction: vi.fn().mockResolvedValue(undefined),
runOpenshell: vi.fn(() => ({ status: 0 })),
runSetupAction: vi.fn().mockResolvedValue(undefined),
runSetupSparkAction: vi.fn().mockResolvedValue(undefined),
version: vi.fn(),
}));
@ -26,8 +24,6 @@ vi.mock("./maintenance", () => ({
}));
vi.mock("./onboard", () => ({
runOnboardAction: mocks.runOnboardAction,
runSetupAction: mocks.runSetupAction,
runSetupSparkAction: mocks.runSetupSparkAction,
}));
vi.mock("../adapters/openshell/runtime", () => ({ runOpenshell: mocks.runOpenshell }));
vi.mock("./root-help", () => ({ help: mocks.help, version: mocks.version }));
@ -39,8 +35,6 @@ import {
runGarbageCollectImagesAction,
runOnboardAction,
runOpenshellProviderCommand,
runSetupAction,
runSetupSparkAction,
runUpgradeSandboxesAction,
setGlobalCliActionRuntimeHooksForTest,
showRootHelp,
@ -54,18 +48,14 @@ describe("global cli action facade", () => {
});
it("forwards onboarding, deploy, maintenance, and help actions", async () => {
await runOnboardAction(["--resume"]);
await runSetupAction(["--fresh"]);
await runSetupSparkAction(["--name", "alpha"]);
await runOnboardAction({ resume: true });
await runDeployAction("gpu-alpha");
await runBackupAllAction();
await runGarbageCollectImagesAction({ dryRun: true });
showRootHelp();
showVersion();
expect(mocks.runOnboardAction).toHaveBeenCalledWith(["--resume"]);
expect(mocks.runSetupAction).toHaveBeenCalledWith(["--fresh"]);
expect(mocks.runSetupSparkAction).toHaveBeenCalledWith(["--name", "alpha"]);
expect(mocks.runOnboardAction).toHaveBeenCalledWith({ resume: true });
expect(mocks.runDeployAction).toHaveBeenCalledWith("gpu-alpha");
expect(mocks.backupAll).toHaveBeenCalledWith();
expect(mocks.garbageCollectImages).toHaveBeenCalledWith({ dryRun: true });

View file

@ -7,16 +7,13 @@ import {
type UpgradeSandboxesOptions,
} from "../domain/lifecycle/options";
import { recoverNamedGatewayRuntime as recoverNamedGatewayRuntimeAction } from "../gateway-runtime-action";
import type { OnboardFlags } from "../onboard/command-support";
import { runDeployAction as executeDeployAction } from "./deploy";
import {
backupAll as executeBackupAllAction,
garbageCollectImages as executeGarbageCollectImagesAction,
} from "./maintenance";
import {
runOnboardAction as executeOnboardAction,
runSetupAction as executeSetupAction,
runSetupSparkAction as executeSetupSparkAction,
} from "./onboard";
import { runOnboardAction as executeOnboardAction } from "./onboard";
import { help, version } from "./root-help";
type GatewayRecovery = { recovered: boolean };
@ -33,16 +30,8 @@ export function setGlobalCliActionRuntimeHooksForTest(hooks: GlobalCliActionRunt
runtimeHooks = hooks;
}
export async function runOnboardAction(args: string[] = []): Promise<void> {
await executeOnboardAction(args);
}
export async function runSetupAction(args: string[] = []): Promise<void> {
await executeSetupAction(args);
}
export async function runSetupSparkAction(args: string[] = []): Promise<void> {
await executeSetupSparkAction(args);
export async function runOnboardAction(flags: OnboardFlags): Promise<void> {
await executeOnboardAction(flags);
}
export async function runDeployAction(instanceName?: string): Promise<void> {

View file

@ -2,18 +2,16 @@
// SPDX-License-Identifier: Apache-2.0
import { listAgents } from "../agent/defs";
import { runDeprecatedOnboardAliasCommand, runOnboardCommand } from "../onboard/legacy-command";
import { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } from "../onboard/usage-notice";
import { runOnboardCommand } from "../onboard/command";
import type { OnboardFlags } from "../onboard/command-support";
const { onboard: runOnboard } = require("../onboard") as {
onboard: (options?: unknown) => Promise<void>;
};
function buildOnboardCommandDeps(args: string[]) {
function buildOnboardCommandDeps(flags: OnboardFlags) {
return {
args,
noticeAcceptFlag: NOTICE_ACCEPT_FLAG,
noticeAcceptEnv: NOTICE_ACCEPT_ENV,
flags,
env: process.env,
runOnboard,
listAgents,
@ -23,20 +21,6 @@ function buildOnboardCommandDeps(args: string[]) {
};
}
export async function runOnboardAction(args: string[]): Promise<void> {
await runOnboardCommand(buildOnboardCommandDeps(args));
}
export async function runSetupAction(args: string[] = []): Promise<void> {
await runDeprecatedOnboardAliasCommand({
...buildOnboardCommandDeps(args),
kind: "setup",
});
}
export async function runSetupSparkAction(args: string[] = []): Promise<void> {
await runDeprecatedOnboardAliasCommand({
...buildOnboardCommandDeps(args),
kind: "setup-spark",
});
export async function runOnboardAction(flags: OnboardFlags): Promise<void> {
await runOnboardCommand(buildOnboardCommandDeps(flags));
}

View file

@ -12,7 +12,7 @@ types.ts
providers.ts
preflight.ts
usage-notice.ts
legacy-command.ts
command.ts
```
Related modules may live outside this folder when their ownership is clearer:

View file

@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveOnboardOptions, runOnboardCommand } from "./command";
function exitWithCode(code: number): never {
throw new Error(`exit:${code}`);
}
describe("onboard --agents", () => {
it("resolves an existing manifest to an absolute path", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(manifestPath, "agents: []\n");
const relativeManifestPath = path.relative(process.cwd(), manifestPath);
const result = resolveOnboardOptions(
{ agents: relativeManifestPath },
{ env: {}, exit: exitWithCode },
);
expect(result.agentsManifest).toBe(path.resolve(relativeManifestPath));
});
it("rejects a missing manifest", () => {
const errors: string[] = [];
expect(() =>
resolveOnboardOptions(
{ agents: "/nonexistent/agents.yaml" },
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--agents path not found");
});
it("rejects manifests for non-OpenClaw runtimes before mutating the environment", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-hermes-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(manifestPath, "agents: []\n");
const errors: string[] = [];
vi.stubEnv("NEMOCLAW_EXTRA_AGENTS_JSON", "unchanged");
try {
await expect(
runOnboardCommand({
flags: { agent: "hermes", agents: manifestPath },
env: {},
listAgents: () => ["openclaw", "hermes"],
error: (message = "") => errors.push(message),
exit: exitWithCode,
runOnboard: vi.fn(),
}),
).rejects.toThrow("exit:1");
expect(errors.join("\n")).toContain("--agents is OpenClaw-specific");
expect(process.env.NEMOCLAW_EXTRA_AGENTS_JSON).toBe("unchanged");
} finally {
vi.unstubAllEnvs();
}
});
it("applies the manifest environment before invoking onboard", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-env-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(
manifestPath,
["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"),
);
const previous = process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
const restoreEnvironment =
previous === undefined
? () => delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON
: () => {
process.env.NEMOCLAW_EXTRA_AGENTS_JSON = previous;
};
let observedRaw: string | undefined;
const runOnboard = vi.fn(async () => {
observedRaw = process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
});
try {
await runOnboardCommand({
flags: { agents: manifestPath },
env: {},
runOnboard,
exit: exitWithCode,
});
expect(observedRaw).toBeDefined();
const payload = JSON.parse(observedRaw as string);
expect(payload.agents).toHaveLength(1);
expect(payload.agents[0]).toMatchObject({
id: "alpha",
workspace: "/sandbox/.openclaw/workspace-alpha",
agentDir: "/sandbox/.openclaw/agents/alpha",
});
} finally {
restoreEnvironment();
}
});
});

View file

@ -4,9 +4,7 @@
import { Flags } from "@oclif/core";
import { describeAgentFlag } from "./agent-flag-help";
import { NOTICE_ACCEPT_FLAG } from "./usage-notice";
const acceptFlagName = NOTICE_ACCEPT_FLAG.replace(/^--/, "");
import { NOTICE_ACCEPT_FLAG, NOTICE_ACCEPT_FLAG_NAME } from "./usage-notice";
type AgentRegistryReader = () => readonly string[];
@ -79,7 +77,7 @@ export type OnboardFlags = {
"control-ui-port"?: number;
yes?: boolean;
"no-ollama-autostart"?: boolean;
[acceptFlagName]?: boolean;
[NOTICE_ACCEPT_FLAG_NAME]?: boolean;
};
export function buildOnboardFlags(): Record<string, any> {
@ -96,24 +94,27 @@ export function buildOnboardFlags(): Record<string, any> {
"recreate-sandbox": Flags.boolean({ description: "Delete and recreate an existing sandbox" }),
gpu: Flags.boolean({
description: "Require OpenShell GPU passthrough for the gateway and sandbox",
exclusive: ["no-gpu"],
exclusive: ["no-gpu", "no-sandbox-gpu"],
}),
"no-gpu": Flags.boolean({
description: "Disable GPU passthrough even when an NVIDIA GPU is detected",
exclusive: ["gpu"],
exclusive: ["gpu", "sandbox-gpu"],
}),
from: Flags.string({ description: "Path to a Dockerfile to use as the sandbox image source" }),
name: Flags.string({ description: "Sandbox name" }),
"sandbox-gpu": Flags.boolean({
description: "Enable direct NVIDIA GPU access inside the sandbox",
exclusive: ["no-gpu", "no-sandbox-gpu"],
}),
"no-sandbox-gpu": Flags.boolean({
description:
"Force CPU sandbox behavior (equivalent to NEMOCLAW_SANDBOX_GPU=0; alternative to --no-gpu when Docker Desktop WSL CDI injection fails)",
exclusive: ["gpu", "sandbox-gpu"],
}),
"sandbox-gpu-device": Flags.string({
description:
"OpenShell GPU device selector to pass to sandbox create; requires --sandbox-gpu",
dependsOn: ["sandbox-gpu"],
}),
agent: Flags.string({ description: agentFlagDescription() }),
agents: Flags.string({
@ -133,32 +134,8 @@ export function buildOnboardFlags(): Record<string, any> {
description:
"Skip the wizard's eager Ollama auto-start during inference-provider selection so onboard surfaces the unreachable-Ollama warning and the default fallback model; later setup steps still expect a reachable Ollama, and on Linux/systemd hosts the loopback-override path may still restart the daemon",
}),
[acceptFlagName]: Flags.boolean({ description: "Accept the third-party software notice" }),
[NOTICE_ACCEPT_FLAG_NAME]: Flags.boolean({
description: "Accept the third-party software notice",
}),
} as Record<string, any>;
}
export function toLegacyOnboardArgs(flags: OnboardFlags): string[] {
const args: string[] = [];
if (flags["non-interactive"]) args.push("--non-interactive");
if (flags.resume) args.push("--resume");
if (flags.fresh) args.push("--fresh");
if (flags["recreate-sandbox"]) args.push("--recreate-sandbox");
if (flags.gpu) args.push("--gpu");
if (flags["no-gpu"]) args.push("--no-gpu");
if (flags.from !== undefined) args.push("--from", flags.from);
if (flags.name !== undefined) args.push("--name", flags.name);
if (flags["sandbox-gpu"]) args.push("--sandbox-gpu");
if (flags["no-sandbox-gpu"]) args.push("--no-sandbox-gpu");
if (flags["sandbox-gpu-device"] !== undefined) {
args.push("--sandbox-gpu-device", flags["sandbox-gpu-device"]);
}
if (flags.agent !== undefined) args.push("--agent", flags.agent);
if (flags.agents !== undefined) args.push("--agents", flags.agents);
if (flags["control-ui-port"] !== undefined) {
args.push("--control-ui-port", String(flags["control-ui-port"]));
}
if (flags.yes) args.push("--yes");
if (flags["no-ollama-autostart"]) args.push("--no-ollama-autostart");
if (flags[acceptFlagName]) args.push(NOTICE_ACCEPT_FLAG);
return args;
}

View file

@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveOnboardOptions, runOnboardCommand } from "./command";
import type { OnboardFlags } from "./command-support";
function exitWithCode(code: number): never {
throw new Error(`exit:${code}`);
}
function resolve(
flags: OnboardFlags,
overrides: Partial<Parameters<typeof resolveOnboardOptions>[1]> = {},
) {
return resolveOnboardOptions(flags, {
env: {},
error: () => {},
exit: exitWithCode,
...overrides,
});
}
describe("onboard command options", () => {
it("maps typed oclif flags to onboarding options", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-options-"));
const dockerfilePath = path.join(tmpDir, "Custom.Dockerfile");
fs.writeFileSync(dockerfilePath, "FROM scratch\n");
expect(
resolve(
{
"non-interactive": true,
resume: true,
"recreate-sandbox": true,
from: dockerfilePath,
name: "second-assistant",
"sandbox-gpu": true,
"sandbox-gpu-device": "nvidia.com/gpu=0",
agent: "dcode",
"control-ui-port": 18790,
gpu: true,
yes: true,
"no-ollama-autostart": true,
"yes-i-accept-third-party-software": true,
},
{ listAgents: () => ["openclaw", "hermes", "langchain-deepagents-code"] },
),
).toEqual({
nonInteractive: true,
resume: true,
fresh: false,
recreateSandbox: true,
fromDockerfile: dockerfilePath,
sandboxName: "second-assistant",
sandboxGpu: "enable",
sandboxGpuDevice: "nvidia.com/gpu=0",
acceptThirdPartySoftware: true,
agent: "langchain-deepagents-code",
agentsManifest: null,
controlUiPort: 18790,
gpu: true,
noGpu: false,
autoYes: true,
noOllamaAutostart: true,
});
});
it("uses explicit false/null defaults when flags are absent", () => {
expect(resolve({})).toEqual({
nonInteractive: false,
resume: false,
fresh: false,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("accepts the environment-based third-party notice acknowledgement", () => {
expect(
resolve({}, { env: { NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" } }).acceptThirdPartySoftware,
).toBe(true);
});
it("preserves the requested Dockerfile path after validating the resolved file", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-"));
const dockerfilePath = path.join(tmpDir, "Custom.Dockerfile");
fs.writeFileSync(dockerfilePath, "FROM scratch\n");
const relativeDockerfilePath = path.relative(process.cwd(), dockerfilePath);
expect(resolve({ from: relativeDockerfilePath }).fromDockerfile).toBe(relativeDockerfilePath);
});
it("rejects missing and non-file Dockerfile paths before onboarding", () => {
const errors: string[] = [];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-errors-"));
const deps = { error: (message = "") => errors.push(message) };
expect(() => resolve({ from: path.join(tmpDir, "missing") }, deps)).toThrow("exit:1");
expect(errors.join("\n")).toContain("--from path not found:");
errors.length = 0;
expect(() => resolve({ from: tmpDir }, deps)).toThrow("exit:1");
expect(errors.join("\n")).toContain("--from must point to a Dockerfile:");
});
it("canonicalizes known agent aliases", () => {
const listAgents = () => ["openclaw", "hermes", "langchain-deepagents-code"];
expect(resolve({ agent: "dcode" }, { listAgents }).agent).toBe("langchain-deepagents-code");
expect(resolve({ agent: "nemohermes" }, { listAgents }).agent).toBe("hermes");
});
it("rejects unknown agents with the available aliases", () => {
const errors: string[] = [];
expect(() =>
resolve(
{ agent: "bogus" },
{
listAgents: () => ["openclaw", "hermes"],
error: (message = "") => errors.push(message),
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("Unknown agent 'bogus'");
expect(errors.join("\n")).toContain("aliases: nemohermes → hermes");
});
it("runs onboard with resolved options", async () => {
const runOnboard = vi.fn(async () => {});
await runOnboardCommand({
flags: { resume: true },
env: {},
runOnboard,
error: () => {},
exit: exitWithCode,
});
expect(runOnboard).toHaveBeenCalledWith(expect.objectContaining({ resume: true }));
});
it("sets the Ollama autostart override before onboarding", async () => {
const previous = process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART;
delete process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART;
const restoreEnvironment =
previous === undefined
? () => delete process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART
: () => {
process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART = previous;
};
let observed: string | undefined;
try {
await runOnboardCommand({
flags: { "no-ollama-autostart": true },
env: {},
runOnboard: async () => {
observed = process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART;
},
});
expect(observed).toBe("1");
} finally {
restoreEnvironment();
}
});
});

134
src/lib/onboard/command.ts Normal file
View file

@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import fs from "node:fs";
import path from "node:path";
import { formatAgentAliasSuffix, resolveAgentNameAlias } from "../agent/aliases";
import { applyAgentsManifestEnv } from "./agents-manifest";
import type { OnboardFlags } from "./command-support";
import { isOpenclawAgent } from "./openclaw-otel-policy-presets";
import { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG_NAME } from "./usage-notice";
export interface OnboardCommandOptions {
nonInteractive: boolean;
resume: boolean;
fresh: boolean;
recreateSandbox: boolean;
fromDockerfile: string | null;
sandboxName: string | null;
sandboxGpu: "enable" | "disable" | null;
sandboxGpuDevice: string | null;
acceptThirdPartySoftware: boolean;
agent: string | null;
agentsManifest: string | null;
controlUiPort: number | null;
gpu: boolean;
noGpu: boolean;
autoYes: boolean;
noOllamaAutostart: boolean;
}
export interface ResolveOnboardOptionsDeps {
env: NodeJS.ProcessEnv;
listAgents?: () => string[];
error?: (message?: string) => void;
exit?: (code: number) => never;
}
export interface RunOnboardCommandDeps extends ResolveOnboardOptionsDeps {
flags: OnboardFlags;
runOnboard: (options: OnboardCommandOptions) => Promise<void>;
}
function fail(deps: ResolveOnboardOptionsDeps, message: string): never {
const error = deps.error ?? console.error;
const exit = deps.exit ?? ((code: number) => process.exit(code));
error(message);
return exit(1);
}
function resolveFileOption(
flag: "--from" | "--agents",
value: string | undefined,
deps: ResolveOnboardOptionsDeps,
preserveInput: boolean,
): string | null {
if (value === undefined) return null;
const resolved = path.resolve(value);
if (!fs.existsSync(resolved)) fail(deps, ` ${flag} path not found: ${resolved}`);
if (!fs.statSync(resolved).isFile()) {
const expected = flag === "--from" ? "a Dockerfile" : "a file";
fail(deps, ` ${flag} must point to ${expected}: ${resolved}`);
}
return preserveInput ? value : resolved;
}
function resolveAgent(
requestedAgent: string | undefined,
deps: ResolveOnboardOptionsDeps,
): string | null {
if (requestedAgent === undefined) return null;
const knownAgents = deps.listAgents?.() ?? [];
if (knownAgents.length === 0) return requestedAgent;
const resolvedAgent = resolveAgentNameAlias(requestedAgent, knownAgents);
if (resolvedAgent) return resolvedAgent;
return fail(
deps,
` Unknown agent '${requestedAgent}'. Available: ${knownAgents.join(", ")}${formatAgentAliasSuffix(knownAgents)}`,
);
}
function resolveAgentsManifest(
requestedManifest: string | undefined,
agent: string | null,
deps: ResolveOnboardOptionsDeps,
): string | null {
if (requestedManifest === undefined) return null;
if (!isOpenclawAgent(agent)) {
fail(
deps,
` --agents is OpenClaw-specific and cannot be used with --agent ${agent}; the declarative manifest only drives OpenClaw secondary agents.`,
);
}
return resolveFileOption("--agents", requestedManifest, deps, false);
}
function resolveSandboxGpu(flags: OnboardFlags): "enable" | "disable" | null {
if (flags["sandbox-gpu"]) return "enable";
if (flags["no-sandbox-gpu"]) return "disable";
return null;
}
export function resolveOnboardOptions(
flags: OnboardFlags,
deps: ResolveOnboardOptionsDeps,
): OnboardCommandOptions {
const agent = resolveAgent(flags.agent, deps);
return {
nonInteractive: flags["non-interactive"] === true,
resume: flags.resume === true,
fresh: flags.fresh === true,
recreateSandbox: flags["recreate-sandbox"] === true,
fromDockerfile: resolveFileOption("--from", flags.from, deps, true),
sandboxName: flags.name ?? null,
sandboxGpu: resolveSandboxGpu(flags),
sandboxGpuDevice: flags["sandbox-gpu-device"] ?? null,
acceptThirdPartySoftware:
flags[NOTICE_ACCEPT_FLAG_NAME] === true || String(deps.env[NOTICE_ACCEPT_ENV] || "") === "1",
agent,
agentsManifest: resolveAgentsManifest(flags.agents, agent, deps),
controlUiPort: flags["control-ui-port"] ?? null,
gpu: flags.gpu === true,
noGpu: flags["no-gpu"] === true,
autoYes: flags.yes === true,
noOllamaAutostart: flags["no-ollama-autostart"] === true,
};
}
export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise<void> {
const options = resolveOnboardOptions(deps.flags, deps);
if (options.noOllamaAutostart) process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1";
if (options.agentsManifest) applyAgentsManifestEnv(options.agentsManifest);
await deps.runOnboard(options);
}

View file

@ -1,137 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Focused coverage for the `--agents <agents.yaml>` lifecycle on `onboard`:
// parse-arg-into-option, missing-path/value rejection, env-var application
// before runOnboard is invoked. Split from legacy-command.test.ts so the
// hotspot does not grow further.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { parseOnboardArgs, runOnboardCommand } from "./legacy-command";
function exitWithCode(code: number): never {
throw new Error(String(code));
}
function exitWithPrefixedCode(code: number): never {
throw new Error(`exit:${code}`);
}
describe("onboard --agents", () => {
it("parses --agents <agents.yaml> into agentsManifest", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-parse-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(manifestPath, "agents: []\n");
const result = parseOnboardArgs(
["--agents", manifestPath],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
);
expect(result.agentsManifest).toBe(manifestPath);
});
it("rejects --agents when the file is missing", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--agents", "/nonexistent/agents.yaml"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--agents path not found");
});
it("rejects --agents when the value is missing", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--agents"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--agents requires a path to a YAML manifest");
});
it("rejects --agents when --agent is set to a non-OpenClaw runtime", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-hermes-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(manifestPath, "agents: []\n");
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--agent", "hermes", "--agents", manifestPath],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--agents is OpenClaw-specific");
});
it("sets NEMOCLAW_EXTRA_AGENTS_JSON before invoking runOnboard when --agents is supplied", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-agents-env-"));
const manifestPath = path.join(tmpDir, "agents.yaml");
fs.writeFileSync(
manifestPath,
["agents:", " - id: alpha", " tools:", " allow: [read]", ""].join("\n"),
);
const previous = process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
let observedRaw: string | undefined;
const runOnboard = vi.fn(async () => {
observedRaw = process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
});
try {
await runOnboardCommand({
args: ["--agents", manifestPath],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
error: () => {},
exit: exitWithCode,
});
expect(observedRaw).toBeDefined();
const payload = JSON.parse(observedRaw as string);
expect(payload.agents).toHaveLength(1);
expect(payload.agents[0]).toMatchObject({
id: "alpha",
workspace: "/sandbox/.openclaw/workspace-alpha",
agentDir: "/sandbox/.openclaw/agents/alpha",
});
} finally {
if (previous === undefined) {
delete process.env.NEMOCLAW_EXTRA_AGENTS_JSON;
} else {
process.env.NEMOCLAW_EXTRA_AGENTS_JSON = previous;
}
}
});
});

View file

@ -1,749 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
parseOnboardArgs,
runDeprecatedOnboardAliasCommand,
runOnboardCommand,
} from "./legacy-command";
function exitWithCode(code: number): never {
throw new Error(String(code));
}
function exitWithPrefixedCode(code: number): never {
throw new Error(`exit:${code}`);
}
describe("onboard command", () => {
it("parses onboard flags", () => {
expect(
parseOnboardArgs(
["--non-interactive", "--resume", "--yes-i-accept-third-party-software"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
),
).toEqual({
nonInteractive: true,
resume: true,
fresh: false,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: true,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it.each<{ flags: string[] }>([
{ flags: ["--yes"] },
{ flags: ["-y"] },
{ flags: ["--yes", "-y"] },
])("sets autoYes when invoked with $flags", ({ flags }) => {
const result = parseOnboardArgs(
flags,
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
);
expect(result.autoYes).toBe(true);
});
it("accepts the env-based third-party notice acknowledgement", () => {
expect(
parseOnboardArgs(
[],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: { NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" },
error: () => {},
exit: exitWithCode,
},
),
).toEqual({
nonInteractive: false,
resume: false,
fresh: false,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: true,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("runs onboard with parsed options", async () => {
const runOnboard = vi.fn(async () => {});
await runOnboardCommand({
args: ["--resume"],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
error: () => {},
exit: exitWithCode,
});
expect(runOnboard).toHaveBeenCalledWith({
nonInteractive: false,
resume: true,
fresh: false,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("prints usage and skips onboarding for --help", async () => {
const runOnboard = vi.fn(async () => {});
const lines: string[] = [];
await runOnboardCommand({
args: ["--help"],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
log: (message = "") => lines.push(message),
error: () => {},
exit: exitWithCode,
});
expect(runOnboard).not.toHaveBeenCalled();
expect(lines.join("\n")).toContain("Usage: nemoclaw onboard");
expect(lines.join("\n")).toContain("--from <Dockerfile>");
expect(lines.join("\n")).toContain("--name <sandbox>");
expect(lines.join("\n")).toContain("Dockerfile's parent directory");
expect(lines.join("\n")).toContain("node_modules, .git, .venv, __pycache__");
expect(lines.join("\n")).toContain(".env*, .ssh, .aws");
expect(lines.join("\n")).toContain("--agent <name>");
expect(lines.join("\n")).toContain("--agents <agents.yaml>");
expect(lines.join("\n")).toContain("--no-gpu");
});
it("parses --from <Dockerfile>", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-parse-"));
const dockerfilePath = path.join(tmpDir, "Custom.Dockerfile");
fs.writeFileSync(dockerfilePath, "FROM scratch\n");
expect(
parseOnboardArgs(
["--resume", "--from", dockerfilePath],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
),
).toEqual({
nonInteractive: false,
resume: true,
fresh: false,
recreateSandbox: false,
fromDockerfile: dockerfilePath,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
// --agents <agents.yaml> parsing covered by
// src/lib/onboard/legacy-command-agents.test.ts to keep this hotspot from
// growing further; that file owns the full --agents lifecycle (parse,
// missing-path/value rejection, env-var application before runOnboard).
it("parses --fresh and surfaces it as fresh=true", () => {
expect(
parseOnboardArgs(
["--fresh"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
),
).toEqual({
nonInteractive: false,
resume: false,
fresh: true,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("rejects --resume and --fresh together", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--resume", "--fresh"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--resume and --fresh are mutually exclusive");
});
it("parses --name <sandbox>", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-name-parse-"));
const dockerfilePath = path.join(tmpDir, "Custom.Dockerfile");
fs.writeFileSync(dockerfilePath, "FROM scratch\n");
expect(
parseOnboardArgs(
["--non-interactive", "--from", dockerfilePath, "--name", "second-assistant"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
),
).toEqual({
nonInteractive: true,
resume: false,
fresh: false,
recreateSandbox: false,
fromDockerfile: dockerfilePath,
sandboxName: "second-assistant",
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("exits when --name is missing its sandbox value", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--name"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--name requires a sandbox name");
});
it("exits when --name is followed by another flag instead of a value", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--name", "--resume"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--name requires a sandbox name");
});
it("exits when --from is missing its Dockerfile path", () => {
expect(() =>
parseOnboardArgs(
["--from"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
});
it("exits before onboarding when --from points to a missing Dockerfile", async () => {
const runOnboard = vi.fn(async () => {});
const errors: string[] = [];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-missing-"));
await expect(
runOnboardCommand({
args: ["--from", path.join(tmpDir, "no-such-dockerfile-2589")],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
}),
).rejects.toThrow("exit:1");
expect(runOnboard).not.toHaveBeenCalled();
expect(errors.join("\n")).toContain("--from path not found:");
});
it("exits before onboarding when --from points to a directory", async () => {
const runOnboard = vi.fn(async () => {});
const errors: string[] = [];
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-from-dir-"));
await expect(
runOnboardCommand({
args: ["--from", tmpDir],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
}),
).rejects.toThrow("exit:1");
expect(runOnboard).not.toHaveBeenCalled();
expect(errors.join("\n")).toContain("--from must point to a Dockerfile:");
});
it("exits with usage on unknown args", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--bad-flag"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("Unknown onboard option(s): --bad-flag");
expect(errors.join("\n")).toContain("Usage: nemoclaw onboard");
});
it("parses --agent", () => {
expect(
parseOnboardArgs(
["--agent", "openclaw"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
listAgents: () => ["openclaw", "hermes"],
error: () => {},
exit: exitWithCode,
},
),
).toEqual({
nonInteractive: false,
resume: false,
fresh: false,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: "openclaw",
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("canonicalizes known --agent aliases", () => {
expect(
parseOnboardArgs(
["--agent", "dcode"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
listAgents: () => ["openclaw", "hermes", "langchain-deepagents-code"],
error: () => {},
exit: exitWithCode,
},
).agent,
).toBe("langchain-deepagents-code");
expect(
parseOnboardArgs(
["--agent", "nemohermes"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
listAgents: () => ["openclaw", "hermes", "langchain-deepagents-code"],
error: () => {},
exit: exitWithCode,
},
).agent,
).toBe("hermes");
});
it("rejects unknown --agent values", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--agent", "bogus"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
listAgents: () => ["openclaw", "hermes"],
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("Unknown agent 'bogus'");
expect(errors.join("\n")).toContain("aliases: nemohermes → hermes");
expect(errors.join("\n")).toContain("Usage: nemoclaw onboard");
});
it("parses --control-ui-port with a valid port", () => {
const result = parseOnboardArgs(
["--control-ui-port", "18790"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: ((code: number) => {
throw new Error(String(code));
}) as never,
},
);
expect(result.controlUiPort).toBe(18790);
});
it("exits when --control-ui-port is missing its value", () => {
expect(() =>
parseOnboardArgs(
["--control-ui-port"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: ((code: number) => {
throw new Error(`exit:${code}`);
}) as never,
},
),
).toThrow("exit:1");
});
it("exits when --control-ui-port value is out of range", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--control-ui-port", "80"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: ((code: number) => {
throw new Error(`exit:${code}`);
}) as never,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("1024-65535");
});
it("--control-ui-port takes precedence over CHAT_UI_URL env", () => {
const result = parseOnboardArgs(
["--control-ui-port", "19000"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: { CHAT_UI_URL: "http://127.0.0.1:18790" },
error: () => {},
exit: ((code: number) => {
throw new Error(String(code));
}) as never,
},
);
expect(result.controlUiPort).toBe(19000);
});
it("parses direct sandbox GPU flags", () => {
const result = parseOnboardArgs(
["--sandbox-gpu", "--sandbox-gpu-device", "0"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
);
expect(result.sandboxGpu).toBe("enable");
expect(result.sandboxGpuDevice).toBe("0");
});
it("rejects conflicting sandbox GPU flags", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--sandbox-gpu", "--no-sandbox-gpu"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("mutually exclusive");
});
it("--help includes --control-ui-port in usage", async () => {
const lines: string[] = [];
await runOnboardCommand({
args: ["--help"],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard: vi.fn(async () => {}),
log: (message = "") => lines.push(message),
error: () => {},
exit: ((code: number) => {
throw new Error(String(code));
}) as never,
});
expect(lines.join("\n")).toContain("--control-ui-port");
expect(lines.join("\n")).toContain("--sandbox-gpu");
});
it("prints the setup-spark deprecation text before delegating", async () => {
const lines: string[] = [];
const runOnboard = vi.fn(async () => {});
await runDeprecatedOnboardAliasCommand({
kind: "setup-spark",
args: [],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
log: (message = "") => lines.push(message),
error: () => {},
exit: exitWithCode,
});
expect(lines.join("\n")).toContain("setup-spark` is deprecated");
expect(lines.join("\n")).toContain("Use `nemoclaw onboard` instead");
expect(runOnboard).toHaveBeenCalledTimes(1);
expect(runOnboard).toHaveBeenCalledWith({
nonInteractive: false,
resume: false,
fresh: false,
recreateSandbox: false,
fromDockerfile: null,
sandboxName: null,
sandboxGpu: null,
sandboxGpuDevice: null,
acceptThirdPartySoftware: false,
agent: null,
agentsManifest: null,
controlUiPort: null,
gpu: false,
noGpu: false,
autoYes: false,
noOllamaAutostart: false,
});
});
it("prints the setup deprecation text before delegating", async () => {
const lines: string[] = [];
const runOnboard = vi.fn(async () => {});
await runDeprecatedOnboardAliasCommand({
kind: "setup",
args: [],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard,
log: (message = "") => lines.push(message),
error: () => {},
exit: exitWithCode,
});
expect(lines.join("\n")).toContain("`nemoclaw setup` is deprecated");
expect(lines.join("\n")).toContain("Use `nemoclaw onboard` instead");
expect(runOnboard).toHaveBeenCalledTimes(1);
});
it("parses --gpu as explicit GPU passthrough intent", () => {
const result = parseOnboardArgs(
["--gpu", "--non-interactive"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
);
expect(result.gpu).toBe(true);
expect(result.noGpu).toBe(false);
expect(result.nonInteractive).toBe(true);
});
it("parses --no-gpu as explicit GPU passthrough opt-out", () => {
const result = parseOnboardArgs(
["--no-gpu", "--non-interactive"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
);
expect(result.gpu).toBe(false);
expect(result.noGpu).toBe(true);
expect(result.nonInteractive).toBe(true);
});
it("rejects --gpu and --no-gpu together", () => {
const errors: string[] = [];
expect(() =>
parseOnboardArgs(
["--gpu", "--no-gpu"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
),
).toThrow("exit:1");
expect(errors.join("\n")).toContain("--gpu and --no-gpu are mutually exclusive");
});
it("defaults noOllamaAutostart to false when the flag is absent", () => {
const result = parseOnboardArgs(
[],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: () => {},
exit: exitWithCode,
},
);
expect(result.noOllamaAutostart).toBe(false);
});
it("parses --no-ollama-autostart as noOllamaAutostart=true without rejecting it as unknown", () => {
const errors: string[] = [];
const result = parseOnboardArgs(
["--no-ollama-autostart"],
"--yes-i-accept-third-party-software",
"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
{
env: {},
error: (message = "") => errors.push(message),
exit: exitWithPrefixedCode,
},
);
expect(result.noOllamaAutostart).toBe(true);
expect(errors.join("\n")).not.toContain("Unknown onboard option(s)");
});
it("--help advertises --no-ollama-autostart in the usage output", async () => {
const lines: string[] = [];
await runOnboardCommand({
args: ["--help"],
noticeAcceptFlag: "--yes-i-accept-third-party-software",
noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE",
env: {},
runOnboard: vi.fn(async () => {}),
log: (message = "") => lines.push(message),
error: () => {},
exit: exitWithCode,
});
expect(lines.join("\n")).toContain("--no-ollama-autostart");
expect(lines.join("\n")).toContain("inference-provider selection");
});
});

View file

@ -1,328 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import fs from "node:fs";
import path from "node:path";
import { formatAgentAliasSuffix, resolveAgentNameAlias } from "../agent/aliases";
import { CLI_NAME } from "../cli/branding";
import { applyAgentsManifestEnv } from "./agents-manifest";
import { isOpenclawAgent } from "./openclaw-otel-policy-presets";
export interface OnboardCommandOptions {
nonInteractive: boolean;
resume: boolean;
fresh: boolean;
recreateSandbox: boolean;
fromDockerfile: string | null;
sandboxName: string | null;
sandboxGpu: "enable" | "disable" | null;
sandboxGpuDevice: string | null;
acceptThirdPartySoftware: boolean;
agent: string | null;
agentsManifest: string | null;
controlUiPort: number | null;
gpu: boolean;
noGpu: boolean;
autoYes: boolean;
noOllamaAutostart: boolean;
}
export interface RunOnboardCommandDeps {
args: string[];
noticeAcceptFlag: string;
noticeAcceptEnv: string;
env: NodeJS.ProcessEnv;
runOnboard: (options: OnboardCommandOptions) => Promise<void>;
listAgents?: () => string[];
log?: (message?: string) => void;
error?: (message?: string) => void;
exit?: (code: number) => never;
}
export interface RunDeprecatedOnboardAliasCommandDeps extends RunOnboardCommandDeps {
kind: "setup" | "setup-spark";
}
const ONBOARD_BASE_ARGS = [
"--non-interactive",
"--resume",
"--fresh",
"--recreate-sandbox",
"--gpu",
"--no-gpu",
"--yes",
"-y",
"--no-ollama-autostart",
];
function onboardUsageLines(noticeAcceptFlag: string): string[] {
const name = CLI_NAME;
return [
` Usage: ${name} onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from <Dockerfile>] [--name <sandbox>] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device <device>] [--agent <name>] [--agents <agents.yaml>] [--control-ui-port <N>] [--yes | -y] [--no-ollama-autostart] [${noticeAcceptFlag}]`,
"",
" --from <Dockerfile> uses the Dockerfile's parent directory as the Docker build context.",
" --agents <agents.yaml> declares secondary OpenClaw agents, agents.defaults, and main-agent overrides; the YAML is baked into the sandbox image at build time.",
" --no-ollama-autostart skips the wizard's eager Ollama auto-start during inference-provider selection so onboard surfaces the unreachable-Ollama warning and the default fallback model; later setup steps still expect a reachable Ollama, and on Linux hosts with a systemd Ollama unit the loopback-override path may still restart the daemon ahead of this gate.",
" --gpu enables direct NVIDIA GPU access inside the sandbox; --no-gpu forces CPU sandbox behavior.",
" --sandbox-gpu enables direct NVIDIA GPU access inside the sandbox; --no-sandbox-gpu forces CPU sandbox behavior.",
" --sandbox-gpu-device passes a specific OpenShell GPU device selector to sandbox create; requires --sandbox-gpu.",
" Put files referenced by COPY/ADD next to that Dockerfile, or move the Dockerfile into",
" a dedicated build directory to avoid sending unrelated files to Docker.",
" Common large directories are skipped: node_modules, .git, .venv, __pycache__.",
" Credential-style files and directories such as .env*, .ssh, .aws, .netrc, .npmrc, secrets/, *.pem, and *.key are also skipped.",
" Generated output directories such as dist/, build/, and target/ are still included.",
"",
];
}
function printOnboardUsage(writer: (message?: string) => void, noticeAcceptFlag: string): void {
for (const line of onboardUsageLines(noticeAcceptFlag)) {
writer(line);
}
}
export function parseOnboardArgs(
args: string[],
noticeAcceptFlag: string,
noticeAcceptEnv: string,
deps: Pick<RunOnboardCommandDeps, "env" | "error" | "exit" | "listAgents">,
): OnboardCommandOptions {
const error = deps.error ?? console.error;
const exit = deps.exit ?? ((code: number) => process.exit(code));
const parsedArgs = [...args];
let fromDockerfile: string | null = null;
const fromIdx = parsedArgs.indexOf("--from");
if (fromIdx !== -1) {
const requestedFromDockerfile = parsedArgs[fromIdx + 1];
if (!requestedFromDockerfile || requestedFromDockerfile.startsWith("--")) {
error(" --from requires a path to a Dockerfile");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const resolvedFromDockerfile = path.resolve(requestedFromDockerfile);
if (!fs.existsSync(resolvedFromDockerfile)) {
error(` --from path not found: ${resolvedFromDockerfile}`);
exit(1);
}
if (!fs.statSync(resolvedFromDockerfile).isFile()) {
error(` --from must point to a Dockerfile: ${resolvedFromDockerfile}`);
exit(1);
}
fromDockerfile = requestedFromDockerfile;
parsedArgs.splice(fromIdx, 2);
}
let sandboxName: string | null = null;
const nameIdx = parsedArgs.indexOf("--name");
if (nameIdx !== -1) {
const nameValue = parsedArgs[nameIdx + 1];
if (typeof nameValue !== "string" || nameValue.length === 0 || nameValue.startsWith("--")) {
error(" --name requires a sandbox name");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
sandboxName = nameValue;
parsedArgs.splice(nameIdx, 2);
}
let agent: string | null = null;
const agentIdx = parsedArgs.indexOf("--agent");
if (agentIdx !== -1) {
const agentValue = parsedArgs[agentIdx + 1];
if (typeof agentValue !== "string" || agentValue.startsWith("--")) {
error(" --agent requires a name");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const knownAgents = deps.listAgents?.() ?? [];
const resolvedAgent =
knownAgents.length > 0 ? resolveAgentNameAlias(agentValue, knownAgents) : agentValue;
if (knownAgents.length > 0 && !resolvedAgent) {
error(
` Unknown agent '${agentValue}'. Available: ${knownAgents.join(", ")}${formatAgentAliasSuffix(knownAgents)}`,
);
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
agent = resolvedAgent;
parsedArgs.splice(agentIdx, 2);
}
let agentsManifest: string | null = null;
const agentsIdx = parsedArgs.indexOf("--agents");
if (agentsIdx !== -1) {
const agentsValue = parsedArgs[agentsIdx + 1];
if (
typeof agentsValue !== "string" ||
agentsValue.length === 0 ||
agentsValue.startsWith("--")
) {
error(" --agents requires a path to a YAML manifest");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
if (!isOpenclawAgent(agent)) {
error(
` --agents is OpenClaw-specific and cannot be used with --agent ${agent}; the declarative manifest only drives OpenClaw secondary agents.`,
);
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const resolved = path.resolve(agentsValue);
if (!fs.existsSync(resolved)) {
error(` --agents path not found: ${resolved}`);
exit(1);
}
if (!fs.statSync(resolved).isFile()) {
error(` --agents must point to a file: ${resolved}`);
exit(1);
}
agentsManifest = resolved;
parsedArgs.splice(agentsIdx, 2);
}
let controlUiPort: number | null = null;
const portIdx = parsedArgs.indexOf("--control-ui-port");
if (portIdx !== -1) {
const portValue = parsedArgs[portIdx + 1];
if (typeof portValue !== "string" || portValue.startsWith("--")) {
error(" --control-ui-port requires a port number");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const parsed = Number(portValue);
if (!Number.isInteger(parsed) || parsed < 1024 || parsed > 65535) {
error(` --control-ui-port: ${portValue} is not a valid port (1024-65535)`);
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
controlUiPort = parsed;
parsedArgs.splice(portIdx, 2);
}
const sandboxGpuFlag = parsedArgs.includes("--sandbox-gpu");
const noSandboxGpuFlag = parsedArgs.includes("--no-sandbox-gpu");
if (sandboxGpuFlag && noSandboxGpuFlag) {
error(" --sandbox-gpu and --no-sandbox-gpu are mutually exclusive.");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
let sandboxGpu: "enable" | "disable" | null = null;
if (sandboxGpuFlag) {
sandboxGpu = "enable";
parsedArgs.splice(parsedArgs.indexOf("--sandbox-gpu"), 1);
}
if (noSandboxGpuFlag) {
sandboxGpu = "disable";
parsedArgs.splice(parsedArgs.indexOf("--no-sandbox-gpu"), 1);
}
let sandboxGpuDevice: string | null = null;
const sandboxGpuDeviceIdx = parsedArgs.indexOf("--sandbox-gpu-device");
if (sandboxGpuDeviceIdx !== -1) {
const deviceValue = parsedArgs[sandboxGpuDeviceIdx + 1];
if (
typeof deviceValue !== "string" ||
deviceValue.length === 0 ||
deviceValue.startsWith("--")
) {
error(" --sandbox-gpu-device requires a device selector");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
sandboxGpuDevice = deviceValue;
parsedArgs.splice(sandboxGpuDeviceIdx, 2);
}
if (sandboxGpu === "disable" && sandboxGpuDevice) {
error(" --sandbox-gpu-device cannot be used with --no-sandbox-gpu.");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const allowedArgs = new Set([...ONBOARD_BASE_ARGS, noticeAcceptFlag]);
const unknownArgs = parsedArgs.filter((arg) => !allowedArgs.has(arg));
if (unknownArgs.length > 0) {
error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`);
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const resume = parsedArgs.includes("--resume");
const fresh = parsedArgs.includes("--fresh");
if (resume && fresh) {
error(" --resume and --fresh are mutually exclusive.");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
const gpu = parsedArgs.includes("--gpu");
const noGpu = parsedArgs.includes("--no-gpu");
if (gpu && noGpu) {
error(" --gpu and --no-gpu are mutually exclusive.");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
if ((gpu && sandboxGpu === "disable") || (noGpu && sandboxGpu === "enable")) {
error(" --gpu/--no-gpu conflict with the sandbox GPU flags.");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
if (noGpu && sandboxGpuDevice) {
error(" --sandbox-gpu-device cannot be used with --no-gpu.");
printOnboardUsage(error, noticeAcceptFlag);
exit(1);
}
return {
nonInteractive: parsedArgs.includes("--non-interactive"),
resume,
fresh,
recreateSandbox: parsedArgs.includes("--recreate-sandbox"),
fromDockerfile,
sandboxName,
sandboxGpu,
sandboxGpuDevice,
acceptThirdPartySoftware:
parsedArgs.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1",
agent,
agentsManifest,
controlUiPort,
gpu,
noGpu,
autoYes: parsedArgs.includes("--yes") || parsedArgs.includes("-y"),
noOllamaAutostart: parsedArgs.includes("--no-ollama-autostart"),
};
}
export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise<void> {
const log = deps.log ?? console.log;
if (deps.args.includes("--help") || deps.args.includes("-h")) {
printOnboardUsage(log, deps.noticeAcceptFlag);
return;
}
const options = parseOnboardArgs(deps.args, deps.noticeAcceptFlag, deps.noticeAcceptEnv, deps);
if (options.noOllamaAutostart) process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1";
if (options.agentsManifest) {
applyAgentsManifestEnv(options.agentsManifest);
}
await deps.runOnboard(options);
}
export async function runDeprecatedOnboardAliasCommand(
deps: RunDeprecatedOnboardAliasCommandDeps,
): Promise<void> {
const cliName = CLI_NAME;
const log = deps.log ?? console.log;
log("");
if (deps.kind === "setup") {
log(`\`${cliName} setup\` is deprecated. Use \`${cliName} onboard\` instead.`);
} else {
log(`\`${cliName} setup-spark\` is deprecated.`);
log(" Current OpenShell releases handle the old DGX Spark cgroup issue themselves.");
log(` Use \`${cliName} onboard\` instead.`);
}
log("");
await runOnboardCommand(deps);
}

View file

@ -7,7 +7,8 @@ import path from "node:path";
import noticeConfig from "../../../bin/lib/usage-notice.json";
export const NOTICE_ACCEPT_FLAG = "--yes-i-accept-third-party-software";
export const NOTICE_ACCEPT_FLAG_NAME = "yes-i-accept-third-party-software";
export const NOTICE_ACCEPT_FLAG = `--${NOTICE_ACCEPT_FLAG_NAME}`;
export const NOTICE_ACCEPT_ENV = "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE";
const OSC8_OPEN = "\u001B]8;;";
const OSC8_CLOSE = "\u001B]8;;\u001B\\";

View file

@ -1,10 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PARSER_EXIT_CODE, run, runWithEnv } from "./helpers";
@ -100,43 +100,48 @@ describe("CLI onboard compatibility", () => {
expect(r.out).not.toContain("Nonexistent flag: --yes");
});
it("passes onboard sandbox GPU flags to legacy validation", () => {
it("lets oclif reject conflicting sandbox GPU flags", () => {
const r = run(
"onboard --sandbox-gpu --no-sandbox-gpu --non-interactive --yes-i-accept-third-party-software --yes",
);
expect(r.code).toBe(1);
expect(r.out).toContain("--sandbox-gpu and --no-sandbox-gpu are mutually exclusive");
expect(r.out).not.toContain("Nonexistent flag: --sandbox-gpu");
expect(r.out).not.toContain("Nonexistent flag: --no-sandbox-gpu");
expect(r.code).toBe(PARSER_EXIT_CODE);
expect(r.out).toContain("--no-sandbox-gpu=true cannot also be provided");
expect(r.out).toContain("--sandbox-gpu");
});
it("passes onboard sandbox GPU device flags to legacy validation", () => {
it("lets oclif enforce the sandbox GPU device dependency", () => {
const r = run(
"onboard --sandbox-gpu-device nvidia.com/gpu=0 --no-sandbox-gpu --non-interactive --yes-i-accept-third-party-software --yes",
);
expect(r.code).toBe(1);
expect(r.out).toContain("--sandbox-gpu-device cannot be used with --no-sandbox-gpu");
expect(r.out).not.toContain("Nonexistent flag: --sandbox-gpu-device");
expect(r.code).toBe(PARSER_EXIT_CODE);
expect(r.out).toContain("must be provided when using --sandbox-gpu-device");
expect(r.out).toContain("--sandbox-gpu");
});
it("setup --help exits 0 and shows onboard usage", () => {
it("lets oclif reject privileged control UI ports", () => {
const r = run("onboard --control-ui-port 80");
expect(r.code).toBe(PARSER_EXIT_CODE);
expect(r.out).toContain("Expected an integer greater than or equal to 1024 but received: 80");
});
it("setup --help exits 0 and shows native deprecated-alias usage", () => {
const r = run("setup --help");
expect(r.code).toBe(0);
expect(r.out.includes("setup` is deprecated")).toBeTruthy();
expect(r.out.includes("Usage: nemoclaw onboard")).toBeTruthy();
expect(r.out.includes("Unknown onboard option")).toBeFalsy();
expect(r.out).toContain("Deprecated: 'nemoclaw setup' is now 'nemoclaw onboard'");
expect(r.out).toContain("$ nemoclaw setup [flags]");
expect(r.out).not.toContain("Unknown onboard option");
});
it("setup forwards unknown options into onboard parsing", () => {
it("setup rejects unknown options through oclif", () => {
const r = run("setup --non-interactiv");
expect(r.code).toBe(PARSER_EXIT_CODE);
expect(r.out).toContain("Nonexistent flag: --non-interactiv");
});
it("setup forwards --resume into onboard parsing", () => {
it("setup forwards --resume into the shared onboard action", () => {
const r = run("setup --resume --non-interactive --yes-i-accept-third-party-software --yes");
expect(r.code).toBe(1);
expect(r.out.includes("deprecated")).toBeTruthy();
expect(r.out).toContain("Deprecated: 'nemoclaw setup' is now 'nemoclaw onboard'");
expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy();
});
@ -197,13 +202,12 @@ describe("CLI onboard compatibility", () => {
expect(r.out.includes("Cannot resume non-interactive onboard")).toBeTruthy();
});
it("setup-spark --help exits 0 and shows onboard usage", () => {
it("setup-spark --help exits 0 and shows native deprecated-alias usage", () => {
const r = run("setup-spark --help");
expect(r.code).toBe(0);
expect(r.out.includes("setup-spark` is deprecated")).toBeTruthy();
expect(r.out.includes("Use `nemoclaw onboard` instead")).toBeTruthy();
expect(r.out.includes("Usage: nemoclaw onboard")).toBeTruthy();
expect(r.out.includes("Unknown onboard option")).toBeFalsy();
expect(r.out).toContain("Deprecated: 'nemoclaw setup-spark' is now 'nemoclaw onboard'");
expect(r.out).toContain("$ nemoclaw setup-spark [flags]");
expect(r.out).not.toContain("Unknown onboard option");
});
it("setup-spark is a deprecated compatibility alias for onboard", () => {
@ -211,8 +215,7 @@ describe("CLI onboard compatibility", () => {
"setup-spark --resume --non-interactive --yes-i-accept-third-party-software --yes",
);
expect(r.code).toBe(1);
expect(r.out.includes("setup-spark` is deprecated")).toBeTruthy();
expect(r.out.includes("Use `nemoclaw onboard` instead")).toBeTruthy();
expect(r.out).toContain("Deprecated: 'nemoclaw setup-spark' is now 'nemoclaw onboard'");
expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy();
});

View file

@ -299,10 +299,16 @@ JSON
$mode = "flags";
next;
}
if (/^\s*(ARGUMENTS|DESCRIPTION|EXAMPLES)\s*$/i || /^\s*$/) {
if (/^\s*(ARGUMENTS|DESCRIPTION|EXAMPLES)\s*$/i) {
$mode = "";
next;
}
if (/^\s*$/) {
# Oclif separates flag entries with blank lines, while the custom
# one-line Usage format uses one to end its usage synopsis.
$mode = "" if $mode eq "usage";
next;
}
emit_flags($_) if $mode;
' | LC_ALL=C sort -u
}

View file

@ -1,10 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { execTimeout } from "./helpers/timeouts";
@ -96,6 +96,13 @@ describe("nemohermes alias", () => {
expect(out).toContain("NemoHermes");
});
it("brands deprecated setup help with the invoked alias", () => {
const { code, out } = runHermes("setup --help");
expect(code).toBe(0);
expect(out).toContain("Deprecated: 'nemohermes setup' is now 'nemohermes onboard'");
expect(out).not.toContain("Deprecated: 'nemoclaw setup'");
});
it("routes nemohermes uninstall as a global command, not a sandbox connect command", () => {
const { code, out } = runHermes("uninstall --help");
expect(code).toBe(0);