Skip to content

Commit 3106014

Browse files
Merge branch 'main' into fix/github-policy-protocol
2 parents 38f44e6 + bbec268 commit 3106014

3 files changed

Lines changed: 230 additions & 53 deletions

File tree

bin/nemoclaw.js

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice");
4343
const { runDebugCommand } = require("../dist/lib/debug-command");
4444
const { executeDeploy } = require("../dist/lib/deploy");
4545
const { runStartCommand, runStopCommand } = require("../dist/lib/services-command");
46+
const {
47+
buildVersionedUninstallUrl,
48+
runUninstallCommand,
49+
} = require("../dist/lib/uninstall-command");
4650

4751
// ── Global commands ──────────────────────────────────────────────
4852

@@ -64,8 +68,7 @@ const GLOBAL_COMMANDS = new Set([
6468
"-v",
6569
]);
6670

67-
const REMOTE_UNINSTALL_URL =
68-
"https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh";
71+
const REMOTE_UNINSTALL_URL = buildVersionedUninstallUrl(getVersion());
6972
let OPENSHELL_BIN = null;
7073
const MIN_LOGS_OPENSHELL_VERSION = "0.0.7";
7174
const NEMOCLAW_GATEWAY_NAME = "nemoclaw";
@@ -759,18 +762,6 @@ function printOldLogsCompatibilityGuidance(installedVersion = null) {
759762
);
760763
}
761764

