diff --git a/src/lib/legacy-oclif-dispatch.ts b/src/lib/legacy-oclif-dispatch.ts new file mode 100644 index 00000000000..9e1030c91e0 --- /dev/null +++ b/src/lib/legacy-oclif-dispatch.ts @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type OclifDispatch = { + kind: "oclif"; + commandId: string; + args: string[]; +}; + +export type HelpDispatch = { + kind: "help"; + usage: string; +}; + +export type UsageErrorDispatch = { + kind: "usageError"; + lines: string[]; +}; + +export type LegacyDispatch = { + kind: "legacy"; + target: "doctor" | "policy-add" | "skill" | "snapshot"; +}; + +export type UnknownSubcommandDispatch = { + kind: "unknownSubcommand"; + command: "credentials" | "channels"; + subcommand: string; +}; + +export type UnknownActionDispatch = { + kind: "unknownAction"; + action: string; +}; + +export type DispatchResult = + | OclifDispatch + | HelpDispatch + | UsageErrorDispatch + | LegacyDispatch + | UnknownSubcommandDispatch + | UnknownActionDispatch; + +function hasHelpFlag(args: readonly string[]): boolean { + return args.includes("--help") || args.includes("-h"); +} + +function hasMissingFlagValue(args: readonly string[], flagName: string): boolean { + const index = args.indexOf(flagName); + return index !== -1 && (!args[index + 1] || args[index + 1].startsWith("--")); +} + +export function resolveGlobalOclifDispatch(cmd: string, args: string[]): DispatchResult { + switch (cmd) { + case "onboard": + case "setup": + case "setup-spark": + case "deploy": + case "start": + case "stop": + case "status": + case "debug": + case "uninstall": + case "list": + case "backup-all": + case "upgrade-sandboxes": + case "gc": + return { kind: "oclif", commandId: cmd, args }; + case "tunnel": { + const sub = args[0]; + if (sub === "start" || sub === "stop") { + return { kind: "oclif", commandId: `tunnel:${sub}`, args: args.slice(1) }; + } + return { kind: "usageError", lines: ["tunnel "] }; + } + case "credentials": { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + return { kind: "oclif", commandId: "credentials", args: [] }; + } + if (sub === "list") { + return { kind: "oclif", commandId: "credentials:list", args: args.slice(1) }; + } + if (sub === "reset") { + return { kind: "oclif", commandId: "credentials:reset", args: args.slice(1) }; + } + return { kind: "unknownSubcommand", command: "credentials", subcommand: sub }; + } + case "--version": + case "-v": + return { kind: "oclif", commandId: "root:version", args: [] }; + default: + return { kind: "usageError", lines: [] }; + } +} + +export function resolveSandboxOclifDispatch( + sandboxName: string, + action: string, + actionArgs: string[], +): DispatchResult { + switch (action) { + case "connect": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "connect" }; + return { kind: "oclif", commandId: "sandbox:connect", args: [sandboxName, ...actionArgs] }; + case "status": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "status" }; + return { kind: "oclif", commandId: "sandbox:status", args: [sandboxName, ...actionArgs] }; + case "logs": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "logs [--follow]" }; + return { kind: "oclif", commandId: "sandbox:logs", args: [sandboxName, ...actionArgs] }; + case "doctor": + return { kind: "legacy", target: "doctor" }; + case "policy-add": + if (hasHelpFlag(actionArgs)) { + return { + kind: "help", + usage: "policy-add [preset] [--yes|-y] [--dry-run] [--from-file ] [--from-dir ]", + }; + } + if (hasMissingFlagValue(actionArgs, "--from-file") || hasMissingFlagValue(actionArgs, "--from-dir")) { + return { kind: "legacy", target: "policy-add" }; + } + return { kind: "oclif", commandId: "sandbox:policy-add", args: [sandboxName, ...actionArgs] }; + case "policy-remove": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "policy-remove [preset] [--yes|-y] [--dry-run]" }; + return { kind: "oclif", commandId: "sandbox:policy-remove", args: [sandboxName, ...actionArgs] }; + case "policy-list": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "policy-list" }; + return { kind: "oclif", commandId: "sandbox:policy-list", args: [sandboxName, ...actionArgs] }; + case "destroy": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "destroy [--yes|--force]" }; + return { kind: "oclif", commandId: "sandbox:destroy", args: [sandboxName, ...actionArgs] }; + case "gateway-token": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "gateway-token [--quiet|-q]" }; + return { kind: "oclif", commandId: "sandbox:gateway-token", args: [sandboxName, ...actionArgs] }; + case "skill": { + const skillSub = actionArgs[0]; + const skillArgs = actionArgs.slice(1); + if (!skillSub || skillSub === "help" || skillSub === "--help" || skillSub === "-h") { + return { kind: "legacy", target: "skill" }; + } + if (skillSub === "install") { + if (hasHelpFlag(skillArgs)) return { kind: "legacy", target: "skill" }; + return { kind: "oclif", commandId: "sandbox:skill:install", args: [sandboxName, ...skillArgs] }; + } + return { kind: "legacy", target: "skill" }; + } + case "rebuild": + if (hasHelpFlag(actionArgs)) return { kind: "help", usage: "rebuild [--yes|--force] [--verbose|-v]" }; + return { kind: "oclif", commandId: "sandbox:rebuild", args: [sandboxName, ...actionArgs] }; + case "share": + return { kind: "oclif", commandId: "share", args: [sandboxName, ...actionArgs] }; + case "snapshot": { + const snapshotSub = actionArgs[0]; + const snapshotArgs = actionArgs.slice(1); + if (snapshotSub === "list") { + if (hasHelpFlag(snapshotArgs)) return { kind: "help", usage: "snapshot list" }; + return { kind: "oclif", commandId: "sandbox:snapshot:list", args: [sandboxName, ...snapshotArgs] }; + } + if (snapshotSub === "create") { + if (hasHelpFlag(snapshotArgs)) return { kind: "help", usage: "snapshot create [--name ]" }; + return { kind: "oclif", commandId: "sandbox:snapshot:create", args: [sandboxName, ...snapshotArgs] }; + } + if (snapshotSub === "restore") { + if (hasHelpFlag(snapshotArgs)) return { kind: "help", usage: "snapshot restore [selector] [--to ]" }; + return { kind: "oclif", commandId: "sandbox:snapshot:restore", args: [sandboxName, ...snapshotArgs] }; + } + return { kind: "legacy", target: "snapshot" }; + } + case "shields": { + const shieldsSub = actionArgs[0]; + const shieldsArgs = actionArgs.slice(1); + if (shieldsSub === "down") { + if (hasHelpFlag(shieldsArgs)) return { kind: "help", usage: "shields down [--timeout 5m] [--reason 'text'] [--policy permissive]" }; + return { kind: "oclif", commandId: "sandbox:shields:down", args: [sandboxName, ...shieldsArgs] }; + } + if (shieldsSub === "up") { + if (hasHelpFlag(shieldsArgs)) return { kind: "help", usage: "shields up" }; + return { kind: "oclif", commandId: "sandbox:shields:up", args: [sandboxName, ...shieldsArgs] }; + } + if (shieldsSub === "status") { + if (hasHelpFlag(shieldsArgs)) return { kind: "help", usage: "shields status" }; + return { kind: "oclif", commandId: "sandbox:shields:status", args: [sandboxName, ...shieldsArgs] }; + } + return { + kind: "usageError", + lines: ["shields ", " down [--timeout 5m] [--reason 'text'] [--policy permissive]", " up Restore policy from snapshot", " status Show current shields state"], + }; + } + case "channels": { + const channelsSub = actionArgs[0]; + const channelsArgs = actionArgs.slice(1); + if (channelsSub === "list") { + if (hasHelpFlag(channelsArgs)) return { kind: "help", usage: "channels list" }; + return { kind: "oclif", commandId: "sandbox:channels:list", args: [sandboxName, ...channelsArgs] }; + } + if (!channelsSub) return { kind: "oclif", commandId: "sandbox:channels:list", args: [sandboxName] }; + if (channelsSub === "--help" || channelsSub === "-h") return { kind: "help", usage: "channels list" }; + if (["add", "remove", "stop", "start"].includes(channelsSub)) { + if (hasHelpFlag(channelsArgs)) return { kind: "help", usage: `channels ${channelsSub} [--dry-run]` }; + return { kind: "oclif", commandId: `sandbox:channels:${channelsSub}`, args: [sandboxName, ...channelsArgs] }; + } + return { kind: "unknownSubcommand", command: "channels", subcommand: channelsSub }; + } + case "config": { + const configSub = actionArgs[0]; + if (configSub === "get") { + if (hasHelpFlag(actionArgs.slice(1))) return { kind: "help", usage: "config get [--key dotpath] [--format json|yaml]" }; + return { kind: "oclif", commandId: "sandbox:config:get", args: [sandboxName, ...actionArgs.slice(1)] }; + } + if (configSub === "--help" || configSub === "-h") return { kind: "help", usage: "config get [--key dotpath] [--format json|yaml]" }; + return { kind: "usageError", lines: ["config get [--key dotpath] [--format json|yaml]"] }; + } + default: + return { kind: "unknownAction", action }; + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 692098ee6a6..009bb04e78b 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -99,6 +99,11 @@ import { OPENSHELL_OPERATION_TIMEOUT_MS, OPENSHELL_PROBE_TIMEOUT_MS, } from "./lib/openshell-timeouts"; +import { + resolveGlobalOclifDispatch, + resolveSandboxOclifDispatch, + type DispatchResult, +} from "./lib/legacy-oclif-dispatch"; const onboardProviders = require("./lib/onboard-providers"); // ── Global commands (derived from command registry) ────────────── @@ -1295,19 +1300,10 @@ async function listSandboxes(args: string[] = []): Promise { await runOclif("list", args); } -function hasHelpFlag(args: string[]): boolean { - return args.includes("--help") || args.includes("-h"); -} - function printSandboxActionUsage(action: string): void { console.log(` Usage: ${CLI_NAME} ${action}`); } -function hasMissingFlagValue(args: string[], flagName: string): boolean { - const index = args.indexOf(flagName); - return index !== -1 && (!args[index + 1] || args[index + 1].startsWith("--")); -} - // ── Sandbox-scoped actions ─────────────────────────────────────── type SandboxConnectOptions = { @@ -4765,6 +4761,85 @@ function printConnectOrderHint(candidate: string | null): void { } } +const VALID_SANDBOX_ACTIONS = + "connect, status, doctor, logs, policy-add, policy-remove, policy-list, skill, snapshot, share, rebuild, shields, config, channels, gateway-token, destroy"; + +function printDispatchUsageError( + result: Extract, + sandboxName?: string, +): never { + if (result.lines.length === 0) { + help(); + process.exit(1); + } + + const [usage, ...details] = result.lines; + console.error(` Usage: ${CLI_NAME} ${sandboxName ? `${sandboxName} ` : ""}${usage}`); + for (const line of details) { + console.error(` ${line}`); + } + process.exit(1); +} + +async function runDispatchResult( + result: DispatchResult, + opts: { sandboxName?: string; actionArgs?: string[] } = {}, +): Promise { + switch (result.kind) { + case "oclif": + await runOclif(result.commandId, result.args); + return; + case "help": + printSandboxActionUsage(result.usage); + return; + case "usageError": + printDispatchUsageError(result, opts.sandboxName); + case "unknownSubcommand": + if (result.command === "credentials") { + console.error(` Unknown credentials subcommand: ${result.subcommand}`); + console.error(` Run '${CLI_NAME} credentials help' for usage.`); + } else { + console.error(` Unknown channels subcommand: ${result.subcommand}`); + console.error( + ` Usage: ${CLI_NAME} channels [args]`, + ); + console.error(" list List supported messaging channels"); + console.error(" add Store credentials and rebuild the sandbox"); + console.error(" remove Clear credentials and rebuild the sandbox"); + console.error(" stop Disable channel without wiping credentials"); + console.error(" start Re-enable a previously stopped channel"); + } + process.exit(1); + case "unknownAction": + console.error(` Unknown action: ${result.action}`); + console.error(` Valid actions: ${VALID_SANDBOX_ACTIONS}`); + process.exit(1); + case "legacy": { + const sandboxName = opts.sandboxName; + const actionArgs = opts.actionArgs ?? []; + if (!sandboxName) { + throw new Error(`Missing sandbox name for legacy dispatch target ${result.target}`); + } + switch (result.target) { + case "doctor": + await sandboxDoctor(sandboxName, actionArgs); + return; + case "policy-add": + await sandboxPolicyAdd(sandboxName, actionArgs); + return; + case "skill": + await sandboxSkillInstall(sandboxName, actionArgs); + return; + case "snapshot": + await sandboxSnapshot(sandboxName, actionArgs); + return; + default: + throw new Error(`Unhandled legacy dispatch target ${result.target}`); + } + } + } +} + // ── Dispatch ───────────────────────────────────────────────────── const [cmd, ...args] = process.argv.slice(2); @@ -4785,60 +4860,7 @@ const mainPromise = (async () => { // Global commands if (GLOBAL_COMMANDS.has(cmd)) { - switch (cmd) { - case "onboard": - await runOclif("onboard", args); - break; - case "setup": - await runOclif("setup", args); - break; - case "setup-spark": - await runOclif("setup-spark", args); - break; - case "deploy": - await runOclif("deploy", args); - break; - case "start": - await start(args); - break; - case "stop": - await stop(args); - break; - case "tunnel": - await tunnel(args); - break; - case "status": - await showStatus(args); - break; - case "debug": - await debug(args); - break; - case "uninstall": - await uninstall(args); - break; - case "credentials": - await credentialsCommand(args); - break; - case "list": - await listSandboxes(args); - break; - case "backup-all": - await runOclif("backup-all", args); - break; - case "upgrade-sandboxes": - await runOclif("upgrade-sandboxes", args); - break; - case "gc": - await runOclif("gc", args); - break; - case "--version": - case "-v": - await runOclif("root:version", []); - break; - default: - help(); - break; - } + await runDispatchResult(resolveGlobalOclifDispatch(cmd, args)); return; } @@ -4906,260 +4928,13 @@ const mainPromise = (async () => { validateName(cmd, "sandbox name"); const action = requestedSandboxAction; const actionArgs = requestedSandboxActionArgs; - - switch (action) { - case "connect": - parseSandboxConnectArgs(cmd, actionArgs); - await runOclif("sandbox:connect", [cmd, ...actionArgs]); - break; - case "status": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage("status"); - break; - } - await runOclif("sandbox:status", [cmd, ...actionArgs]); - break; - case "doctor": - await sandboxDoctor(cmd, actionArgs); - break; - case "logs": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage("logs [--follow]"); - break; - } - await runOclif("sandbox:logs", [cmd, ...actionArgs]); - break; - case "policy-add": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage( - "policy-add [preset] [--yes|-y] [--dry-run] [--from-file ] [--from-dir ]", - ); - break; - } - if ( - hasMissingFlagValue(actionArgs, "--from-file") || - hasMissingFlagValue(actionArgs, "--from-dir") - ) { - await sandboxPolicyAdd(cmd, actionArgs); - break; - } - await runOclif("sandbox:policy-add", [cmd, ...actionArgs]); - break; - case "policy-remove": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage("policy-remove [preset] [--yes|-y] [--dry-run]"); - break; - } - await runOclif("sandbox:policy-remove", [cmd, ...actionArgs]); - break; - case "policy-list": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage("policy-list"); - break; - } - await runOclif("sandbox:policy-list", [cmd, ...actionArgs]); - break; - case "destroy": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage("destroy [--yes|--force]"); - break; - } - await runOclif("sandbox:destroy", [cmd, ...actionArgs]); - break; - case "gateway-token": - if (actionArgs.includes("--help") || actionArgs.includes("-h")) { - console.log(` Usage: ${CLI_NAME} gateway-token [--quiet|-q]`); - break; - } - await runOclif("sandbox:gateway-token", [cmd, ...actionArgs]); - break; - case "skill": { - const skillSub = actionArgs[0]; - const skillArgs = actionArgs.slice(1); - if (!skillSub || skillSub === "help" || skillSub === "--help" || skillSub === "-h") { - await sandboxSkillInstall(cmd, actionArgs); - } else if (skillSub === "install") { - if (hasHelpFlag(skillArgs)) { - await sandboxSkillInstall(cmd, actionArgs); - } else { - await runOclif("sandbox:skill:install", [cmd, ...skillArgs]); - } - } else { - await sandboxSkillInstall(cmd, actionArgs); - } - break; - } - case "rebuild": - if (hasHelpFlag(actionArgs)) { - printSandboxActionUsage("rebuild [--yes|--force] [--verbose|-v]"); - break; - } - await runOclif("sandbox:rebuild", [cmd, ...actionArgs]); - break; - case "snapshot": { - const snapshotSub = actionArgs[0]; - const snapshotArgs = actionArgs.slice(1); - switch (snapshotSub) { - case "list": - if (hasHelpFlag(snapshotArgs)) { - printSandboxActionUsage("snapshot list"); - break; - } - await runOclif("sandbox:snapshot:list", [cmd, ...snapshotArgs]); - break; - case "create": - if (hasHelpFlag(snapshotArgs)) { - printSandboxActionUsage("snapshot create [--name ]"); - break; - } - await runOclif("sandbox:snapshot:create", [cmd, ...snapshotArgs]); - break; - case "restore": - if (hasHelpFlag(snapshotArgs)) { - printSandboxActionUsage("snapshot restore [selector] [--to ]"); - break; - } - await runOclif("sandbox:snapshot:restore", [cmd, ...snapshotArgs]); - break; - default: - await sandboxSnapshot(cmd, actionArgs); - break; - } - break; - } - case "share": - await runRegisteredOclifCommand("share", [cmd, ...actionArgs], { - rootDir: ROOT, - error: console.error, - exit: (code: number) => process.exit(code), - }); - break; - case "shields": { - const shieldsSub = actionArgs[0]; - const shieldsArgs = actionArgs.slice(1); - switch (shieldsSub) { - case "down": - if (hasHelpFlag(shieldsArgs)) { - printSandboxActionUsage( - "shields down [--timeout 5m] [--reason 'text'] [--policy permissive]", - ); - break; - } - await runOclif("sandbox:shields:down", [cmd, ...shieldsArgs]); - break; - case "up": - if (hasHelpFlag(shieldsArgs)) { - printSandboxActionUsage("shields up"); - break; - } - await runOclif("sandbox:shields:up", [cmd, ...shieldsArgs]); - break; - case "status": - if (hasHelpFlag(shieldsArgs)) { - printSandboxActionUsage("shields status"); - break; - } - await runOclif("sandbox:shields:status", [cmd, ...shieldsArgs]); - break; - default: - console.error(` Usage: ${CLI_NAME} shields `); - console.error(" down [--timeout 5m] [--reason 'text'] [--policy permissive]"); - console.error(" up Restore policy from snapshot"); - console.error(" status Show current shields state"); - process.exit(1); - } - break; - } - case "channels": { - const channelsSub = actionArgs[0]; - const channelsArgs = actionArgs.slice(1); - switch (channelsSub) { - case "list": - if (hasHelpFlag(channelsArgs)) { - printSandboxActionUsage("channels list"); - break; - } - await runOclif("sandbox:channels:list", [cmd, ...channelsArgs]); - break; - case undefined: - case "": - await runOclif("sandbox:channels:list", [cmd]); - break; - case "add": - if (hasHelpFlag(channelsArgs)) { - printSandboxActionUsage("channels add [--dry-run]"); - break; - } - await runOclif("sandbox:channels:add", [cmd, ...channelsArgs]); - break; - case "remove": - if (hasHelpFlag(channelsArgs)) { - printSandboxActionUsage("channels remove [--dry-run]"); - break; - } - await runOclif("sandbox:channels:remove", [cmd, ...channelsArgs]); - break; - case "stop": - if (hasHelpFlag(channelsArgs)) { - printSandboxActionUsage("channels stop [--dry-run]"); - break; - } - await runOclif("sandbox:channels:stop", [cmd, ...channelsArgs]); - break; - case "start": - if (hasHelpFlag(channelsArgs)) { - printSandboxActionUsage("channels start [--dry-run]"); - break; - } - await runOclif("sandbox:channels:start", [cmd, ...channelsArgs]); - break; - case "--help": - case "-h": - printSandboxActionUsage("channels list"); - break; - default: - console.error(` Unknown channels subcommand: ${channelsSub}`); - console.error( - ` Usage: ${CLI_NAME} channels [args]`, - ); - console.error(" list List supported messaging channels"); - console.error(" add Store credentials and rebuild the sandbox"); - console.error(" remove Clear credentials and rebuild the sandbox"); - console.error(" stop Disable channel without wiping credentials"); - console.error(" start Re-enable a previously stopped channel"); - process.exit(1); - } - break; - } - case "config": { - const configSub = actionArgs[0]; - switch (configSub) { - case "get": - if (hasHelpFlag(actionArgs.slice(1))) { - printSandboxActionUsage("config get [--key dotpath] [--format json|yaml]"); - break; - } - await runOclif("sandbox:config:get", [cmd, ...actionArgs.slice(1)]); - break; - case "--help": - case "-h": - printSandboxActionUsage("config get [--key dotpath] [--format json|yaml]"); - break; - default: - console.error( - ` Usage: ${CLI_NAME} config get [--key dotpath] [--format json|yaml]`, - ); - process.exit(1); - } - break; - } - default: - console.error(` Unknown action: ${action}`); - console.error( - ` Valid actions: connect, status, doctor, logs, policy-add, policy-remove, policy-list, skill, snapshot, share, rebuild, shields, config, channels, gateway-token, destroy`, - ); - process.exit(1); + if (action === "connect") { + parseSandboxConnectArgs(cmd, actionArgs); } + await runDispatchResult(resolveSandboxOclifDispatch(cmd, action, actionArgs), { + sandboxName: cmd, + actionArgs, + }); return; } diff --git a/test/image-cleanup.test.ts b/test/image-cleanup.test.ts index 03fc1a8ad1d..b2e2a2a1900 100644 --- a/test/image-cleanup.test.ts +++ b/test/image-cleanup.test.ts @@ -118,9 +118,9 @@ describe("image cleanup: gc command exists (#2086)", () => { expect(nemoclawSrc).toContain("globalCommandTokens()"); }); - it("gc command is dispatched in the CLI switch", () => { - expect(nemoclawSrc).toContain('case "gc"'); - expect(nemoclawSrc).toContain("garbageCollectImages"); + it("gc command is dispatched through the oclif bridge", () => { + expect(nemoclawSrc).toContain("resolveGlobalOclifDispatch"); + expect(registrySrc).toContain('"nemoclaw gc"'); }); it("garbageCollectImages lists sandbox-from images and cross-references registry", () => {