From 9d463a6350561fff1822fee4184b10b952bcf422 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Fri, 20 Mar 2026 10:15:51 +0530 Subject: [PATCH 1/5] feat: add sandbox export/import backup commands and shell completion --- bin/lib/backup.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 bin/lib/backup.js diff --git a/bin/lib/backup.js b/bin/lib/backup.js new file mode 100644 index 00000000000..40e1e0aaf27 --- /dev/null +++ b/bin/lib/backup.js @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Sandbox backup and restore functionality + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); +const registry = require("./registry"); + +const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); + +function ensureBackupDir() { + fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 }); +} + +function listBackups() { + ensureBackupDir(); + const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith(".json")); + const backups = []; + + for (const file of files) { + try { + const content = JSON.parse(fs.readFileSync(path.join(BACKUP_DIR, file), "utf-8")); + backups.push({ + name: content.metadata.name, + createdAt: content.metadata.createdAt, + path: path.join(BACKUP_DIR, file), + size: fs.statSync(path.join(BACKUP_DIR, file)).size, + }); + } catch {} + } + + return backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); +} + +function exportSandbox(sandboxName, outputPath) { + const sandbox = registry.getSandbox(sandboxName); + if (!sandbox) { + console.error(` Sandbox not found: ${sandboxName}`); + return null; + } + + ensureBackupDir(); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const defaultName = `${sandboxName}-${timestamp}.json`; + const backupPath = outputPath || path.join(BACKUP_DIR, defaultName); + + let policyContent = ""; + try { + policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, { + encoding: "utf-8", + timeout: 10000, + }); + } catch { + policyContent = ""; + } + + const backup = { + version: "1.0", + metadata: { + name: sandboxName, + createdAt: new Date().toISOString(), + nemoclawVersion: require("../package.json").version, + }, + sandbox: { + name: sandbox.name, + model: sandbox.model, + provider: sandbox.provider, + gpuEnabled: sandbox.gpuEnabled, + policies: sandbox.policies || [], + }, + policy: policyContent, + }; + + const dir = path.dirname(backupPath); + if (dir !== BACKUP_DIR) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2), { mode: 0o600 }); + console.log(` Exported sandbox '${sandboxName}' to: ${backupPath}`); + + return backupPath; +} + +function importSandbox(backupPath, newName) { + if (!fs.existsSync(backupPath)) { + console.error(` Backup file not found: ${backupPath}`); + return false; + } + + let backup; + try { + backup = JSON.parse(fs.readFileSync(backupPath, "utf-8")); + } catch { + console.error(` Invalid backup file: ${backupPath}`); + return false; + } + + if (!backup.version || !backup.sandbox) { + console.error(" Invalid backup format"); + return false; + } + + const sandboxName = newName || backup.sandbox.name; + + console.log(` Creating sandbox '${sandboxName}' from backup...`); + + try { + execSync(`openshell sandbox exists ${sandboxName} 2>/dev/null`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + console.error( + ` Sandbox '${sandboxName}' already exists. Use a different name or delete it first.`, + ); + return false; + } catch {} + + console.log(` Note: This only imports the registry config.`); + console.log(` You need to manually recreate the sandbox and apply policies.`); + + registry.registerSandbox({ + name: sandboxName, + model: backup.sandbox.model, + provider: backup.sandbox.provider, + gpuEnabled: backup.sandbox.gpuEnabled, + policies: backup.sandbox.policies || [], + }); + + if (backup.policy) { + const policyPath = path.join(os.tmpdir(), `nemoclaw-restore-${Date.now()}.yaml`); + fs.writeFileSync(policyPath, backup.policy); + try { + execSync(`openshell policy set --policy "${policyPath}" --wait ${sandboxName}`, { + encoding: "utf-8", + timeout: 30000, + }); + console.log(` Restored policy for '${sandboxName}'`); + } catch (err) { + console.warn(` Warning: Could not restore policy: ${err.message}`); + } finally { + fs.unlinkSync(policyPath); + } + } + + console.log(` Imported sandbox '${sandboxName}' from backup`); + return true; +} + +function deleteBackup(backupPath) { + if (!fs.existsSync(backupPath)) { + console.error(` Backup not found: ${backupPath}`); + return false; + } + fs.unlinkSync(backupPath); + console.log(` Deleted backup: ${backupPath}`); + return true; +} + +module.exports = { + BACKUP_DIR, + listBackups, + exportSandbox, + importSandbox, + deleteBackup, +}; From 094af6fe4260fcae48f0b6656dab77080ac510e7 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Sun, 22 Mar 2026 19:40:28 +0530 Subject: [PATCH 2/5] Add docstrings to improve coverage for CodeRabbit pre-merge checks --- bin/lib/backup.js | 24 ++ bin/nemoclaw.js | 653 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 675 insertions(+), 2 deletions(-) diff --git a/bin/lib/backup.js b/bin/lib/backup.js index 40e1e0aaf27..18d5aa53836 100644 --- a/bin/lib/backup.js +++ b/bin/lib/backup.js @@ -11,10 +11,17 @@ const registry = require("./registry"); const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); +/** + * Ensures the backup directory exists with appropriate permissions. + */ function ensureBackupDir() { fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 }); } +/** + * Lists all sandbox backups in the backup directory. + * @returns {Array<{name: string, createdAt: string, path: string, size: number}>} + */ function listBackups() { ensureBackupDir(); const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith(".json")); @@ -35,6 +42,12 @@ function listBackups() { return backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } +/** + * Exports a sandbox to a backup file. + * @param {string} sandboxName - Name of the sandbox to export. + * @param {string} [outputPath] - Optional output path for the backup file. + * @returns {string|null} Path to the created backup file, or null if sandbox not found. + */ function exportSandbox(sandboxName, outputPath) { const sandbox = registry.getSandbox(sandboxName); if (!sandbox) { @@ -86,6 +99,12 @@ function exportSandbox(sandboxName, outputPath) { return backupPath; } +/** + * Imports a sandbox from a backup file. + * @param {string} backupPath - Path to the backup file. + * @param {string} [newName] - Optional new name for the imported sandbox. + * @returns {boolean} True if import succeeded, false otherwise. + */ function importSandbox(backupPath, newName) { if (!fs.existsSync(backupPath)) { console.error(` Backup file not found: ${backupPath}`); @@ -151,6 +170,11 @@ function importSandbox(backupPath, newName) { return true; } +/** + * Deletes a backup file. + * @param {string} backupPath - Path to the backup file to delete. + * @returns {boolean} True if deletion succeeded, false otherwise. + */ function deleteBackup(backupPath) { if (!fs.existsSync(backupPath)) { console.error(` Backup not found: ${backupPath}`); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index ebdf7404d25..ba853e8c946 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -1,6 +1,655 @@ #!/usr/bin/env node -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -require("../dist/nemoclaw"); +const { execFileSync, spawnSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +// --------------------------------------------------------------------------- +// Color / style — respects NO_COLOR and non-TTY environments. +// Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. +// --------------------------------------------------------------------------- +const _useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; +const _tc = _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); +const G = _useColor ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : ""; +const B = _useColor ? "\x1b[1m" : ""; +const D = _useColor ? "\x1b[2m" : ""; +const R = _useColor ? "\x1b[0m" : ""; +const RD = _useColor ? "\x1b[1;31m" : ""; +const YW = _useColor ? "\x1b[1;33m" : ""; + +const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); +const { + ensureApiKey, + ensureGithubToken, + getCredential, + isRepoPrivate, +} = require("./lib/credentials"); +const registry = require("./lib/registry"); +const nim = require("./lib/nim"); +const policies = require("./lib/policies"); +const backup = require("./lib/backup"); + +// ── Global commands ────────────────────────────────────────────── + +const GLOBAL_COMMANDS = new Set([ + "onboard", "list", "deploy", "setup", "setup-spark", + "start", "stop", "status", "debug", "uninstall", + "backups", + "completion", + "help", "--help", "-h", "--version", "-v", +]); + +const SANDBOX_ACTIONS = [ + "connect", "status", "logs", "policy-add", "policy-list", "destroy", "export" +]; + +const SHELL_TYPES = ["bash", "zsh", "fish"]; + +const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; + +function resolveUninstallScript() { + const candidates = [ + path.join(ROOT, "uninstall.sh"), + path.join(__dirname, "..", "uninstall.sh"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function exitWithSpawnResult(result) { + if (result.status !== null) { + process.exit(result.status); + } + + if (result.signal) { + const signalNumber = os.constants.signals[result.signal]; + process.exit(signalNumber ? 128 + signalNumber : 1); + } + + process.exit(1); +} + +// ── Commands ───────────────────────────────────────────────────── + +async function onboard(args) { + const { onboard: runOnboard } = require("./lib/onboard"); + const allowedArgs = new Set(["--non-interactive"]); + const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); + if (unknownArgs.length > 0) { + console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); + console.error(" Usage: nemoclaw onboard [--non-interactive]"); + process.exit(1); + } + const nonInteractive = args.includes("--non-interactive"); + await runOnboard({ nonInteractive }); +} + +async function setup() { + console.log(""); + console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); + console.log(" Running legacy setup.sh for backwards compatibility..."); + console.log(""); + await ensureApiKey(); + const { defaultSandbox } = registry.listSandboxes(); + const safeName = defaultSandbox && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(defaultSandbox) ? defaultSandbox : ""; + run(`bash "${SCRIPTS}/setup.sh" ${shellQuote(safeName)}`); +} + +async function setupSpark() { + await ensureApiKey(); + run(`sudo -E NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)} bash "${SCRIPTS}/setup-spark.sh"`); +} + +async function deploy(instanceName) { + if (!instanceName) { + console.error(" Usage: nemoclaw deploy "); + console.error(""); + console.error(" Examples:"); + console.error(" nemoclaw deploy my-gpu-box"); + console.error(" nemoclaw deploy nemoclaw-prod"); + console.error(" nemoclaw deploy nemoclaw-test"); + process.exit(1); + } + await ensureApiKey(); + if (isRepoPrivate("NVIDIA/OpenShell")) { + await ensureGithubToken(); + } + validateName(instanceName, "instance name"); + const name = instanceName; + const qname = shellQuote(name); + const gpu = process.env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; + + console.log(""); + console.log(` Deploying NemoClaw to Brev instance: ${name}`); + console.log(""); + + try { + execFileSync("which", ["brev"], { stdio: "ignore" }); + } catch { + console.error("brev CLI not found. Install: https://brev.nvidia.com"); + process.exit(1); + } + + let exists = false; + try { + const out = execFileSync("brev", ["ls"], { encoding: "utf-8" }); + exists = out.includes(name); + } catch (err) { + if (err.stdout && err.stdout.includes(name)) exists = true; + if (err.stderr && err.stderr.includes(name)) exists = true; + } + + if (!exists) { + console.log(` Creating Brev instance '${name}' (${gpu})...`); + run(`brev create ${qname} --gpu ${shellQuote(gpu)}`); + } else { + console.log(` Brev instance '${name}' already exists.`); + } + + run(`brev refresh`, { ignoreError: true }); + + process.stdout.write(` Waiting for SSH `); + for (let i = 0; i < 60; i++) { + try { + execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" }); + process.stdout.write(` ${G}✓${R}\n`); + break; + } catch { + if (i === 59) { + process.stdout.write("\n"); + console.error(` Timed out waiting for SSH to ${name}`); + process.exit(1); + } + process.stdout.write("."); + spawnSync("sleep", ["3"]); + } + } + + console.log(" Syncing NemoClaw to VM..."); + run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`); + run(`rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`); + + const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`]; + const ghToken = process.env.GITHUB_TOKEN; + if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`); + const tgToken = getCredential("TELEGRAM_BOT_TOKEN"); + if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`); + const discordToken = getCredential("DISCORD_BOT_TOKEN"); + if (discordToken) envLines.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`); + const slackToken = getCredential("SLACK_BOT_TOKEN"); + if (slackToken) envLines.push(`SLACK_BOT_TOKEN=${shellQuote(slackToken)}`); + const envDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-")); + const envTmp = path.join(envDir, "env"); + fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); + try { + run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`); + } finally { + try { fs.unlinkSync(envTmp); } catch {} + try { fs.rmdirSync(envDir); } catch {} + } + + console.log(" Running setup..."); + runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); + + if (tgToken) { + console.log(" Starting services..."); + run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`); + } + + console.log(""); + console.log(" Connecting to sandbox..."); + console.log(""); + runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); +} + +async function start() { + await ensureApiKey(); + const { defaultSandbox } = registry.listSandboxes(); + const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; + const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; + run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); +} + +function stop() { + run(`bash "${SCRIPTS}/start-services.sh" --stop`); +} + +function debug(args) { + const result = spawnSync("bash", [path.join(SCRIPTS, "debug.sh"), ...args], { + stdio: "inherit", + cwd: ROOT, + env: { + ...process.env, + SANDBOX_NAME: registry.listSandboxes().defaultSandbox || "", + }, + }); + exitWithSpawnResult(result); +} + +function uninstall(args) { + const localScript = resolveUninstallScript(); + if (localScript) { + console.log(` Running local uninstall script: ${localScript}`); + const result = spawnSync("bash", [localScript, ...args], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + exitWithSpawnResult(result); + } + + console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`); + const forwardedArgs = args.map(shellQuote).join(" "); + const command = forwardedArgs.length > 0 + ? `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash -s -- ${forwardedArgs}` + : `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash`; + const result = spawnSync("bash", ["-c", command], { + stdio: "inherit", + cwd: ROOT, + env: process.env, + }); + exitWithSpawnResult(result); +} + +function showStatus() { + // Show sandbox registry + const { sandboxes, defaultSandbox } = registry.listSandboxes(); + if (sandboxes.length > 0) { + console.log(""); + console.log(" Sandboxes:"); + for (const sb of sandboxes) { + const def = sb.name === defaultSandbox ? " *" : ""; + const model = sb.model ? ` (${sb.model})` : ""; + console.log(` ${sb.name}${def}${model}`); + } + console.log(""); + } + + // Show service status + run(`bash "${SCRIPTS}/start-services.sh" --status`); +} + +function listSandboxes() { + const { sandboxes, defaultSandbox } = registry.listSandboxes(); + if (sandboxes.length === 0) { + console.log(""); + console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + console.log(""); + return; + } + + console.log(""); + console.log(" Sandboxes:"); + for (const sb of sandboxes) { + const def = sb.name === defaultSandbox ? " *" : ""; + const model = sb.model || "unknown"; + const provider = sb.provider || "unknown"; + const gpu = sb.gpuEnabled ? "GPU" : "CPU"; + const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none"; + console.log(` ${sb.name}${def}`); + console.log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`); + } + console.log(""); + console.log(" * = default sandbox"); + console.log(""); +} + +// ── Sandbox-scoped actions ─────────────────────────────────────── + +function sandboxConnect(sandboxName) { + const qn = shellQuote(sandboxName); + // Ensure port forward is alive before connecting + run(`openshell forward start --background 18789 ${qn} 2>/dev/null || true`, { ignoreError: true }); + runInteractive(`openshell sandbox connect ${qn}`); +} + +function sandboxStatus(sandboxName) { + const sb = registry.getSandbox(sandboxName); + if (sb) { + console.log(""); + console.log(` Sandbox: ${sb.name}`); + console.log(` Model: ${sb.model || "unknown"}`); + console.log(` Provider: ${sb.provider || "unknown"}`); + console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`); + console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); + } + + // openshell info + run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + + // NIM health + const nimStat = nim.nimStatus(sandboxName); + console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); + if (nimStat.running) { + console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); + } + console.log(""); +} + +function sandboxLogs(sandboxName, follow) { + const followFlag = follow ? " --tail" : ""; + run(`openshell logs ${shellQuote(sandboxName)}${followFlag}`); +} + +async function sandboxPolicyAdd(sandboxName) { + const allPresets = policies.listPresets(); + const applied = policies.getAppliedPresets(sandboxName); + + console.log(""); + console.log(" Available presets:"); + allPresets.forEach((p) => { + const marker = applied.includes(p.name) ? "●" : "○"; + console.log(` ${marker} ${p.name} — ${p.description}`); + }); + console.log(""); + + const { prompt: askPrompt } = require("./lib/credentials"); + const answer = await askPrompt(" Preset to apply: "); + if (!answer) return; + + const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); + if (confirm.toLowerCase() === "n") return; + + policies.applyPreset(sandboxName, answer); +} + +function sandboxPolicyList(sandboxName) { + const allPresets = policies.listPresets(); + const applied = policies.getAppliedPresets(sandboxName); + + console.log(""); + console.log(` Policy presets for sandbox '${sandboxName}':`); + allPresets.forEach((p) => { + const marker = applied.includes(p.name) ? "●" : "○"; + console.log(` ${marker} ${p.name} — ${p.description}`); + }); + console.log(""); +} + +async function sandboxDestroy(sandboxName, args = []) { + const skipConfirm = args.includes("--yes") || args.includes("--force"); + if (!skipConfirm) { + const { prompt: askPrompt } = require("./lib/credentials"); + const answer = await askPrompt( + ` ${YW}Destroy sandbox '${sandboxName}'?${R} This cannot be undone. [y/N]: `, + ); + if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { + console.log(" Cancelled."); + return; + } + } + + console.log(` Stopping NIM for '${sandboxName}'...`); + nim.stopNimContainer(sandboxName); + + console.log(` Deleting sandbox '${sandboxName}'...`); + run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); + + registry.removeSandbox(sandboxName); + console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); +} + +/** + * Exports a sandbox to a backup file. + * @param {string} sandboxName - Name of the sandbox to export. + * @param {string} [exportPath] - Optional output path for the backup. + */ +function sandboxExport(sandboxName, exportPath) { + backup.exportSandbox(sandboxName, exportPath); +} + +/** + * Lists all sandbox backups. + */ +function listBackups() { + backup.listBackups(); +} + +/** + * Imports a sandbox from a backup file. + * @param {string} backupPath - Path to the backup file. + * @param {string} [newName] - Optional new name for the sandbox. + */ +async function importBackup(backupPath, newName) { + await backup.importSandbox(backupPath, newName); +} + +// ── Shell Completion ───────────────────────────────────────────── + +/** + * Prints shell completion script for the specified shell. + * @param {string} shell - Shell type (bash, zsh, or fish). + */ +function printCompletion(shell) { + if (!shell || !SHELL_TYPES.includes(shell)) { + console.log(" Usage: nemoclaw completion "); + console.log(""); + console.log(" Generate shell completion scripts."); + console.log(""); + console.log(" Shells supported: bash, zsh, fish"); + console.log(""); + console.log(" Example:"); + console.log(" # Bash:"); + console.log(" nemoclaw completion bash >> ~/.bashrc"); + console.log(""); + console.log(" # Zsh:"); + console.log(" nemoclaw completion zsh >> ~/.zshrc"); + console.log(""); + console.log(" # Fish:"); + console.log(" nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish"); + return; + } + + const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")); + let sandboxNames = []; + try { + sandboxNames = registry.listSandboxes().sandboxes.map(s => s.name); + } catch { + sandboxNames = []; + } + + const sandboxNamesStr = sandboxNames.length > 0 ? sandboxNames.join(" ") : ""; + const sandboxNamesZsh = sandboxNames.length > 0 ? sandboxNames.map(s => `"${s}"`).join(" ") : ""; + const sandboxNamesFish = sandboxNames.length > 0 ? sandboxNames.map(s => `'${s}'`).join(" ") : ""; + + if (shell === "bash") { + console.log(`_nemoclaw_completions() { + local cur prev opts + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + + # Global commands + opts="${globalCmds.join(" ")}" + + # Sandbox names (if previous word is a known sandbox) + if [[ " ${sandboxNamesStr} " =~ " $prev " ]]; then + opts="${SANDBOX_ACTIONS.join(" ")}" + fi + + # Also add sandbox names as possible first argument + opts="$opts ${sandboxNamesStr}" + + COMPREPLY=(\$(compgen -W "\$opts" -- \$cur)) + return 0 +} + +complete -F _nemoclaw_completions nemoclaw`); + } else if (shell === "zsh") { + console.log(`# nemoclaw zsh completion + +local -a global_cmds +global_cmds=(${globalCmds.map(c => `"${c}"`).join(" ")}) + +local -a sandbox_actions +sandbox_actions=(${SANDBOX_ACTIONS.map(a => `"${a}"`).join(" ")}) + +local -a sandbox_names +sandbox_names=(${sandboxNamesZsh}) + +_nemoclaw() { + local -a cmd + cmd=(\${words[1,CURRENT-1]}) + + # Check if first word is a sandbox name + if [[ " \${sandbox_names[@]} " =~ " \${cmd[1]} " ]]; then + _describe 'sandbox actions' sandbox_actions + else + _describe 'commands' global_cmds + _describe 'sandboxes' sandbox_names + fi +} + +compdef _nemoclaw nemoclaw`); + } else if (shell === "fish") { + console.log(`# nemoclaw fish completion + +complete -c nemoclaw -f -a "${globalCmds.join(" ")} ${sandboxNamesStr}" -n "test (count (commandline -opc)) -eq 1" + +complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains (commandline -opc | head -1) ${sandboxNamesFish}" +`); + } +} + +// ── Help ───────────────────────────────────────────────────────── + +function help() { + const pkg = require(path.join(__dirname, "..", "package.json")); + console.log(` + ${B}${G}NemoClaw${R} ${D}v${pkg.version}${R} + ${D}Deploy more secure, always-on AI assistants with a single command.${R} + + ${G}Getting Started:${R} + ${B}nemoclaw onboard${R} Configure inference endpoint and credentials + nemoclaw setup-spark Set up on DGX Spark ${D}(fixes cgroup v2 + Docker)${R} + + ${G}Sandbox Management:${R} + ${B}nemoclaw list${R} List all sandboxes + nemoclaw connect Shell into a running sandbox + nemoclaw status Sandbox health + NIM status + nemoclaw logs ${D}[--follow]${R} Stream sandbox logs + nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} + nemoclaw export ${D}[path]${R} Export sandbox to backup file + + ${G}Policy Presets:${R} + nemoclaw policy-add Add a network or filesystem policy preset + nemoclaw policy-list List presets ${D}(● = applied)${R} + + ${G}Deploy:${R} + nemoclaw deploy Deploy to a Brev VM and start services + + ${G}Services:${R} + nemoclaw start Start auxiliary services ${D}(Telegram, tunnel)${R} + nemoclaw stop Stop all services + nemoclaw status Show sandbox list and service status + + Troubleshooting: + nemoclaw debug [--quick] Collect diagnostics for bug reports + nemoclaw debug --output FILE Save diagnostics tarball for GitHub issues + + Cleanup: + nemoclaw uninstall [flags] Run uninstall.sh (local first, curl fallback) + + ${G}Uninstall flags:${R} + --yes Skip the confirmation prompt + --keep-openshell Leave the openshell binary installed + --delete-models Remove NemoClaw-pulled Ollama models + + ${G}Backup & Restore:${R} + nemoclaw backups List all backups + nemoclaw import ${D} [name]${R} Import sandbox from backup file + + Shell Completion: + nemoclaw completion ${D}${R} Generate shell completion script + + ${D}Powered by NVIDIA OpenShell · Nemotron · Agent Toolkit + Credentials saved in ~/.nemoclaw/credentials.json (mode 600)${R} + ${D}https://www.nvidia.com/nemoclaw${R} +`); +} + +// ── Dispatch ───────────────────────────────────────────────────── + +const [cmd, ...args] = process.argv.slice(2); + +(async () => { + // No command → help + if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { + help(); + return; + } + + // Global commands + if (GLOBAL_COMMANDS.has(cmd)) { + switch (cmd) { + case "onboard": await onboard(args); break; + case "setup": await setup(); break; + case "setup-spark": await setupSpark(); break; + case "deploy": await deploy(args[0]); break; + case "start": await start(); break; + case "stop": stop(); break; + case "status": showStatus(); break; + case "debug": debug(args); break; + case "uninstall": uninstall(args); break; + case "list": listSandboxes(); break; + case "backups": listBackups(); break; + case "completion": printCompletion(args[0]); break; + case "import": await importBackup(args[0], args[1]); break; + case "--version": + case "-v": { + const pkg = require(path.join(__dirname, "..", "package.json")); + console.log(`nemoclaw v${pkg.version}`); + break; + } + default: help(); break; + } + return; + } + + // Sandbox-scoped commands: nemoclaw + const sandbox = registry.getSandbox(cmd); + if (sandbox) { + validateName(cmd, "sandbox name"); + const action = args[0] || "connect"; + const actionArgs = args.slice(1); + + switch (action) { + case "connect": sandboxConnect(cmd); break; + case "status": sandboxStatus(cmd); break; + case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; + case "policy-add": await sandboxPolicyAdd(cmd); break; + case "policy-list": sandboxPolicyList(cmd); break; + case "destroy": await sandboxDestroy(cmd, actionArgs); break; + case "export": sandboxExport(cmd, actionArgs[0]); break; + default: + console.error(` Unknown action: ${action}`); + console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy, export`); + process.exit(1); + } + return; + } + + // Unknown command — suggest + console.error(` Unknown command: ${cmd}`); + console.error(""); + + // Check if it looks like a sandbox name with missing action + const allNames = registry.listSandboxes().sandboxes.map((s) => s.name); + if (allNames.length > 0) { + console.error(` Registered sandboxes: ${allNames.join(", ")}`); + console.error(` Try: nemoclaw connect`); + console.error(""); + } + + console.error(` Run 'nemoclaw help' for usage.`); + process.exit(1); +})(); From ba67be7beadfb5ac371cdb617ad760b03aa62f05 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Thu, 26 Mar 2026 11:44:30 +0530 Subject: [PATCH 3/5] fix: resolve lint and typecheck errors in backup.js and nemoclaw.js --- bin/lib/backup.js | 30 +++++++++++++++++------------- bin/nemoclaw.js | 4 ++-- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/bin/lib/backup.js b/bin/lib/backup.js index 18d5aa53836..023ab294678 100644 --- a/bin/lib/backup.js +++ b/bin/lib/backup.js @@ -8,6 +8,7 @@ const path = require("path"); const os = require("os"); const { execSync } = require("child_process"); const registry = require("./registry"); +const { ROOT } = require("./runner"); const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); @@ -24,7 +25,7 @@ function ensureBackupDir() { */ function listBackups() { ensureBackupDir(); - const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith(".json")); + const files = fs.readdirSync(BACKUP_DIR).filter(f => f.endsWith(".json")); const backups = []; for (const file of files) { @@ -36,10 +37,12 @@ function listBackups() { path: path.join(BACKUP_DIR, file), size: fs.statSync(path.join(BACKUP_DIR, file)).size, }); - } catch {} + } catch { + // Ignore malformed backup files + } } - return backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + return backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); } /** @@ -61,22 +64,23 @@ function exportSandbox(sandboxName, outputPath) { const defaultName = `${sandboxName}-${timestamp}.json`; const backupPath = outputPath || path.join(BACKUP_DIR, defaultName); - let policyContent = ""; + let _policyContent = ""; try { - policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, { + _policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, { encoding: "utf-8", - timeout: 10000, + timeout: 10000 }); } catch { - policyContent = ""; + // Keep empty string on error } + const policyContent = _policyContent; const backup = { version: "1.0", metadata: { name: sandboxName, createdAt: new Date().toISOString(), - nemoclawVersion: require("../package.json").version, + nemoclawVersion: JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")).version, }, sandbox: { name: sandbox.name, @@ -131,13 +135,13 @@ function importSandbox(backupPath, newName) { try { execSync(`openshell sandbox exists ${sandboxName} 2>/dev/null`, { encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"] }); - console.error( - ` Sandbox '${sandboxName}' already exists. Use a different name or delete it first.`, - ); + console.error(` Sandbox '${sandboxName}' already exists. Use a different name or delete it first.`); return false; - } catch {} + } catch { + // Sandbox does not exist, continue + } console.log(` Note: This only imports the registry config.`); console.log(` You need to manually recreate the sandbox and apply policies.`); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index ba853e8c946..7065eae2f14 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -450,7 +450,7 @@ function printCompletion(shell) { } const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")); - let sandboxNames = []; + let sandboxNames; try { sandboxNames = registry.listSandboxes().sandboxes.map(s => s.name); } catch { @@ -479,7 +479,7 @@ function printCompletion(shell) { # Also add sandbox names as possible first argument opts="$opts ${sandboxNamesStr}" - COMPREPLY=(\$(compgen -W "\$opts" -- \$cur)) + COMPREPLY=$(compgen -W '$opts' -- '$cur') return 0 } From b50bab5a5dca3154634d52848f4ab26e5b0c0dda Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Thu, 26 Mar 2026 12:19:36 +0530 Subject: [PATCH 4/5] fix: address all PR review comments for backup feature - Add warning logging for malformed backup files - Use spawnSync with args array to prevent command injection - Fix listBackups() to display backups to user - Fix Fish completion to check correct token [(commandline -opc)[2]] - Fix Zsh completion to check cmd[2] instead of cmd[1] - Register 'import' as global command - Propagate import failures to CLI exit code - Fix Bash completion COMPREPLY to use array syntax --- bin/lib/backup.js | 66 +++++++++++++++++++++++++++++------------------ bin/nemoclaw.js | 30 ++++++++++++++++----- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/bin/lib/backup.js b/bin/lib/backup.js index 023ab294678..39a763d8356 100644 --- a/bin/lib/backup.js +++ b/bin/lib/backup.js @@ -6,12 +6,31 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const { execSync } = require("child_process"); +const { spawnSync } = require("child_process"); const registry = require("./registry"); const { ROOT } = require("./runner"); const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); +const SANDBOX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; + +function isValidSandboxName(name) { + return SANDBOX_NAME_PATTERN.test(name); +} + +function runOpenshell(args) { + const result = spawnSync("openshell", args, { + encoding: "utf-8", + timeout: 30000, + stdio: ["pipe", "pipe", "pipe"], + }); + return { + status: result.status ?? 1, + stdout: result.stdout || "", + stderr: result.stderr || "", + }; +} + /** * Ensures the backup directory exists with appropriate permissions. */ @@ -31,14 +50,18 @@ function listBackups() { for (const file of files) { try { const content = JSON.parse(fs.readFileSync(path.join(BACKUP_DIR, file), "utf-8")); + if (!content.metadata || !content.metadata.name || !content.metadata.createdAt) { + console.warn(` Warning: Skipping malformed backup ${file}: missing metadata`); + continue; + } backups.push({ name: content.metadata.name, createdAt: content.metadata.createdAt, path: path.join(BACKUP_DIR, file), size: fs.statSync(path.join(BACKUP_DIR, file)).size, }); - } catch { - // Ignore malformed backup files + } catch (err) { + console.warn(` Warning: Could not read backup ${file}: ${err.message}`); } } @@ -64,16 +87,8 @@ function exportSandbox(sandboxName, outputPath) { const defaultName = `${sandboxName}-${timestamp}.json`; const backupPath = outputPath || path.join(BACKUP_DIR, defaultName); - let _policyContent = ""; - try { - _policyContent = execSync(`openshell policy get ${sandboxName} 2>/dev/null`, { - encoding: "utf-8", - timeout: 10000 - }); - } catch { - // Keep empty string on error - } - const policyContent = _policyContent; + const policyResult = runOpenshell(["policy", "get", sandboxName]); + const policyContent = policyResult.status === 0 ? policyResult.stdout : ""; const backup = { version: "1.0", @@ -130,17 +145,17 @@ function importSandbox(backupPath, newName) { const sandboxName = newName || backup.sandbox.name; + if (!isValidSandboxName(sandboxName)) { + console.error(` Invalid sandbox name: ${sandboxName}`); + return false; + } + console.log(` Creating sandbox '${sandboxName}' from backup...`); - try { - execSync(`openshell sandbox exists ${sandboxName} 2>/dev/null`, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"] - }); + const existsResult = runOpenshell(["sandbox", "exists", sandboxName]); + if (existsResult.status === 0) { console.error(` Sandbox '${sandboxName}' already exists. Use a different name or delete it first.`); return false; - } catch { - // Sandbox does not exist, continue } console.log(` Note: This only imports the registry config.`); @@ -158,11 +173,12 @@ function importSandbox(backupPath, newName) { const policyPath = path.join(os.tmpdir(), `nemoclaw-restore-${Date.now()}.yaml`); fs.writeFileSync(policyPath, backup.policy); try { - execSync(`openshell policy set --policy "${policyPath}" --wait ${sandboxName}`, { - encoding: "utf-8", - timeout: 30000, - }); - console.log(` Restored policy for '${sandboxName}'`); + const policyResult = runOpenshell(["policy", "set", "--policy", policyPath, "--wait", sandboxName]); + if (policyResult.status === 0) { + console.log(` Restored policy for '${sandboxName}'`); + } else { + console.warn(` Warning: Could not restore policy: ${policyResult.stderr}`); + } } catch (err) { console.warn(` Warning: Could not restore policy: ${err.message}`); } finally { diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 7065eae2f14..09589d3c12d 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -38,6 +38,7 @@ const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", "start", "stop", "status", "debug", "uninstall", "backups", + "import", "completion", "help", "--help", "-h", "--version", "-v", ]); @@ -411,7 +412,21 @@ function sandboxExport(sandboxName, exportPath) { * Lists all sandbox backups. */ function listBackups() { - backup.listBackups(); + const backups = backup.listBackups(); + if (backups.length === 0) { + console.log(""); + console.log(" No backups found."); + console.log(""); + return; + } + console.log(""); + console.log(" Backups:"); + for (const b of backups) { + const sizeKb = (b.size / 1024).toFixed(1); + console.log(` ${b.name} (${b.createdAt}) — ${sizeKb} KB`); + console.log(` ${b.path}`); + } + console.log(""); } /** @@ -420,7 +435,10 @@ function listBackups() { * @param {string} [newName] - Optional new name for the sandbox. */ async function importBackup(backupPath, newName) { - await backup.importSandbox(backupPath, newName); + const imported = await backup.importSandbox(backupPath, newName); + if (!imported) { + process.exitCode = 1; + } } // ── Shell Completion ───────────────────────────────────────────── @@ -479,7 +497,7 @@ function printCompletion(shell) { # Also add sandbox names as possible first argument opts="$opts ${sandboxNamesStr}" - COMPREPLY=$(compgen -W '$opts' -- '$cur') + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return 0 } @@ -500,8 +518,8 @@ _nemoclaw() { local -a cmd cmd=(\${words[1,CURRENT-1]}) - # Check if first word is a sandbox name - if [[ " \${sandbox_names[@]} " =~ " \${cmd[1]} " ]]; then + # Check if first word is a sandbox name (cmd[2] is first arg, cmd[1] is command) + if (( \${#cmd} >= 2 )) && [[ " \${sandbox_names[@]} " =~ " \${cmd[2]} " ]]; then _describe 'sandbox actions' sandbox_actions else _describe 'commands' global_cmds @@ -515,7 +533,7 @@ compdef _nemoclaw nemoclaw`); complete -c nemoclaw -f -a "${globalCmds.join(" ")} ${sandboxNamesStr}" -n "test (count (commandline -opc)) -eq 1" -complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains (commandline -opc | head -1) ${sandboxNamesFish}" +complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains -- (commandline -opc)[2] ${sandboxNamesFish}" `); } } From 60fbace7a88bf35a803d6ad90187bffdec8b4292 Mon Sep 17 00:00:00 2001 From: vasanth53 Date: Fri, 24 Apr 2026 14:56:37 +0530 Subject: [PATCH 5/5] refactor: port backup/import logic to src/ and clean up completion --- bin/nemoclaw.js | 671 +------------------------ bin/lib/backup.js => src/lib/backup.ts | 75 +-- src/nemoclaw.ts | 64 ++- 3 files changed, 107 insertions(+), 703 deletions(-) rename bin/lib/backup.js => src/lib/backup.ts (77%) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 09589d3c12d..ebdf7404d25 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -1,673 +1,6 @@ #!/usr/bin/env node +// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -const { execFileSync, spawnSync } = require("child_process"); -const path = require("path"); -const fs = require("fs"); -const os = require("os"); - -// --------------------------------------------------------------------------- -// Color / style — respects NO_COLOR and non-TTY environments. -// Uses exact NVIDIA green #76B900 on truecolor terminals; 256-color otherwise. -// --------------------------------------------------------------------------- -const _useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; -const _tc = _useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); -const G = _useColor ? (_tc ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : ""; -const B = _useColor ? "\x1b[1m" : ""; -const D = _useColor ? "\x1b[2m" : ""; -const R = _useColor ? "\x1b[0m" : ""; -const RD = _useColor ? "\x1b[1;31m" : ""; -const YW = _useColor ? "\x1b[1;33m" : ""; - -const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); -const { - ensureApiKey, - ensureGithubToken, - getCredential, - isRepoPrivate, -} = require("./lib/credentials"); -const registry = require("./lib/registry"); -const nim = require("./lib/nim"); -const policies = require("./lib/policies"); -const backup = require("./lib/backup"); - -// ── Global commands ────────────────────────────────────────────── - -const GLOBAL_COMMANDS = new Set([ - "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", - "backups", - "import", - "completion", - "help", "--help", "-h", "--version", "-v", -]); - -const SANDBOX_ACTIONS = [ - "connect", "status", "logs", "policy-add", "policy-list", "destroy", "export" -]; - -const SHELL_TYPES = ["bash", "zsh", "fish"]; - -const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; - -function resolveUninstallScript() { - const candidates = [ - path.join(ROOT, "uninstall.sh"), - path.join(__dirname, "..", "uninstall.sh"), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - - return null; -} - -function exitWithSpawnResult(result) { - if (result.status !== null) { - process.exit(result.status); - } - - if (result.signal) { - const signalNumber = os.constants.signals[result.signal]; - process.exit(signalNumber ? 128 + signalNumber : 1); - } - - process.exit(1); -} - -// ── Commands ───────────────────────────────────────────────────── - -async function onboard(args) { - const { onboard: runOnboard } = require("./lib/onboard"); - const allowedArgs = new Set(["--non-interactive"]); - const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); - if (unknownArgs.length > 0) { - console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); - console.error(" Usage: nemoclaw onboard [--non-interactive]"); - process.exit(1); - } - const nonInteractive = args.includes("--non-interactive"); - await runOnboard({ nonInteractive }); -} - -async function setup() { - console.log(""); - console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); - console.log(" Running legacy setup.sh for backwards compatibility..."); - console.log(""); - await ensureApiKey(); - const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(defaultSandbox) ? defaultSandbox : ""; - run(`bash "${SCRIPTS}/setup.sh" ${shellQuote(safeName)}`); -} - -async function setupSpark() { - await ensureApiKey(); - run(`sudo -E NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)} bash "${SCRIPTS}/setup-spark.sh"`); -} - -async function deploy(instanceName) { - if (!instanceName) { - console.error(" Usage: nemoclaw deploy "); - console.error(""); - console.error(" Examples:"); - console.error(" nemoclaw deploy my-gpu-box"); - console.error(" nemoclaw deploy nemoclaw-prod"); - console.error(" nemoclaw deploy nemoclaw-test"); - process.exit(1); - } - await ensureApiKey(); - if (isRepoPrivate("NVIDIA/OpenShell")) { - await ensureGithubToken(); - } - validateName(instanceName, "instance name"); - const name = instanceName; - const qname = shellQuote(name); - const gpu = process.env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; - - console.log(""); - console.log(` Deploying NemoClaw to Brev instance: ${name}`); - console.log(""); - - try { - execFileSync("which", ["brev"], { stdio: "ignore" }); - } catch { - console.error("brev CLI not found. Install: https://brev.nvidia.com"); - process.exit(1); - } - - let exists = false; - try { - const out = execFileSync("brev", ["ls"], { encoding: "utf-8" }); - exists = out.includes(name); - } catch (err) { - if (err.stdout && err.stdout.includes(name)) exists = true; - if (err.stderr && err.stderr.includes(name)) exists = true; - } - - if (!exists) { - console.log(` Creating Brev instance '${name}' (${gpu})...`); - run(`brev create ${qname} --gpu ${shellQuote(gpu)}`); - } else { - console.log(` Brev instance '${name}' already exists.`); - } - - run(`brev refresh`, { ignoreError: true }); - - process.stdout.write(` Waiting for SSH `); - for (let i = 0; i < 60; i++) { - try { - execFileSync("ssh", ["-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", name, "echo", "ok"], { encoding: "utf-8", stdio: "ignore" }); - process.stdout.write(` ${G}✓${R}\n`); - break; - } catch { - if (i === 59) { - process.stdout.write("\n"); - console.error(` Timed out waiting for SSH to ${name}`); - process.exit(1); - } - process.stdout.write("."); - spawnSync("sleep", ["3"]); - } - } - - console.log(" Syncing NemoClaw to VM..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'mkdir -p /home/ubuntu/nemoclaw'`); - run(`rsync -az --delete --exclude node_modules --exclude .git --exclude src -e "ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR" "${ROOT}/scripts" "${ROOT}/Dockerfile" "${ROOT}/nemoclaw" "${ROOT}/nemoclaw-blueprint" "${ROOT}/bin" "${ROOT}/package.json" ${qname}:/home/ubuntu/nemoclaw/`); - - const envLines = [`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY || "")}`]; - const ghToken = process.env.GITHUB_TOKEN; - if (ghToken) envLines.push(`GITHUB_TOKEN=${shellQuote(ghToken)}`); - const tgToken = getCredential("TELEGRAM_BOT_TOKEN"); - if (tgToken) envLines.push(`TELEGRAM_BOT_TOKEN=${shellQuote(tgToken)}`); - const discordToken = getCredential("DISCORD_BOT_TOKEN"); - if (discordToken) envLines.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`); - const slackToken = getCredential("SLACK_BOT_TOKEN"); - if (slackToken) envLines.push(`SLACK_BOT_TOKEN=${shellQuote(slackToken)}`); - const envDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-")); - const envTmp = path.join(envDir, "env"); - fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); - try { - run(`scp -q -o StrictHostKeyChecking=no -o LogLevel=ERROR ${shellQuote(envTmp)} ${qname}:/home/ubuntu/nemoclaw/.env`); - } finally { - try { fs.unlinkSync(envTmp); } catch {} - try { fs.rmdirSync(envDir); } catch {} - } - - console.log(" Running setup..."); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/brev-setup.sh'`); - - if (tgToken) { - console.log(" Starting services..."); - run(`ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && bash scripts/start-services.sh'`); - } - - console.log(""); - console.log(" Connecting to sandbox..."); - console.log(""); - runInteractive(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${qname} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); -} - -async function start() { - await ensureApiKey(); - const { defaultSandbox } = registry.listSandboxes(); - const safeName = defaultSandbox && /^[a-zA-Z0-9._-]+$/.test(defaultSandbox) ? defaultSandbox : null; - const sandboxEnv = safeName ? `SANDBOX_NAME=${shellQuote(safeName)}` : ""; - run(`${sandboxEnv} bash "${SCRIPTS}/start-services.sh"`); -} - -function stop() { - run(`bash "${SCRIPTS}/start-services.sh" --stop`); -} - -function debug(args) { - const result = spawnSync("bash", [path.join(SCRIPTS, "debug.sh"), ...args], { - stdio: "inherit", - cwd: ROOT, - env: { - ...process.env, - SANDBOX_NAME: registry.listSandboxes().defaultSandbox || "", - }, - }); - exitWithSpawnResult(result); -} - -function uninstall(args) { - const localScript = resolveUninstallScript(); - if (localScript) { - console.log(` Running local uninstall script: ${localScript}`); - const result = spawnSync("bash", [localScript, ...args], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); - exitWithSpawnResult(result); - } - - console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`); - const forwardedArgs = args.map(shellQuote).join(" "); - const command = forwardedArgs.length > 0 - ? `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash -s -- ${forwardedArgs}` - : `curl -fsSL ${shellQuote(REMOTE_UNINSTALL_URL)} | bash`; - const result = spawnSync("bash", ["-c", command], { - stdio: "inherit", - cwd: ROOT, - env: process.env, - }); - exitWithSpawnResult(result); -} - -function showStatus() { - // Show sandbox registry - const { sandboxes, defaultSandbox } = registry.listSandboxes(); - if (sandboxes.length > 0) { - console.log(""); - console.log(" Sandboxes:"); - for (const sb of sandboxes) { - const def = sb.name === defaultSandbox ? " *" : ""; - const model = sb.model ? ` (${sb.model})` : ""; - console.log(` ${sb.name}${def}${model}`); - } - console.log(""); - } - - // Show service status - run(`bash "${SCRIPTS}/start-services.sh" --status`); -} - -function listSandboxes() { - const { sandboxes, defaultSandbox } = registry.listSandboxes(); - if (sandboxes.length === 0) { - console.log(""); - console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); - console.log(""); - return; - } - - console.log(""); - console.log(" Sandboxes:"); - for (const sb of sandboxes) { - const def = sb.name === defaultSandbox ? " *" : ""; - const model = sb.model || "unknown"; - const provider = sb.provider || "unknown"; - const gpu = sb.gpuEnabled ? "GPU" : "CPU"; - const presets = sb.policies && sb.policies.length > 0 ? sb.policies.join(", ") : "none"; - console.log(` ${sb.name}${def}`); - console.log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`); - } - console.log(""); - console.log(" * = default sandbox"); - console.log(""); -} - -// ── Sandbox-scoped actions ─────────────────────────────────────── - -function sandboxConnect(sandboxName) { - const qn = shellQuote(sandboxName); - // Ensure port forward is alive before connecting - run(`openshell forward start --background 18789 ${qn} 2>/dev/null || true`, { ignoreError: true }); - runInteractive(`openshell sandbox connect ${qn}`); -} - -function sandboxStatus(sandboxName) { - const sb = registry.getSandbox(sandboxName); - if (sb) { - console.log(""); - console.log(` Sandbox: ${sb.name}`); - console.log(` Model: ${sb.model || "unknown"}`); - console.log(` Provider: ${sb.provider || "unknown"}`); - console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`); - console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); - } - - // openshell info - run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); - - // NIM health - const nimStat = nim.nimStatus(sandboxName); - console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); - if (nimStat.running) { - console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); - } - console.log(""); -} - -function sandboxLogs(sandboxName, follow) { - const followFlag = follow ? " --tail" : ""; - run(`openshell logs ${shellQuote(sandboxName)}${followFlag}`); -} - -async function sandboxPolicyAdd(sandboxName) { - const allPresets = policies.listPresets(); - const applied = policies.getAppliedPresets(sandboxName); - - console.log(""); - console.log(" Available presets:"); - allPresets.forEach((p) => { - const marker = applied.includes(p.name) ? "●" : "○"; - console.log(` ${marker} ${p.name} — ${p.description}`); - }); - console.log(""); - - const { prompt: askPrompt } = require("./lib/credentials"); - const answer = await askPrompt(" Preset to apply: "); - if (!answer) return; - - const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); - if (confirm.toLowerCase() === "n") return; - - policies.applyPreset(sandboxName, answer); -} - -function sandboxPolicyList(sandboxName) { - const allPresets = policies.listPresets(); - const applied = policies.getAppliedPresets(sandboxName); - - console.log(""); - console.log(` Policy presets for sandbox '${sandboxName}':`); - allPresets.forEach((p) => { - const marker = applied.includes(p.name) ? "●" : "○"; - console.log(` ${marker} ${p.name} — ${p.description}`); - }); - console.log(""); -} - -async function sandboxDestroy(sandboxName, args = []) { - const skipConfirm = args.includes("--yes") || args.includes("--force"); - if (!skipConfirm) { - const { prompt: askPrompt } = require("./lib/credentials"); - const answer = await askPrompt( - ` ${YW}Destroy sandbox '${sandboxName}'?${R} This cannot be undone. [y/N]: `, - ); - if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") { - console.log(" Cancelled."); - return; - } - } - - console.log(` Stopping NIM for '${sandboxName}'...`); - nim.stopNimContainer(sandboxName); - - console.log(` Deleting sandbox '${sandboxName}'...`); - run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); - - registry.removeSandbox(sandboxName); - console.log(` ${G}✓${R} Sandbox '${sandboxName}' destroyed`); -} - -/** - * Exports a sandbox to a backup file. - * @param {string} sandboxName - Name of the sandbox to export. - * @param {string} [exportPath] - Optional output path for the backup. - */ -function sandboxExport(sandboxName, exportPath) { - backup.exportSandbox(sandboxName, exportPath); -} - -/** - * Lists all sandbox backups. - */ -function listBackups() { - const backups = backup.listBackups(); - if (backups.length === 0) { - console.log(""); - console.log(" No backups found."); - console.log(""); - return; - } - console.log(""); - console.log(" Backups:"); - for (const b of backups) { - const sizeKb = (b.size / 1024).toFixed(1); - console.log(` ${b.name} (${b.createdAt}) — ${sizeKb} KB`); - console.log(` ${b.path}`); - } - console.log(""); -} - -/** - * Imports a sandbox from a backup file. - * @param {string} backupPath - Path to the backup file. - * @param {string} [newName] - Optional new name for the sandbox. - */ -async function importBackup(backupPath, newName) { - const imported = await backup.importSandbox(backupPath, newName); - if (!imported) { - process.exitCode = 1; - } -} - -// ── Shell Completion ───────────────────────────────────────────── - -/** - * Prints shell completion script for the specified shell. - * @param {string} shell - Shell type (bash, zsh, or fish). - */ -function printCompletion(shell) { - if (!shell || !SHELL_TYPES.includes(shell)) { - console.log(" Usage: nemoclaw completion "); - console.log(""); - console.log(" Generate shell completion scripts."); - console.log(""); - console.log(" Shells supported: bash, zsh, fish"); - console.log(""); - console.log(" Example:"); - console.log(" # Bash:"); - console.log(" nemoclaw completion bash >> ~/.bashrc"); - console.log(""); - console.log(" # Zsh:"); - console.log(" nemoclaw completion zsh >> ~/.zshrc"); - console.log(""); - console.log(" # Fish:"); - console.log(" nemoclaw completion fish > ~/.config/fish/completions/nemoclaw.fish"); - return; - } - - const globalCmds = Array.from(GLOBAL_COMMANDS).filter(c => !c.startsWith("-")); - let sandboxNames; - try { - sandboxNames = registry.listSandboxes().sandboxes.map(s => s.name); - } catch { - sandboxNames = []; - } - - const sandboxNamesStr = sandboxNames.length > 0 ? sandboxNames.join(" ") : ""; - const sandboxNamesZsh = sandboxNames.length > 0 ? sandboxNames.map(s => `"${s}"`).join(" ") : ""; - const sandboxNamesFish = sandboxNames.length > 0 ? sandboxNames.map(s => `'${s}'`).join(" ") : ""; - - if (shell === "bash") { - console.log(`_nemoclaw_completions() { - local cur prev opts - COMPREPLY=() - cur="\${COMP_WORDS[COMP_CWORD]}" - prev="\${COMP_WORDS[COMP_CWORD-1]}" - - # Global commands - opts="${globalCmds.join(" ")}" - - # Sandbox names (if previous word is a known sandbox) - if [[ " ${sandboxNamesStr} " =~ " $prev " ]]; then - opts="${SANDBOX_ACTIONS.join(" ")}" - fi - - # Also add sandbox names as possible first argument - opts="$opts ${sandboxNamesStr}" - - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return 0 -} - -complete -F _nemoclaw_completions nemoclaw`); - } else if (shell === "zsh") { - console.log(`# nemoclaw zsh completion - -local -a global_cmds -global_cmds=(${globalCmds.map(c => `"${c}"`).join(" ")}) - -local -a sandbox_actions -sandbox_actions=(${SANDBOX_ACTIONS.map(a => `"${a}"`).join(" ")}) - -local -a sandbox_names -sandbox_names=(${sandboxNamesZsh}) - -_nemoclaw() { - local -a cmd - cmd=(\${words[1,CURRENT-1]}) - - # Check if first word is a sandbox name (cmd[2] is first arg, cmd[1] is command) - if (( \${#cmd} >= 2 )) && [[ " \${sandbox_names[@]} " =~ " \${cmd[2]} " ]]; then - _describe 'sandbox actions' sandbox_actions - else - _describe 'commands' global_cmds - _describe 'sandboxes' sandbox_names - fi -} - -compdef _nemoclaw nemoclaw`); - } else if (shell === "fish") { - console.log(`# nemoclaw fish completion - -complete -c nemoclaw -f -a "${globalCmds.join(" ")} ${sandboxNamesStr}" -n "test (count (commandline -opc)) -eq 1" - -complete -c nemoclaw -f -a "${SANDBOX_ACTIONS.join(" ")}" -n "test (count (commandline -opc)) -ge 2; and contains -- (commandline -opc)[2] ${sandboxNamesFish}" -`); - } -} - -// ── Help ───────────────────────────────────────────────────────── - -function help() { - const pkg = require(path.join(__dirname, "..", "package.json")); - console.log(` - ${B}${G}NemoClaw${R} ${D}v${pkg.version}${R} - ${D}Deploy more secure, always-on AI assistants with a single command.${R} - - ${G}Getting Started:${R} - ${B}nemoclaw onboard${R} Configure inference endpoint and credentials - nemoclaw setup-spark Set up on DGX Spark ${D}(fixes cgroup v2 + Docker)${R} - - ${G}Sandbox Management:${R} - ${B}nemoclaw list${R} List all sandboxes - nemoclaw connect Shell into a running sandbox - nemoclaw status Sandbox health + NIM status - nemoclaw logs ${D}[--follow]${R} Stream sandbox logs - nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} - nemoclaw export ${D}[path]${R} Export sandbox to backup file - - ${G}Policy Presets:${R} - nemoclaw policy-add Add a network or filesystem policy preset - nemoclaw policy-list List presets ${D}(● = applied)${R} - - ${G}Deploy:${R} - nemoclaw deploy Deploy to a Brev VM and start services - - ${G}Services:${R} - nemoclaw start Start auxiliary services ${D}(Telegram, tunnel)${R} - nemoclaw stop Stop all services - nemoclaw status Show sandbox list and service status - - Troubleshooting: - nemoclaw debug [--quick] Collect diagnostics for bug reports - nemoclaw debug --output FILE Save diagnostics tarball for GitHub issues - - Cleanup: - nemoclaw uninstall [flags] Run uninstall.sh (local first, curl fallback) - - ${G}Uninstall flags:${R} - --yes Skip the confirmation prompt - --keep-openshell Leave the openshell binary installed - --delete-models Remove NemoClaw-pulled Ollama models - - ${G}Backup & Restore:${R} - nemoclaw backups List all backups - nemoclaw import ${D} [name]${R} Import sandbox from backup file - - Shell Completion: - nemoclaw completion ${D}${R} Generate shell completion script - - ${D}Powered by NVIDIA OpenShell · Nemotron · Agent Toolkit - Credentials saved in ~/.nemoclaw/credentials.json (mode 600)${R} - ${D}https://www.nvidia.com/nemoclaw${R} -`); -} - -// ── Dispatch ───────────────────────────────────────────────────── - -const [cmd, ...args] = process.argv.slice(2); - -(async () => { - // No command → help - if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { - help(); - return; - } - - // Global commands - if (GLOBAL_COMMANDS.has(cmd)) { - switch (cmd) { - case "onboard": await onboard(args); break; - case "setup": await setup(); break; - case "setup-spark": await setupSpark(); break; - case "deploy": await deploy(args[0]); break; - case "start": await start(); break; - case "stop": stop(); break; - case "status": showStatus(); break; - case "debug": debug(args); break; - case "uninstall": uninstall(args); break; - case "list": listSandboxes(); break; - case "backups": listBackups(); break; - case "completion": printCompletion(args[0]); break; - case "import": await importBackup(args[0], args[1]); break; - case "--version": - case "-v": { - const pkg = require(path.join(__dirname, "..", "package.json")); - console.log(`nemoclaw v${pkg.version}`); - break; - } - default: help(); break; - } - return; - } - - // Sandbox-scoped commands: nemoclaw - const sandbox = registry.getSandbox(cmd); - if (sandbox) { - validateName(cmd, "sandbox name"); - const action = args[0] || "connect"; - const actionArgs = args.slice(1); - - switch (action) { - case "connect": sandboxConnect(cmd); break; - case "status": sandboxStatus(cmd); break; - case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; - case "policy-add": await sandboxPolicyAdd(cmd); break; - case "policy-list": sandboxPolicyList(cmd); break; - case "destroy": await sandboxDestroy(cmd, actionArgs); break; - case "export": sandboxExport(cmd, actionArgs[0]); break; - default: - console.error(` Unknown action: ${action}`); - console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy, export`); - process.exit(1); - } - return; - } - - // Unknown command — suggest - console.error(` Unknown command: ${cmd}`); - console.error(""); - - // Check if it looks like a sandbox name with missing action - const allNames = registry.listSandboxes().sandboxes.map((s) => s.name); - if (allNames.length > 0) { - console.error(` Registered sandboxes: ${allNames.join(", ")}`); - console.error(` Try: nemoclaw connect`); - console.error(""); - } - - console.error(` Run 'nemoclaw help' for usage.`); - process.exit(1); -})(); +require("../dist/nemoclaw"); diff --git a/bin/lib/backup.js b/src/lib/backup.ts similarity index 77% rename from bin/lib/backup.js rename to src/lib/backup.ts index 39a763d8356..356961f7f7c 100644 --- a/bin/lib/backup.js +++ b/src/lib/backup.ts @@ -3,22 +3,22 @@ // // Sandbox backup and restore functionality -const fs = require("fs"); -const path = require("path"); -const os = require("os"); -const { spawnSync } = require("child_process"); -const registry = require("./registry"); -const { ROOT } = require("./runner"); +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { spawnSync } from "node:child_process"; +import * as registry from "./registry.js"; +import { ROOT } from "./runner.js"; -const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); +export const BACKUP_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "backups"); const SANDBOX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; -function isValidSandboxName(name) { +function isValidSandboxName(name: string): boolean { return SANDBOX_NAME_PATTERN.test(name); } -function runOpenshell(args) { +function runOpenshell(args: string[]) { const result = spawnSync("openshell", args, { encoding: "utf-8", timeout: 30000, @@ -34,22 +34,32 @@ function runOpenshell(args) { /** * Ensures the backup directory exists with appropriate permissions. */ -function ensureBackupDir() { +export function ensureBackupDir(): void { fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 }); } +export interface BackupEntry { + name: string; + createdAt: string; + path: string; + size: number; +} + /** * Lists all sandbox backups in the backup directory. - * @returns {Array<{name: string, createdAt: string, path: string, size: number}>} + * @returns {BackupEntry[]} */ -function listBackups() { +export function listBackups(): BackupEntry[] { ensureBackupDir(); + if (!fs.existsSync(BACKUP_DIR)) return []; + const files = fs.readdirSync(BACKUP_DIR).filter(f => f.endsWith(".json")); - const backups = []; + const backups: BackupEntry[] = []; for (const file of files) { try { - const content = JSON.parse(fs.readFileSync(path.join(BACKUP_DIR, file), "utf-8")); + const filePath = path.join(BACKUP_DIR, file); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); if (!content.metadata || !content.metadata.name || !content.metadata.createdAt) { console.warn(` Warning: Skipping malformed backup ${file}: missing metadata`); continue; @@ -57,10 +67,10 @@ function listBackups() { backups.push({ name: content.metadata.name, createdAt: content.metadata.createdAt, - path: path.join(BACKUP_DIR, file), - size: fs.statSync(path.join(BACKUP_DIR, file)).size, + path: filePath, + size: fs.statSync(filePath).size, }); - } catch (err) { + } catch (err: any) { console.warn(` Warning: Could not read backup ${file}: ${err.message}`); } } @@ -74,7 +84,7 @@ function listBackups() { * @param {string} [outputPath] - Optional output path for the backup file. * @returns {string|null} Path to the created backup file, or null if sandbox not found. */ -function exportSandbox(sandboxName, outputPath) { +export function exportSandbox(sandboxName: string, outputPath?: string): string | null { const sandbox = registry.getSandbox(sandboxName); if (!sandbox) { console.error(` Sandbox not found: ${sandboxName}`); @@ -90,12 +100,19 @@ function exportSandbox(sandboxName, outputPath) { const policyResult = runOpenshell(["policy", "get", sandboxName]); const policyContent = policyResult.status === 0 ? policyResult.stdout : ""; + let version = "unknown"; + try { + version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")).version; + } catch { + // ignore + } + const backup = { version: "1.0", metadata: { name: sandboxName, createdAt: new Date().toISOString(), - nemoclawVersion: JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")).version, + nemoclawVersion: version, }, sandbox: { name: sandbox.name, @@ -122,15 +139,15 @@ function exportSandbox(sandboxName, outputPath) { * Imports a sandbox from a backup file. * @param {string} backupPath - Path to the backup file. * @param {string} [newName] - Optional new name for the imported sandbox. - * @returns {boolean} True if import succeeded, false otherwise. + * @returns {Promise} True if import succeeded, false otherwise. */ -function importSandbox(backupPath, newName) { +export async function importSandbox(backupPath: string, newName?: string): Promise { if (!fs.existsSync(backupPath)) { console.error(` Backup file not found: ${backupPath}`); return false; } - let backup; + let backup: any; try { backup = JSON.parse(fs.readFileSync(backupPath, "utf-8")); } catch { @@ -179,10 +196,10 @@ function importSandbox(backupPath, newName) { } else { console.warn(` Warning: Could not restore policy: ${policyResult.stderr}`); } - } catch (err) { + } catch (err: any) { console.warn(` Warning: Could not restore policy: ${err.message}`); } finally { - fs.unlinkSync(policyPath); + try { fs.unlinkSync(policyPath); } catch { /* ignore */ } } } @@ -195,7 +212,7 @@ function importSandbox(backupPath, newName) { * @param {string} backupPath - Path to the backup file to delete. * @returns {boolean} True if deletion succeeded, false otherwise. */ -function deleteBackup(backupPath) { +export function deleteBackup(backupPath: string): boolean { if (!fs.existsSync(backupPath)) { console.error(` Backup not found: ${backupPath}`); return false; @@ -204,11 +221,3 @@ function deleteBackup(backupPath) { console.log(` Deleted backup: ${backupPath}`); return true; } - -module.exports = { - BACKUP_DIR, - listBackups, - exportSandbox, - importSandbox, - deleteBackup, -}; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 37e6d617a0f..785b0472233 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -66,6 +66,7 @@ const agentRuntime = require("../bin/lib/agent-runtime"); const sandboxVersion = require("./lib/sandbox-version"); const sandboxState = require("./lib/sandbox-state"); const { ensureOllamaAuthProxy } = require("./lib/onboard"); +const backup = require("./lib/backup"); const skillInstall = require("./lib/skill-install"); const { sleepSeconds } = require("./lib/wait"); const { parseSandboxPhase } = require("./lib/gateway-state"); @@ -100,6 +101,8 @@ const GLOBAL_COMMANDS = new Set([ "uninstall", "credentials", "backup-all", + "backups", + "import", "upgrade-sandboxes", "gc", "help", @@ -2932,6 +2935,50 @@ async function garbageCollectImages(args = []) { if (failed > 0) process.exit(1); } +/** + * Lists all sandbox backups. + */ +function listBackups() { + const backups = backup.listBackups(); + if (backups.length === 0) { + console.log(""); + console.log(" No backups found in ~/.nemoclaw/backups/"); + console.log(""); + return; + } + + console.log(""); + console.log(" Backups:"); + for (const b of backups) { + const sizeMb = (b.size / (1024 * 1024)).toFixed(1); + console.log(` ${b.name} (${sizeMb} MB) — ${b.createdAt}`); + console.log(` ${D}${b.path}${R}`); + } + console.log(""); +} + +/** + * Imports a sandbox from a backup file. + * @param {string} backupPath - Path to the backup file. + * @param {string} [newName] - Optional new name for the sandbox. + */ +async function importBackup(backupPath, newName) { + if (!backupPath) { + console.error(" Usage: nemoclaw import [new-name]"); + process.exit(1); + } + await backup.importSandbox(backupPath, newName); +} + +/** + * Exports a sandbox to a backup file. + * @param {string} sandboxName - Name of the sandbox to export. + * @param {string} [exportPath] - Optional output path for the backup. + */ +function sandboxExport(sandboxName, exportPath) { + backup.exportSandbox(sandboxName, exportPath); +} + // ── Help ───────────────────────────────────────────────────────── /** Print CLI usage with all commands, flags, and reconfiguration guidance. */ @@ -2955,6 +3002,7 @@ function help() { nemoclaw snapshot restore Restore state from a snapshot ${D}([v|name|timestamp], omit for latest)${R} nemoclaw rebuild Upgrade sandbox to current agent version ${D}(--yes to skip prompt)${R} nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} + nemoclaw export ${D}[path]${R} Export sandbox to backup file ${G}Skills:${R} nemoclaw skill install Deploy a skill directory to the sandbox @@ -2995,6 +3043,10 @@ function help() { ${G}Backup:${R} nemoclaw backup-all Back up all sandbox state before upgrade + ${G}Backup & Restore:${R} + nemoclaw backups List all backups + nemoclaw import ${D} [name]${R} Import sandbox from backup file + ${G}Upgrade:${R} nemoclaw upgrade-sandboxes Detect and rebuild stale sandboxes ${D}(--check, --auto)${R} @@ -3086,6 +3138,12 @@ const [cmd, ...args] = process.argv.slice(2); case "backup-all": backupAll(); break; + case "backups": + listBackups(); + break; + case "import": + await importBackup(args[0], args[1]); + break; case "upgrade-sandboxes": await upgradeSandboxes(args); break; @@ -3121,6 +3179,7 @@ const [cmd, ...args] = process.argv.slice(2); "shields", "config", "channels", + "export", "", ]; if (!registry.getSandbox(cmd) && sandboxActions.includes(args[0] || "")) { @@ -3178,6 +3237,9 @@ const [cmd, ...args] = process.argv.slice(2); case "snapshot": sandboxSnapshot(cmd, actionArgs); break; + case "export": + sandboxExport(cmd, actionArgs[0]); + break; case "shields": { const shieldsSub = actionArgs[0]; const shieldsFlags = actionArgs.slice(1); @@ -3302,7 +3364,7 @@ const [cmd, ...args] = process.argv.slice(2); default: console.error(` Unknown action: ${action}`); console.error( - ` Valid actions: connect, status, logs, policy-add, policy-remove, policy-list, skill, snapshot, rebuild, shields, config, channels, destroy`, + ` Valid actions: connect, status, logs, policy-add, policy-remove, policy-list, skill, snapshot, rebuild, shields, config, channels, export, destroy`, ); process.exit(1); }