762-
function resolveUninstallScript() {
763-
const candidates = [path.join(ROOT, "uninstall.sh"), path.join(__dirname, "..", "uninstall.sh")];
764-
765-
for (const candidate of candidates) {
766-
if (fs.existsSync(candidate)) {
767-
return candidate;
768-
}
769-
}
770-
771-
return null;
772-
}
773-
774765
function exitWithSpawnResult(result) {
775766
if (result.status !== null) {
776767
process.exit(result.status);
@@ -885,45 +876,18 @@ function debug(args) {
885876
}
886877

887878
function uninstall(args) {
888-
const localScript = resolveUninstallScript();
889-
if (localScript) {
890-
console.log(` Running local uninstall script: ${localScript}`);
891-
const result = spawnSync("bash", [localScript, ...args], {
892-
stdio: "inherit",
893-
cwd: ROOT,
894-
env: process.env,
895-
});
896-
exitWithSpawnResult(result);
897-
}
898-
899-
// Download to file before execution — prevents partial-download execution.
900-
// Upstream URL is a rolling release so SHA-256 pinning isn't practical.
901-
console.log(` Local uninstall script not found; falling back to ${REMOTE_UNINSTALL_URL}`);
902-
const uninstallDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-uninstall-"));
903-
const uninstallScript = path.join(uninstallDir, "uninstall.sh");
904-
let result;
905-
let downloadFailed = false;
906-
try {
907-
try {
908-
execFileSync("curl", ["-fsSL", REMOTE_UNINSTALL_URL, "-o", uninstallScript], {
909-
stdio: "inherit",
910-
});
911-
} catch {
912-
console.error(` Failed to download uninstall script from ${REMOTE_UNINSTALL_URL}`);
913-
downloadFailed = true;
914-
}
915-
if (!downloadFailed) {
916-
result = spawnSync("bash", [uninstallScript, ...args], {
917-
stdio: "inherit",
918-
cwd: ROOT,
919-
env: process.env,
920-
});
921-
}
922-
} finally {
923-
fs.rmSync(uninstallDir, { recursive: true, force: true });
924-
}
925-
if (downloadFailed) process.exit(1);
926-
exitWithSpawnResult(result);
879+
runUninstallCommand({
880+
args,
881+
rootDir: ROOT,
882+
currentDir: __dirname,
883+
remoteScriptUrl: REMOTE_UNINSTALL_URL,
884+
env: process.env,
885+
spawnSyncImpl: spawnSync,
886+
execFileSyncImpl: execFileSync,
887+
log: console.log,
888+
error: console.error,
889+
exit: (code) => process.exit(code),
890+
});
927891
}
928892

929893
function showStatus() {

src/lib/uninstall-command.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it, vi } from "vitest";
5+
6+
import {
7+
buildVersionedUninstallUrl,
8+
exitWithSpawnResult,
9+
resolveUninstallScript,
10+
runUninstallCommand,
11+
} from "../../dist/lib/uninstall-command";
12+
13+
describe("uninstall command", () => {
14+
it("builds a version-pinned uninstall URL", () => {
15+
expect(buildVersionedUninstallUrl("0.1.0")).toBe(
16+
"https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/tags/v0.1.0/uninstall.sh",
17+
);
18+
expect(buildVersionedUninstallUrl("v0.1.0-3-gdeadbee")).toBe(
19+
"https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/tags/v0.1.0/uninstall.sh",
20+
);
21+
});
22+
23+
it("selects the first existing uninstall script", () => {
24+
const script = resolveUninstallScript(["/a", "/b"], (candidate) => candidate === "/b");
25+
expect(script).toBe("/b");
26+
});
27+
28+
it("maps spawn signals to shell-style exit codes", () => {
29+
expect(() =>
30+
exitWithSpawnResult(
31+
{ status: null, signal: "SIGTERM" },
32+
((code: number) => {
33+
throw new Error(`exit:${code}`);
34+
}) as never,
35+
),
36+
).toThrow("exit:143");
37+
});
38+
39+
it("runs the local uninstall script when present", () => {
40+
const spawnSyncImpl = vi.fn(() => ({ status: 0, signal: null }));
41+
expect(() =>
42+
runUninstallCommand({
43+
args: ["--yes"],
44+
rootDir: "/repo",
45+
currentDir: "/repo/bin",
46+
remoteScriptUrl: "https://example.invalid/uninstall.sh",
47+
env: process.env,
48+
spawnSyncImpl,
49+
execFileSyncImpl: vi.fn(),
50+
existsSyncImpl: (candidate) => candidate === "/repo/uninstall.sh",
51+
log: () => {},
52+
error: () => {},
53+
exit: ((code: number) => {
54+
throw new Error(`exit:${code}`);
55+
}) as never,
56+
}),
57+
).toThrow("exit:0");
58+
expect(spawnSyncImpl).toHaveBeenCalledWith("bash", ["/repo/uninstall.sh", "--yes"], {
59+
stdio: "inherit",
60+
cwd: "/repo",
61+
env: process.env,
62+
});
63+
});
64+
65+
it("downloads and runs the remote uninstall script when no local copy exists", () => {
66+
const execFileSyncImpl = vi.fn();
67+
const spawnSyncImpl = vi.fn(() => ({ status: 0, signal: null }));
68+
const rmSyncImpl = vi.fn();
69+
expect(() =>
70+
runUninstallCommand({
71+
args: ["--yes"],
72+
rootDir: "/repo",
73+
currentDir: "/repo/bin",
74+
remoteScriptUrl: "https://example.invalid/uninstall.sh",
75+
env: process.env,
76+
spawnSyncImpl,
77+
execFileSyncImpl,
78+
existsSyncImpl: () => false,
79+
mkdtempSyncImpl: () => "/tmp/nemoclaw-uninstall-123",
80+
rmSyncImpl,
81+
tmpdirFn: () => "/tmp",
82+
log: () => {},
83+
error: () => {},
84+
exit: ((code: number) => {
85+
throw new Error(`exit:${code}`);
86+
}) as never,
87+
}),
88+
).toThrow("exit:0");
89+
expect(execFileSyncImpl).toHaveBeenCalledWith(
90+
"curl",
91+
["-fsSL", "https://example.invalid/uninstall.sh", "-o", "/tmp/nemoclaw-uninstall-123/uninstall.sh"],
92+
{ stdio: "inherit" },
93+
);
94+
expect(rmSyncImpl).toHaveBeenCalledWith("/tmp/nemoclaw-uninstall-123", {
95+
recursive: true,
96+
force: true,
97+
});
98+
});
99+
});

src/lib/uninstall-command.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
import type { SpawnSyncReturns } from "node:child_process";
8+
9+
export function buildVersionedUninstallUrl(version: string): string {
10+
const stableVersion = String(version || "").trim().replace(/^v/, "").replace(/-.*/, "");
11+
return `https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/tags/v${stableVersion}/uninstall.sh`;
12+
}
13+
14+
export function resolveUninstallScript(
15+
candidates: string[],
16+
existsSyncImpl: (path: string) => boolean = fs.existsSync,
17+
): string | null {
18+
for (const candidate of candidates) {
19+
if (existsSyncImpl(candidate)) {
20+
return candidate;
21+
}
22+
}
23+
return null;
24+
}
25+
26+
export function exitWithSpawnResult(
27+
result: Pick<SpawnSyncReturns<string>, "status" | "signal">,
28+
exit: (code: number) => never = (code) => process.exit(code),
29+
): never {
30+
if (result.status !== null) {
31+
return exit(result.status);
32+
}
33+
34+
if (result.signal) {
35+
const signalNumber = os.constants.signals[result.signal];
36+
return exit(signalNumber ? 128 + signalNumber : 1);
37+
}
38+
39+
return exit(1);
40+
}
41+
42+
export interface RunUninstallCommandDeps {
43+
args: string[];
44+
rootDir: string;
45+
currentDir: string;
46+
remoteScriptUrl: string;
47+
env: NodeJS.ProcessEnv;
48+
spawnSyncImpl: (
49+
file: string,
50+
args: string[],
51+
options?: Record<string, unknown>,
52+
) => Pick<SpawnSyncReturns<string>, "status" | "signal">;
53+
execFileSyncImpl: (file: string, args: string[], options?: Record<string, unknown>) => void;
54+
existsSyncImpl?: (path: string) => boolean;
55+
mkdtempSyncImpl?: (prefix: string) => string;
56+
rmSyncImpl?: (path: string, options?: { recursive?: boolean; force?: boolean }) => void;
57+
tmpdirFn?: () => string;
58+
log?: (message?: string) => void;
59+
error?: (message?: string) => void;
60+
exit?: (code: number) => never;
61+
}
62+
63+
export function runUninstallCommand(deps: RunUninstallCommandDeps): never {
64+
const log = deps.log ?? console.log;
65+
const error = deps.error ?? console.error;
66+
const exit = deps.exit ?? ((code: number) => process.exit(code));
67+
const existsSyncImpl = deps.existsSyncImpl ?? fs.existsSync;
68+
const mkdtempSyncImpl = deps.mkdtempSyncImpl ?? fs.mkdtempSync;
69+
const rmSyncImpl = deps.rmSyncImpl ?? fs.rmSync;
70+
const tmpdirFn = deps.tmpdirFn ?? os.tmpdir;
71+
72+
const localScript = resolveUninstallScript(
73+
[path.join(deps.rootDir, "uninstall.sh"), path.join(deps.currentDir, "..", "uninstall.sh")],
74+
existsSyncImpl,
75+
);
76+
if (localScript) {
77+
log(` Running local uninstall script: ${localScript}`);
78+
const result = deps.spawnSyncImpl("bash", [localScript, ...deps.args], {
79+
stdio: "inherit",
80+
cwd: deps.rootDir,
81+
env: deps.env,
82+
});
83+
return exitWithSpawnResult(result, exit);
84+
}
85+
86+
log(` Local uninstall script not found; falling back to ${deps.remoteScriptUrl}`);
87+
const uninstallDir = mkdtempSyncImpl(path.join(tmpdirFn(), "nemoclaw-uninstall-"));
88+
const uninstallScript = path.join(uninstallDir, "uninstall.sh");
89+
let result: Pick<SpawnSyncReturns<string>, "status" | "signal"> | undefined;
90+
let downloadFailed = false;
91+
try {
92+
try {
93+
deps.execFileSyncImpl("curl", ["-fsSL", deps.remoteScriptUrl, "-o", uninstallScript], {
94+
stdio: "inherit",
95+
});
96+
} catch {
97+
error(` Failed to download uninstall script from ${deps.remoteScriptUrl}`);
98+
downloadFailed = true;
99+
}
100+
if (!downloadFailed) {
101+
result = deps.spawnSyncImpl("bash", [uninstallScript, ...deps.args], {
102+
stdio: "inherit",
103+
cwd: deps.rootDir,
104+
env: deps.env,
105+
});
106+
}
107+
} finally {
108+
rmSyncImpl(uninstallDir, { recursive: true, force: true });
109+
}
110+
if (downloadFailed) {
111+
return exit(1);
112+
}
113+
return exitWithSpawnResult(result || { status: 1, signal: null }, exit);
114+
}

0 commit comments

Comments
 (0)