From 940f599715c0fc722f8fd5d14addade798ca832d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 31 Mar 2026 19:46:39 -0700 Subject: [PATCH 1/6] fix: derive CLI version from git tags instead of hard-coded package.json The -v flag and help output previously read the version from package.json, which was hard-coded at 0.1.0 and never updated when new git tags were created. This caused nemoclaw -v to report the wrong version. Changes: - Add bin/lib/version.js: resolves version from git describe, then .version file, then package.json as a last resort - Update bin/nemoclaw.js to use getVersion() instead of pkg.version - Update install.sh resolve_installer_version() with the same git -> .version -> package.json fallback chain - Fetch v* tags into shallow clones during remote install so git describe works reliably, and stamp .version as belt-and-suspenders - Stamp .version in prepublishOnly for npm tarball installs - Add pre-push hook (scripts/check-version-tag-sync.sh) that blocks pushing a v* tag when package.json version doesn't match - Update tests to reflect the new version resolution behavior --- .gitignore | 1 + .pre-commit-config.yaml | 9 +++ bin/lib/version.js | 43 ++++++++++++++ bin/nemoclaw.js | 7 +-- install.sh | 27 +++++++++ scripts/check-version-tag-sync.sh | 96 +++++++++++++++++++++++++++++++ test/install-preflight.test.js | 22 ++++--- 7 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 bin/lib/version.js create mode 100755 scripts/check-version-tag-sync.sh diff --git a/.gitignore b/.gitignore index 4047e0fe2b..d1991c87c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Build artifacts and caches +.version *.pyc *.tsbuildinfo .pytest_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 28919fe8a5..3b2ee25e5b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -213,6 +213,15 @@ repos: stages: [pre-push] priority: 10 + - id: version-tag-sync + name: package.json ↔ git tag version sync + entry: bash scripts/check-version-tag-sync.sh + language: system + always_run: true + pass_filenames: false + stages: [pre-push] + priority: 10 + # ── Priority 20: project-level checks (coverage + ratchet) ───────────────── - repo: local hooks: diff --git a/bin/lib/version.js b/bin/lib/version.js new file mode 100644 index 0000000000..2aabb638d8 --- /dev/null +++ b/bin/lib/version.js @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Resolve the NemoClaw version from (in order): + * 1. `git describe --tags --match "v*"` — works in dev / source checkouts + * 2. `.version` file at repo root — stamped at publish time + * 3. `package.json` version — hard-coded fallback + */ + +const { execFileSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); + +const ROOT = path.resolve(__dirname, "..", ".."); + +function getVersion() { + // 1. Try git (available in dev clones and CI) + try { + const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { + cwd: ROOT, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + // raw looks like "v0.3.0" or "v0.3.0-4-gabcdef1" + if (raw) return raw.replace(/^v/, ""); + } catch { + // no git, or no matching tags — fall through + } + + // 2. Try .version file (stamped by prepublishOnly) + try { + const ver = fs.readFileSync(path.join(ROOT, ".version"), "utf-8").trim(); + if (ver) return ver; + } catch { + // not present — fall through + } + + // 3. Fallback to package.json + return require(path.join(ROOT, "package.json")).version; +} + +module.exports = { getVersion }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index d19317c16d..e45c80cc92 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -42,6 +42,7 @@ const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); const { parseGatewayInference } = require("./lib/inference-config"); +const { getVersion } = require("./lib/version"); const onboardSession = require("./lib/onboard-session"); const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); @@ -1123,9 +1124,8 @@ async function sandboxDestroy(sandboxName, args = []) { // ── Help ───────────────────────────────────────────────────────── function help() { - const pkg = require(path.join(__dirname, "..", "package.json")); console.log(` - ${B}${G}NemoClaw${R} ${D}v${pkg.version}${R} + ${B}${G}NemoClaw${R} ${D}v${getVersion()}${R} ${D}Deploy more secure, always-on AI assistants with a single command.${R} ${G}Getting Started:${R} @@ -1216,8 +1216,7 @@ const [cmd, ...args] = process.argv.slice(2); break; case "--version": case "-v": { - const pkg = require(path.join(__dirname, "..", "package.json")); - console.log(`nemoclaw v${pkg.version}`); + console.log(`nemoclaw v${getVersion()}`); break; } default: diff --git a/install.sh b/install.sh index a902d2c91f..1d87bea3b0 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,25 @@ DEFAULT_NEMOCLAW_VERSION="0.1.0" TOTAL_STEPS=3 resolve_installer_version() { + # Prefer git tags (works in dev clones and CI) + if command -v git &>/dev/null && [[ -d "${SCRIPT_DIR}/.git" ]]; then + local git_ver + git_ver="$(git -C "$SCRIPT_DIR" describe --tags --match 'v*' 2>/dev/null | sed 's/^v//')" + if [[ -n "$git_ver" ]]; then + printf "%s" "$git_ver" + return + fi + fi + # Fall back to .version file (stamped during install) + if [[ -f "${SCRIPT_DIR}/.version" ]]; then + local file_ver + file_ver="$(cat "${SCRIPT_DIR}/.version")" + if [[ -n "$file_ver" ]]; then + printf "%s" "$file_ver" + return + fi + fi + # Last resort: package.json local package_json="${SCRIPT_DIR}/package.json" local version="" if [[ -f "$package_json" ]]; then @@ -693,6 +712,14 @@ install_nemoclaw() { rm -rf "$nemoclaw_src" mkdir -p "$(dirname "$nemoclaw_src")" spin "Cloning NemoClaw source" git clone --depth 1 --branch "$release_ref" https://github.com/NVIDIA/NemoClaw.git "$nemoclaw_src" + # Fetch version tags into the shallow clone so `git describe --tags + # --match "v*"` works at runtime (the shallow clone only has the + # single ref we asked for). + git -C "$nemoclaw_src" fetch --depth=1 origin 'refs/tags/v*:refs/tags/v*' 2>/dev/null || true + # Also stamp .version as a fallback for environments where git is + # unavailable or tags are pruned later. + git -C "$nemoclaw_src" describe --tags --match 'v*' 2>/dev/null \ + | sed 's/^v//' >"$nemoclaw_src/.version" || true spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" diff --git a/scripts/check-version-tag-sync.sh b/scripts/check-version-tag-sync.sh new file mode 100755 index 0000000000..af1a1d669b --- /dev/null +++ b/scripts/check-version-tag-sync.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Pre-push hook: when pushing a v* tag, verify that package.json at the +# tagged commit has a matching version. Blocks the push if they differ. +# +# Usage (called by prek as a pre-push hook): +# echo " " | bash scripts/check-version-tag-sync.sh +# +# Manual check (no stdin needed — compares latest v* tag with package.json): +# bash scripts/check-version-tag-sync.sh --check + +set -euo pipefail + +RED=$'\033[1;31m' +GREEN=$'\033[32m' +DIM=$'\033[2m' +RESET=$'\033[0m' + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Extract the "version" field from the package.json at a given commit. +version_at_commit() { + local sha="$1" + git -C "$ROOT" show "${sha}:package.json" 2>/dev/null \ + | sed -nE 's/^[[:space:]]*"version":[[:space:]]*"([^"]+)".*/\1/p' \ + | head -1 +} + +check_tag() { + local tag="$1" sha="$2" + local tag_version="${tag#v}" + local pkg_version + pkg_version="$(version_at_commit "$sha")" + + if [[ -z "$pkg_version" ]]; then + echo "${RED}✗${RESET} Tag ${tag}: could not read package.json at ${sha:0:8}" >&2 + return 1 + fi + + if [[ "$pkg_version" != "$tag_version" ]]; then + cat >&2 </dev/null || true)" + if [[ -z "$latest_tag" ]]; then + echo "${DIM}No v* tags found — nothing to check.${RESET}" + exit 0 + fi + sha="$(git -C "$ROOT" rev-list -1 "$latest_tag")" + check_tag "$latest_tag" "$sha" + exit $? +fi + +# ------------------------------------------------------------------ +# Pre-push mode: read pushed refs from stdin +# ------------------------------------------------------------------ +errors=0 + +while IFS=' ' read -r local_ref local_sha _remote_ref _remote_sha; do + # Only care about v* tag pushes + case "$local_ref" in + refs/tags/v*) + tag="${local_ref#refs/tags/}" + check_tag "$tag" "$local_sha" || errors=$((errors + 1)) + ;; + esac +done + +if ((errors > 0)); then + exit 1 +fi diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index d27f37fef6..c96e844d51 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -902,8 +902,14 @@ exit 0`, }); expect(result.status).toBe(0); - // git should NOT have been called at all in the source-checkout path - expect(fs.existsSync(gitLog)).toBe(false); + // git clone / git fetch should NOT have been called in the source-checkout path. + // git may be called for version resolution (git describe), so we check + // that no clone or fetch was attempted rather than no git calls at all. + if (fs.existsSync(gitLog)) { + const gitCalls = fs.readFileSync(gitLog, "utf-8"); + expect(gitCalls).not.toMatch(/clone/); + expect(gitCalls).not.toMatch(/fetch/); + } // And curl for the releases API should NOT have been called expect(`${result.stdout}${result.stderr}`).not.toMatch(/curl should not be called/); }); @@ -1043,18 +1049,20 @@ describe("installer pure helpers", () => { // -- resolve_installer_version -- - it("resolve_installer_version: reads version from package.json", () => { + it("resolve_installer_version: reads version from git or package.json", () => { const r = callInstallerFn("resolve_installer_version"); - // Should read from the repo's actual package.json - expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + // May return clean semver ("0.0.2") or git describe format ("0.0.2-3-gabcdef1") + expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(-.+)?$/); }); it("resolve_installer_version: falls back to DEFAULT when no package.json", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-")); - // source from a directory with no package.json — SCRIPT_DIR will be wrong + // source overwrites SCRIPT_DIR, so we re-set it after sourcing. + // The temp dir has no .git, no .version, and no package.json, + // so the function should fall back to DEFAULT_NEMOCLAW_VERSION. const r = spawnSync( "bash", - ["-c", `SCRIPT_DIR="${tmp}"; source "${INSTALLER}" 2>/dev/null; resolve_installer_version`], + ["-c", `source "${INSTALLER}" 2>/dev/null; SCRIPT_DIR="${tmp}"; resolve_installer_version`], { cwd: tmp, encoding: "utf-8", From 26bbb0f83755341d784386c4197f2aa69c41b676 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 31 Mar 2026 20:15:47 -0700 Subject: [PATCH 2/6] fix: resolve ESLint error and speed up gateway recovery tests - snapshot.test.ts: replace forbidden import() type annotation in importOriginal generic with a top-level type-only import - onboard.js: make health-poll count and interval configurable via NEMOCLAW_HEALTH_POLL_COUNT and NEMOCLAW_HEALTH_POLL_INTERVAL env vars (defaults unchanged for production) - cli.test.js: set poll count=1 and interval=0 in test helper to eliminate ~40s of unnecessary sleep in gateway recovery tests - cli.test.js: reduce test timeouts from 25s to 10s accordingly - Apply Prettier formatting fixes from pre-commit hooks --- bin/lib/onboard.js | 12 +++-- nemoclaw/src/blueprint/runner.test.ts | 2 +- nemoclaw/src/blueprint/snapshot.test.ts | 6 +-- nemoclaw/src/blueprint/state.test.ts | 2 +- test/cli.test.js | 34 +++++++------ test/uninstall.test.js | 63 ++++++++----------------- 6 files changed, 52 insertions(+), 67 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 25fd2fb5c6..c92057d162 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2146,7 +2146,9 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { () => { runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: true, env: gatewayEnv }); - for (let i = 0; i < 5; i++) { + const healthPollCount = Number(process.env.NEMOCLAW_HEALTH_POLL_COUNT) || 5; + const healthPollInterval = Number(process.env.NEMOCLAW_HEALTH_POLL_INTERVAL) || 2; + for (let i = 0; i < healthPollCount; i++) { const status = runCaptureOpenshell(["status"], { ignoreError: true }); const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true, @@ -2155,7 +2157,7 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { if (isGatewayHealthy(status, namedInfo, currentInfo)) { return; // success } - if (i < 4) sleep(2); + if (i < healthPollCount - 1) sleep(healthPollInterval); } throw new Error("Gateway failed to start"); @@ -2237,7 +2239,9 @@ async function recoverGatewayRuntime() { }); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - for (let i = 0; i < 10; i++) { + const recoveryPollCount = Number(process.env.NEMOCLAW_HEALTH_POLL_COUNT) || 10; + const recoveryPollInterval = Number(process.env.NEMOCLAW_HEALTH_POLL_INTERVAL) || 2; + for (let i = 0; i < recoveryPollCount; i++) { status = runCaptureOpenshell(["status"], { ignoreError: true }); if (status.includes("Connected") && isSelectedGateway(status)) { process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; @@ -2249,7 +2253,7 @@ async function recoverGatewayRuntime() { } return true; } - sleep(2); + sleep(recoveryPollInterval); } return false; diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index a00aee730b..46249c1815 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -32,7 +32,7 @@ vi.mock("node:crypto", () => ({ })); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal() as typeof import("node:fs"); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/nemoclaw/src/blueprint/snapshot.test.ts b/nemoclaw/src/blueprint/snapshot.test.ts index 6e51d5b7d4..803fff1f6f 100644 --- a/nemoclaw/src/blueprint/snapshot.test.ts +++ b/nemoclaw/src/blueprint/snapshot.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type fs from "node:fs"; const SNAP = "/snap/20260323"; // ── In-memory filesystem ──────────────────────────────────────── @@ -23,13 +24,12 @@ function addDir(p: string): void { const FAKE_HOME = "/fakehome"; - vi.mock("node:os", () => ({ homedir: () => FAKE_HOME, })); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), @@ -127,7 +127,7 @@ describe("snapshot", () => { expect(result).not.toBeNull(); if (!result) throw new Error("createSnapshot returned null"); - + expect(result.startsWith(SNAPSHOTS_DIR)).toBe(true); // Manifest was written diff --git a/nemoclaw/src/blueprint/state.test.ts b/nemoclaw/src/blueprint/state.test.ts index 5a80aff48f..665f96ddfb 100644 --- a/nemoclaw/src/blueprint/state.test.ts +++ b/nemoclaw/src/blueprint/state.test.ts @@ -7,7 +7,7 @@ import { loadState, saveState, clearState, type NemoClawState } from "./state.js const store = new Map(); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal() as typeof import("node:fs"); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/test/cli.test.js b/test/cli.test.js index 976999a00a..241e1d3bd8 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -18,7 +18,13 @@ function runWithEnv(args, env = {}, timeout = 10000) { const out = execSync(`node "${CLI}" ${args}`, { encoding: "utf-8", timeout, - env: { ...process.env, HOME: "/tmp/nemoclaw-cli-test-" + Date.now(), ...env }, + env: { + ...process.env, + HOME: "/tmp/nemoclaw-cli-test-" + Date.now(), + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + ...env, + }, }); return { code: 0, out }; } catch (err) { @@ -1226,7 +1232,7 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(r.code).toBe(0); @@ -1234,7 +1240,7 @@ describe("CLI dispatch", () => { expect(r.out.includes("gateway identity drift after restart")).toBeTruthy(); const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8")); expect(saved.sandboxes.alpha).toBeTruthy(); - }, 25000); + }, 10000); it("recovers status after gateway runtime is reattached", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-status-")); @@ -1366,14 +1372,14 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(r.code).toBe(0); expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy(); expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); expect(r.out.includes("verify the active gateway")).toBeTruthy(); - }, 25000); + }, 10000); it("matches ANSI-decorated gateway transport errors when printing lifecycle hints", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-transport-hint-")); @@ -1430,12 +1436,12 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(r.code).toBe(0); expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy(); - }, 25000); + }, 10000); it("matches ANSI-decorated gateway auth errors when printing lifecycle hints", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-auth-hint-")); @@ -1492,14 +1498,14 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(r.code).toBe(0); expect( r.out.includes("Verify the active gateway and retry after re-establishing the runtime."), ).toBeTruthy(); - }, 25000); + }, 10000); it("explains unrecoverable gateway trust rotation after restart", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-identity-drift-")); @@ -1555,7 +1561,7 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(statusResult.code).toBe(0); expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy(); @@ -1632,7 +1638,7 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(statusResult.code).toBe(0); expect( @@ -1651,7 +1657,7 @@ describe("CLI dispatch", () => { connectResult.out.includes("gateway is still refusing connections after restart"), ).toBeTruthy(); expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy(); - }, 25000); + }, 10000); it("explains when the named gateway is no longer configured after restart or rebuild", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-missing-")); @@ -1709,14 +1715,14 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - 25000, + 10000, ); expect(statusResult.code).toBe(0); expect( statusResult.out.includes("gateway is no longer configured after restart/rebuild"), ).toBeTruthy(); expect(statusResult.out.includes("Start the gateway again")).toBeTruthy(); - }, 25000); + }, 10000); }); describe("list shows live gateway inference", () => { diff --git a/test/uninstall.test.js b/test/uninstall.test.js index 975646c36e..5e37099710 100644 --- a/test/uninstall.test.js +++ b/test/uninstall.test.js @@ -37,21 +37,15 @@ describe("uninstall CLI flags", () => { }); it("--yes skips the confirmation prompt and completes successfully", () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-uninstall-yes-"), - ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-yes-")); const fakeBin = path.join(tmp, "bin"); fs.mkdirSync(fakeBin); try { for (const cmd of ["npm", "openshell", "docker", "ollama", "pgrep"]) { - fs.writeFileSync( - path.join(fakeBin, cmd), - "#!/usr/bin/env bash\nexit 0\n", - { - mode: 0o755, - }, - ); + fs.writeFileSync(path.join(fakeBin, cmd), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); } const result = spawnSync("bash", [UNINSTALL_SCRIPT, "--yes"], { @@ -79,10 +73,7 @@ describe("uninstall helpers", () => { it("returns the expected gateway volume candidate", () => { const result = spawnSync( "bash", - [ - "-c", - `source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`, - ], + ["-c", `source "${UNINSTALL_SCRIPT}"; gateway_volume_candidates nemoclaw`], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", @@ -94,9 +85,7 @@ describe("uninstall helpers", () => { }); it("removes the user-local nemoclaw shim", () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-uninstall-shim-"), - ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-shim-")); const shimDir = path.join(tmp, ".local", "bin"); const shimPath = path.join(shimDir, "nemoclaw"); const targetPath = path.join(tmp, "prefix", "bin", "nemoclaw"); @@ -106,51 +95,37 @@ describe("uninstall helpers", () => { fs.writeFileSync(targetPath, "#!/usr/bin/env bash\n", { mode: 0o755 }); fs.symlinkSync(targetPath, shimPath); - const result = spawnSync( - "bash", - ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], - { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: createFakeNpmEnv(tmp), - }, - ); + const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: createFakeNpmEnv(tmp), + }); expect(result.status).toBe(0); expect(fs.existsSync(shimPath)).toBe(false); }); it("preserves a user-managed nemoclaw file in the shim directory", () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-"), - ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-preserve-")); const shimDir = path.join(tmp, ".local", "bin"); const shimPath = path.join(shimDir, "nemoclaw"); fs.mkdirSync(shimDir, { recursive: true }); fs.writeFileSync(shimPath, "#!/usr/bin/env bash\n", { mode: 0o755 }); - const result = spawnSync( - "bash", - ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], - { - cwd: path.join(import.meta.dirname, ".."), - encoding: "utf-8", - env: createFakeNpmEnv(tmp), - }, - ); + const result = spawnSync("bash", ["-c", `source "${UNINSTALL_SCRIPT}"; remove_nemoclaw_cli`], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: createFakeNpmEnv(tmp), + }); expect(result.status).toBe(0); expect(fs.existsSync(shimPath)).toBe(true); - expect(`${result.stdout}${result.stderr}`).toMatch( - /not an installer-managed shim/, - ); + expect(`${result.stdout}${result.stderr}`).toMatch(/not an installer-managed shim/); }); it("removes the onboard session file as part of NemoClaw state cleanup", () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-uninstall-session-"), - ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-session-")); const stateDir = path.join(tmp, ".nemoclaw"); const sessionPath = path.join(stateDir, "onboard-session.json"); From 4dd989604db5d053a167e3c2338ba067ee762924 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 31 Mar 2026 20:24:12 -0700 Subject: [PATCH 3/6] fix: align importOriginal type pattern across all test files Apply the same import type fs + importOriginal() pattern from snapshot.test.ts to runner.test.ts and state.test.ts for consistency. --- nemoclaw/src/blueprint/runner.test.ts | 3 ++- nemoclaw/src/blueprint/state.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 46249c1815..f13a78dc54 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type fs from "node:fs"; import YAML from "yaml"; // ── In-memory filesystem ──────────────────────────────────────── @@ -32,7 +33,7 @@ vi.mock("node:crypto", () => ({ })); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), diff --git a/nemoclaw/src/blueprint/state.test.ts b/nemoclaw/src/blueprint/state.test.ts index 665f96ddfb..d2efc8ff10 100644 --- a/nemoclaw/src/blueprint/state.test.ts +++ b/nemoclaw/src/blueprint/state.test.ts @@ -2,12 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, vi } from "vitest"; +import type fs from "node:fs"; import { loadState, saveState, clearState, type NemoClawState } from "./state.js"; const store = new Map(); vi.mock("node:fs", async (importOriginal) => { - const original = await importOriginal(); + const original = await importOriginal(); return { ...original, existsSync: (p: string) => store.has(p), From 9cbda4aba38bd432a71f120d0fb47a34ac6ab7c9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 31 Mar 2026 20:26:14 -0700 Subject: [PATCH 4/6] fix: use safe env var parsing for health-poll settings Replace Number(env) || default with a dedicated envInt() helper that handles 0 correctly (Number('0') || 5 would silently fall back to 5), rejects non-finite values, and clamps to non-negative integers. --- bin/lib/onboard.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index c92057d162..d8ed3989cc 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -10,6 +10,14 @@ const os = require("os"); const path = require("path"); const { spawn, spawnSync } = require("child_process"); const pRetry = require("p-retry"); + +/** Parse a numeric env var, returning `fallback` when unset or non-finite. */ +function envInt(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const n = Number(raw); + return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback; +} const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); const { getDefaultOllamaModel, @@ -2146,8 +2154,8 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { () => { runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: true, env: gatewayEnv }); - const healthPollCount = Number(process.env.NEMOCLAW_HEALTH_POLL_COUNT) || 5; - const healthPollInterval = Number(process.env.NEMOCLAW_HEALTH_POLL_INTERVAL) || 2; + const healthPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 5); + const healthPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2); for (let i = 0; i < healthPollCount; i++) { const status = runCaptureOpenshell(["status"], { ignoreError: true }); const namedInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { @@ -2239,8 +2247,8 @@ async function recoverGatewayRuntime() { }); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); - const recoveryPollCount = Number(process.env.NEMOCLAW_HEALTH_POLL_COUNT) || 10; - const recoveryPollInterval = Number(process.env.NEMOCLAW_HEALTH_POLL_INTERVAL) || 2; + const recoveryPollCount = envInt("NEMOCLAW_HEALTH_POLL_COUNT", 10); + const recoveryPollInterval = envInt("NEMOCLAW_HEALTH_POLL_INTERVAL", 2); for (let i = 0; i < recoveryPollCount; i++) { status = runCaptureOpenshell(["status"], { ignoreError: true }); if (status.includes("Connected") && isSelectedGateway(status)) { From e3e8b749c1f22f007c5a0dae0426365eb5f312e9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 31 Mar 2026 20:36:59 -0700 Subject: [PATCH 5/6] fix: skip sleep after final recovery poll attempt The recovery loop in recoverGatewayRuntime slept unconditionally after every iteration, including the last one. Guard with the same i < count - 1 check used in startGatewayWithOptions. --- bin/lib/onboard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index d8ed3989cc..ca9d68ccad 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2261,7 +2261,7 @@ async function recoverGatewayRuntime() { } return true; } - sleep(recoveryPollInterval); + if (i < recoveryPollCount - 1) sleep(recoveryPollInterval); } return false; From c67e67b37cb21de35d71c382134c9443fc388c96 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Tue, 31 Mar 2026 21:03:22 -0700 Subject: [PATCH 6/6] fix(install): fall back cleanly when git tags are unavailable --- install.sh | 12 +++++++----- test/install-preflight.test.js | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 1d87bea3b0..1ecc18ebda 100755 --- a/install.sh +++ b/install.sh @@ -27,11 +27,13 @@ TOTAL_STEPS=3 resolve_installer_version() { # Prefer git tags (works in dev clones and CI) if command -v git &>/dev/null && [[ -d "${SCRIPT_DIR}/.git" ]]; then - local git_ver - git_ver="$(git -C "$SCRIPT_DIR" describe --tags --match 'v*' 2>/dev/null | sed 's/^v//')" - if [[ -n "$git_ver" ]]; then - printf "%s" "$git_ver" - return + local git_ver="" + if git_ver="$(git -C "$SCRIPT_DIR" describe --tags --match 'v*' 2>/dev/null)"; then + git_ver="${git_ver#v}" + if [[ -n "$git_ver" ]]; then + printf "%s" "$git_ver" + return + fi fi fi # Fall back to .version file (stamped during install) diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index c96e844d51..0300710298 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -1055,6 +1055,29 @@ describe("installer pure helpers", () => { expect(r.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(-.+)?$/); }); + it("resolve_installer_version: falls back to package.json when git tags are unavailable", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-pkg-")); + fs.mkdirSync(path.join(tmp, ".git")); + fs.writeFileSync( + path.join(tmp, "package.json"), + `${JSON.stringify({ version: "0.5.0" }, null, 2)}\n`, + ); + // source overwrites SCRIPT_DIR, so we re-set it after sourcing. + // The temp dir advertises git metadata but has no usable tags, + // so the function should fall back to package.json instead of exiting. + const r = spawnSync( + "bash", + ["-c", `source "${INSTALLER}" 2>/dev/null; SCRIPT_DIR="${tmp}"; resolve_installer_version`], + { + cwd: tmp, + encoding: "utf-8", + env: { HOME: tmp, PATH: TEST_SYSTEM_PATH }, + }, + ); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("0.5.0"); + }); + it("resolve_installer_version: falls back to DEFAULT when no package.json", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resolve-ver-")); // source overwrites SCRIPT_DIR, so we re-set it after sourcing.