diff --git a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index d29d4fc68eb..72e38178856 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -299,6 +299,44 @@ $ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab $ nemoclaw onboard ``` +### Previous onboarding session failed + +If a previous `nemoclaw onboard` attempt fails partway through (for example, a provider or inference-setup step reporting an error), NemoClaw records the failure in `~/.nemoclaw/onboard-session.json`. + +When you re-run the installer, it detects the failed session and does not silently retry it. +Silent retry would loop on the same failure if your original choice, such as an unreachable provider, was the cause. + +- In an interactive terminal, the installer prompts whether to resume the failed session or start fresh. + Press `R` (or Enter) to retry the same session, or `f` to discard it and make fresh choices. +- In non-interactive mode (piped `curl | bash` with `NEMOCLAW_NON_INTERACTIVE=1`, CI, scripts), the installer refuses and exits with a non-zero status so a scripted re-run cannot loop. + You must opt in to one of two paths explicitly: + + Start over with new choices (discards the recorded session and provider/model selection): + + ```console + $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- --fresh + ``` + + Or equivalently, via env var. + The variable must be set on the `bash` side of the pipe, not on `curl`, since only the right-hand process inherits it: + + ```console + $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_FRESH=1 bash + ``` + + Retry the same session without re-prompting. + This is only useful if the original failure was transient, for example a network blip or a stopped Docker daemon, and not a wrong provider choice: + + ```console + $ nemoclaw onboard --resume + ``` + +As a last resort, you can also delete the session file directly and re-run the installer: + +```console +$ rm ~/.nemoclaw/onboard-session.json +``` + ## Runtime ### Reconnect after a host reboot diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 9df7ad23840..6cfcb2de764 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -325,6 +325,44 @@ $ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab $ nemoclaw onboard ``` +### Previous onboarding session failed + +If a previous `nemoclaw onboard` attempt fails partway through (for example, a provider or inference-setup step reporting an error), NemoClaw records the failure in `~/.nemoclaw/onboard-session.json`. + +When you re-run the installer, it detects the failed session and does not silently retry it. +Silent retry would loop on the same failure if your original choice, such as an unreachable provider, was the cause. + +- In an interactive terminal, the installer prompts whether to resume the failed session or start fresh. + Press `R` (or Enter) to retry the same session, or `f` to discard it and make fresh choices. +- In non-interactive mode (piped `curl | bash` with `NEMOCLAW_NON_INTERACTIVE=1`, CI, scripts), the installer refuses and exits with a non-zero status so a scripted re-run cannot loop. + You must opt in to one of two paths explicitly: + + Start over with new choices (discards the recorded session and provider/model selection): + + ```console + $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- --fresh + ``` + + Or equivalently, via env var. + The variable must be set on the `bash` side of the pipe, not on `curl`, since only the right-hand process inherits it: + + ```console + $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_FRESH=1 bash + ``` + + Retry the same session without re-prompting. + This is only useful if the original failure was transient, for example a network blip or a stopped Docker daemon, and not a wrong provider choice: + + ```console + $ nemoclaw onboard --resume + ``` + +As a last resort, you can also delete the session file directly and re-run the installer: + +```console +$ rm ~/.nemoclaw/onboard-session.json +``` + ## Runtime ### Reconnect after a host reboot diff --git a/install.sh b/install.sh index 356f856f064..0b8dcbde86b 100755 --- a/install.sh +++ b/install.sh @@ -92,11 +92,13 @@ bootstrap_usage() { printf " Options:\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" + printf " --fresh Discard any failed/interrupted onboarding session and start over\n" printf " --version, -v Print installer version and exit\n" printf " --help, -h Show this help message and exit\n\n" printf " Environment:\n" printf " NEMOCLAW_INSTALL_TAG Git ref to install (default: latest release)\n" printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_FRESH=1 Same as --fresh\n" printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_PROVIDER build | openai | anthropic | anthropicCompatible\n" diff --git a/scripts/install.sh b/scripts/install.sh index de0ac627cfb..87207d084a3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -321,12 +321,14 @@ usage() { printf " ${C_DIM}Options:${C_RESET}\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" printf " --yes-i-accept-third-party-software Accept the third-party software notice in non-interactive mode\n" + printf " --fresh Discard any failed/interrupted onboarding session and start over\n" printf " --version, -v Print installer version and exit\n" printf " --help, -h Show this help message and exit\n\n" printf " ${C_DIM}Environment:${C_RESET}\n" printf " NVIDIA_API_KEY API key (skips credential prompt)\n" printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_FRESH=1 Same as --fresh\n" printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_SINGLE_SESSION=1 Abort if active sandbox sessions exist\n" printf " NEMOCLAW_RECREATE_SANDBOX=1 Recreate an existing sandbox\n" @@ -1192,22 +1194,85 @@ run_onboard() { show_usage_notice info "Running nemoclaw onboard…" local -a onboard_cmd=(onboard) - if command_exists node && [[ -f "${HOME}/.nemoclaw/onboard-session.json" ]]; then - if node -e ' - const fs = require("fs"); - const file = process.argv[1]; - try { - const data = JSON.parse(fs.readFileSync(file, "utf8")); - const resumable = data && data.resumable !== false; - const status = data && data.status; - process.exit(resumable && status && status !== "complete" ? 0 : 1); - } catch { - process.exit(1); - } - ' "${HOME}/.nemoclaw/onboard-session.json"; then - info "Found an interrupted onboarding session — resuming it." - onboard_cmd+=(--resume) - fi + local session_file="${HOME}/.nemoclaw/onboard-session.json" + # --fresh takes precedence over any session state. We forward --fresh to + # `nemoclaw onboard` so the CLI clears the existing session file before + # creating a new one — the install.sh classifier is bypassed entirely. + if [ "${FRESH:-}" = "1" ]; then + info "Starting a fresh onboarding session (--fresh)." + onboard_cmd+=(--fresh) + elif command_exists node && [[ -f "$session_file" ]]; then + # Classify the session: "resume" (auto-attach --resume), "failed" + # (last run reported a step failure — user must choose), "skip" + # (complete / missing / unreadable — nothing to resume), or "corrupt". + local session_state + session_state="$( + node -e ' + const fs = require("fs"); + let out = "skip"; + try { + const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (!data || data.resumable === false || data.status === "complete") { + out = "skip"; + } else if (data.status === "failed" || data.failure) { + out = "failed"; + } else if (data.status === "in_progress") { + out = "resume"; + } else { + // Unknown or missing status — do not auto-resume a file we + // cannot classify against what onboard-session.ts actually + // writes (in_progress / failed / complete). + out = "corrupt"; + } + } catch { + out = "corrupt"; + } + process.stdout.write(out); + ' "$session_file" 2>/dev/null || printf "corrupt" + )" + case "$session_state" in + resume) + info "Found an interrupted onboarding session — resuming it." + onboard_cmd+=(--resume) + ;; + failed) + # #2430: a previous run failed. The user's provider/inference + # choice may be the cause, so auto-resuming would just loop. + # Refuse in non-interactive mode (no safe default); prompt in + # interactive mode so the user can pick resume vs. fresh. + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + error "Previous onboarding session failed. Re-run with --fresh to discard it, or run 'nemoclaw onboard --resume' to retry the same session." + fi + local _prompt_stdin="/dev/tty" + if [ -t 0 ]; then _prompt_stdin="/dev/stdin"; fi + if [ ! -r "$_prompt_stdin" ]; then + error "Previous onboarding session failed, and no TTY is available to prompt. Re-run with --fresh or run 'nemoclaw onboard --resume'." + fi + info "Previous onboarding session failed." + local _resume_answer="" + while :; do + printf " Resume the failed session, or start fresh? [R/f]: " >&2 + if ! IFS= read -r _resume_answer <"$_prompt_stdin"; then + error "Could not read response from TTY. Re-run with --fresh or run 'nemoclaw onboard --resume'." + fi + case "${_resume_answer,,}" in + "" | r | resume) + onboard_cmd+=(--resume) + break + ;; + f | fresh) + onboard_cmd+=(--fresh) + break + ;; + *) printf " Please answer 'r' or 'f'.\n" >&2 ;; + esac + done + ;; + corrupt) + warn "Onboarding session file is unreadable — ignoring and starting fresh." + ;; + skip | *) ;; + esac fi if [ "${NON_INTERACTIVE:-}" = "1" ]; then onboard_cmd+=(--non-interactive) @@ -1268,10 +1333,12 @@ main() { # Parse flags NON_INTERACTIVE="" ACCEPT_THIRD_PARTY_SOFTWARE="" + FRESH="" for arg in "$@"; do case "$arg" in --non-interactive) NON_INTERACTIVE=1 ;; --yes-i-accept-third-party-software) ACCEPT_THIRD_PARTY_SOFTWARE=1 ;; + --fresh) FRESH=1 ;; --version | -v) local version_suffix version_suffix="$(installer_version_for_display)" @@ -1291,6 +1358,7 @@ main() { # Also honor env var NON_INTERACTIVE="${NON_INTERACTIVE:-${NEMOCLAW_NON_INTERACTIVE:-}}" ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE:-${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}}" + FRESH="${FRESH:-${NEMOCLAW_FRESH:-}}" export NEMOCLAW_NON_INTERACTIVE="${NON_INTERACTIVE}" export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE}" diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts index a16b948b362..5578209cafe 100644 --- a/src/lib/onboard-command.test.ts +++ b/src/lib/onboard-command.test.ts @@ -33,6 +33,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: true, resume: true, + fresh: false, recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: true, @@ -56,6 +57,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: false, resume: false, + fresh: false, recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: true, @@ -78,6 +80,7 @@ describe("onboard command", () => { expect(runOnboard).toHaveBeenCalledWith({ nonInteractive: false, resume: true, + fresh: false, recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: false, @@ -121,6 +124,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: false, resume: true, + fresh: false, recreateSandbox: false, fromDockerfile: "/tmp/Custom.Dockerfile", acceptThirdPartySoftware: false, @@ -129,6 +133,47 @@ describe("onboard command", () => { }); }); + it("parses --fresh and surfaces it as fresh=true", () => { + expect( + parseOnboardArgs( + ["--fresh"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: exitWithCode, + }, + ), + ).toEqual({ + nonInteractive: false, + resume: false, + fresh: true, + recreateSandbox: false, + fromDockerfile: null, + acceptThirdPartySoftware: false, + agent: null, + dangerouslySkipPermissions: false, + }); + }); + + it("rejects --resume and --fresh together", () => { + const errors: string[] = []; + expect(() => + parseOnboardArgs( + ["--resume", "--fresh"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ), + ).toThrow("exit:1"); + expect(errors.join("\n")).toContain("--resume and --fresh are mutually exclusive"); + }); + it("exits when --from is missing its Dockerfile path", () => { expect(() => parseOnboardArgs( @@ -178,6 +223,7 @@ describe("onboard command", () => { ).toEqual({ nonInteractive: false, resume: false, + fresh: false, recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: false, @@ -225,6 +271,7 @@ describe("onboard command", () => { expect(runOnboard).toHaveBeenCalledWith({ nonInteractive: false, resume: false, + fresh: false, recreateSandbox: false, fromDockerfile: null, acceptThirdPartySoftware: false, diff --git a/src/lib/onboard-command.ts b/src/lib/onboard-command.ts index f3f4c5d8f9f..e1378958654 100644 --- a/src/lib/onboard-command.ts +++ b/src/lib/onboard-command.ts @@ -4,6 +4,7 @@ export interface OnboardCommandOptions { nonInteractive: boolean; resume: boolean; + fresh: boolean; recreateSandbox: boolean; fromDockerfile: string | null; acceptThirdPartySoftware: boolean; @@ -30,13 +31,14 @@ export interface RunDeprecatedOnboardAliasCommandDeps extends RunOnboardCommandD const ONBOARD_BASE_ARGS = [ "--non-interactive", "--resume", + "--fresh", "--recreate-sandbox", "--dangerously-skip-permissions", ]; function onboardUsageLines(noticeAcceptFlag: string): string[] { return [ - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--agent ] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--from ] [--agent ] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`, "", ]; } @@ -96,9 +98,18 @@ export function parseOnboardArgs( exit(1); } + const resume = parsedArgs.includes("--resume"); + const fresh = parsedArgs.includes("--fresh"); + if (resume && fresh) { + error(" --resume and --fresh are mutually exclusive."); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } + return { nonInteractive: parsedArgs.includes("--non-interactive"), - resume: parsedArgs.includes("--resume"), + resume, + fresh, recreateSandbox: parsedArgs.includes("--recreate-sandbox"), fromDockerfile, acceptThirdPartySoftware: diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a462b1df317..8567fe81446 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -244,6 +244,7 @@ type OnboardOptions = { recreateSandbox?: boolean; dangerouslySkipPermissions?: boolean; resume?: boolean; + fresh?: boolean; fromDockerfile?: string | null; acceptThirdPartySoftware?: boolean; agent?: string | null; @@ -7263,6 +7264,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { } delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; + const fresh = opts.fresh === true; + if (resume && fresh) { + console.error(" --resume and --fresh cannot both be set."); + process.exit(1); + } // In non-interactive mode also accept the env var so CI pipelines can set it. // This is the explicitly requested value; on resume it may be absent and the // session-recorded path is used instead (see below). @@ -7283,7 +7289,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { // problem: an unsupported provider value. getRequestedProviderHint(); const lockResult = onboardSession.acquireOnboardLock( - `nemoclaw onboard${resume ? " --resume" : ""}${isNonInteractive() ? " --non-interactive" : ""}${requestedFromDockerfile ? ` --from ${requestedFromDockerfile}` : ""}`, + `nemoclaw onboard${resume ? " --resume" : ""}${fresh ? " --fresh" : ""}${isNonInteractive() ? " --non-interactive" : ""}${requestedFromDockerfile ? ` --from ${requestedFromDockerfile}` : ""}`, ); if (!lockResult.acquired) { console.error(" Another NemoClaw onboarding run is already in progress."); @@ -7375,6 +7381,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); session = onboardSession.loadSession(); } else { + // --fresh asks for an explicit fresh start. createSession + saveSession + // already overwrites any existing file, but clearing first removes the + // old file outright so an interrupted createSession cannot leave the + // previous session readable on disk. + if (fresh) { + onboardSession.clearSession(); + } fromDockerfile = requestedFromDockerfile ? path.resolve(requestedFromDockerfile) : null; session = onboardSession.saveSession( onboardSession.createSession({ diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 235e35d0823..77183ec3fce 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -638,6 +638,209 @@ fi`, ); }); + // #2430: a failed session used to be auto-resumed just like in_progress. + // That loops forever when the failure was caused by the user's provider + // choice at step 3 (no way to pick a different provider). In + // non-interactive mode there is no safe default, so we refuse instead. + it("refuses to auto-resume a failed onboarding session in non-interactive mode (#2430)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-failed-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); + + fs.writeFileSync( + path.join(tmp, ".nemoclaw", "onboard-session.json"), + JSON.stringify( + { + resumable: true, + status: "failed", + failure: { step: "inference", message: "Ollama proxy unreachable" }, + }, + null, + 2, + ), + ); + + writeNodeStub(fakeBin); + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +if [ "$1" = "info" ]; then + echo '{"ServerVersion":"29.3.1","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' + exit 0 +fi +exit 0 +`, + ); + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "openshell 0.0.22" + exit 0 +fi +exit 0 +`, + ); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 +fi +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + fs.writeFileSync( + path.join(tmp, "package.json"), + JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), + ); + fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "nemoclaw", "package.json"), + JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + ); + + const result = spawnSync("bash", [INSTALLER], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, + }, + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Previous onboarding session failed/, + ); + expect(`${result.stdout}${result.stderr}`).toMatch(/--fresh/); + // The installer must have bailed out before invoking nemoclaw onboard. + expect(fs.existsSync(onboardLog)).toBe(false); + }); + + // #2430: --fresh is the escape hatch. Even with a session file on disk + // (failed or otherwise), the installer should skip the auto-resume check + // and let the onboard command create a new session. + it("--fresh skips auto-resume regardless of session state (#2430)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-fresh-")); + const fakeBin = path.join(tmp, "bin"); + const prefix = path.join(tmp, "prefix"); + const onboardLog = path.join(tmp, "onboard.log"); + fs.mkdirSync(fakeBin); + fs.mkdirSync(path.join(prefix, "bin"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".nemoclaw"), { recursive: true }); + + // A session that WOULD auto-resume (status=in_progress) without --fresh. + fs.writeFileSync( + path.join(tmp, ".nemoclaw", "onboard-session.json"), + JSON.stringify({ resumable: true, status: "in_progress" }, null, 2), + ); + + writeNodeStub(fakeBin); + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +if [ "$1" = "info" ]; then + echo '{"ServerVersion":"29.3.1","OperatingSystem":"Ubuntu 24.04","CgroupVersion":"2"}' + exit 0 +fi +exit 0 +`, + ); + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "openshell 0.0.22" + exit 0 +fi +exit 0 +`, + ); + writeNpmStub( + fakeBin, + `if [ "$1" = "pack" ]; then + tmpdir="$4" + mkdir -p "$tmpdir/package" + tar -czf "$tmpdir/openclaw-2026.3.11.tgz" -C "$tmpdir" package + exit 0 +fi +if [ "$1" = "install" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi +if [ "$1" = "link" ]; then + cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "nemoclaw v0.1.0-test"; exit 0; fi +printf '%s\\n' "$*" >> "$NEMOCLAW_ONBOARD_LOG" +exit 0 +EOS + chmod +x "$NPM_PREFIX/bin/nemoclaw" + exit 0 +fi`, + ); + + fs.writeFileSync( + path.join(tmp, "package.json"), + JSON.stringify({ name: "nemoclaw", version: "0.1.0" }, null, 2), + ); + fs.mkdirSync(path.join(tmp, "nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, "nemoclaw", "package.json"), + JSON.stringify({ name: "nemoclaw-plugin", version: "0.1.0" }, null, 2), + ); + + const result = spawnSync("bash", [INSTALLER, "--fresh"], { + cwd: tmp, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NPM_PREFIX: prefix, + NEMOCLAW_ONBOARD_LOG: onboardLog, + }, + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Starting a fresh onboarding session/, + ); + expect(`${result.stdout}${result.stderr}`).not.toMatch( + /Found an interrupted onboarding session/, + ); + // onboard was called with --fresh (forwarded so the CLI clears the + // existing session file) and without --resume. + const log = fs.readFileSync(onboardLog, "utf-8"); + expect(log).toMatch( + /^onboard --fresh --non-interactive --yes-i-accept-third-party-software$/m, + ); + expect(log).not.toMatch(/--resume/); + }); + it("skips onboarding when shared host preflight detects Docker is missing", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-missing-docker-")); const fakeBin = path.join(tmp, "bin");