11// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22// SPDX-License-Identifier: Apache-2.0
33
4+ import fs from "node:fs" ;
5+ import os from "node:os" ;
46import path from "node:path" ;
57
68import { expect , it , vi } from "vitest" ;
@@ -12,12 +14,12 @@ import {
1214} from "./run-plan" ;
1315
1416const FORMULA = "nvidia/openshell/openshell" ;
15- const EXECUTABLE_NAMES = new Set ( [
17+ const EXECUTABLE_NAMES = [
1618 "openshell" ,
1719 "openshell-driver-vm" ,
1820 "openshell-gateway" ,
1921 "openshell-sandbox" ,
20- ] ) ;
22+ ] as const ;
2123
2224function ok ( stdout = "" ) : RunResult {
2325 return { status : 0 , stdout, stderr : "" } ;
@@ -42,51 +44,62 @@ function runUninstallPlan(deps: UninstallRunDeps) {
4244 ) ;
4345}
4446
45- function uninstallOnMacOs ( options : { brewAvailable : boolean ; brewStatus : number | null } ) {
47+ function uninstallOpenShell ( options : {
48+ brewAvailable : boolean ;
49+ brewStatus : number | null ;
50+ platform ?: NodeJS . Platform ;
51+ } ) {
52+ const home = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "nemoclaw-uninstall-homebrew-" ) ) ;
53+ const userBin = path . join ( home , ".local" , "bin" ) ;
54+ const executablePaths = EXECUTABLE_NAMES . map ( ( name ) => path . join ( userBin , name ) ) ;
55+ fs . mkdirSync ( userBin , { mode : 0o700 , recursive : true } ) ;
56+ for ( const executablePath of executablePaths ) {
57+ fs . writeFileSync ( executablePath , "" , { mode : 0o755 } ) ;
58+ }
59+
4660 const calls : string [ ] [ ] = [ ] ;
4761 const logs : string [ ] = [ ] ;
4862 const removed : string [ ] = [ ] ;
63+ const existing = new Set ( executablePaths ) ;
4964
50- const result = runUninstallPlan ( {
51- commandExists : ( command ) =>
52- command === "openshell" || ( command === "brew" && options . brewAvailable ) ,
53- env : { HOME : "/tmp/nemoclaw-uninstall-homebrew" } as NodeJS . ProcessEnv ,
54- existsSync : ( target ) =>
55- EXECUTABLE_NAMES . has ( path . basename ( String ( target ) ) ) &&
56- path . basename ( path . dirname ( String ( target ) ) ) === "bin" ,
57- hasPortableRuntimeCleanup : ( ) => false ,
58- isTty : false ,
59- log : ( line ) => logs . push ( line ) ,
60- platform : "darwin" ,
61- rmSync : vi . fn ( ( target ) => removed . push ( String ( target ) ) ) ,
62- run : vi . fn ( ( command , args ) => {
63- calls . push ( [ command , ...args ] ) ;
64- return command === "openshell" && args [ 0 ] === "gateway" && args [ 1 ] === "list"
65- ? ok ( JSON . stringify ( [ { name : "nemoclaw" } ] ) )
66- : command === "brew" && args [ 0 ] === "list"
67- ? { status : options . brewStatus , stdout : "" , stderr : "" }
68- : ok ( ) ;
69- } ) ,
70- runDocker : ( ) => ok ( ) ,
71- } ) ;
72-
73- return { calls, logs, removed, result } ;
74- }
65+ try {
66+ const result = runUninstallPlan ( {
67+ commandExists : ( command ) =>
68+ command === "openshell" || ( command === "brew" && options . brewAvailable ) ,
69+ env : { HOME : home } as NodeJS . ProcessEnv ,
70+ existsSync : ( target ) => existing . has ( target ) && fs . existsSync ( target ) ,
71+ hasPortableRuntimeCleanup : ( ) => false ,
72+ isTty : false ,
73+ log : ( line ) => logs . push ( line ) ,
74+ platform : options . platform ?? "darwin" ,
75+ rmSync : vi . fn ( ( target ) => removed . push ( String ( target ) ) ) ,
76+ run : vi . fn ( ( command , args ) => {
77+ calls . push ( [ command , ...args ] ) ;
78+ return command === "openshell" && args [ 0 ] === "gateway" && args [ 1 ] === "list"
79+ ? ok ( JSON . stringify ( [ { name : "nemoclaw" } ] ) )
80+ : command === "brew" && args [ 0 ] === "list"
81+ ? { status : options . brewStatus , stdout : "" , stderr : "" }
82+ : ok ( ) ;
83+ } ) ,
84+ runDocker : ( ) => ok ( ) ,
85+ } ) ;
7586
76- function removedOpenShellExecutables ( removed : readonly string [ ] ) : string [ ] {
77- return removed . filter ( ( target ) => EXECUTABLE_NAMES . has ( path . basename ( target ) ) ) ;
87+ return { calls, executablePaths, logs, removed, result } ;
88+ } finally {
89+ fs . rmSync ( home , { force : true , recursive : true } ) ;
90+ }
7891}
7992
8093it ( "retains a Homebrew-managed OpenShell and reports its removal command (#8882)" , ( ) => {
81- const { calls, logs, removed, result } = uninstallOnMacOs ( {
94+ const { calls, logs, removed, result } = uninstallOpenShell ( {
8295 brewAvailable : true ,
8396 brewStatus : 0 ,
8497 } ) ;
8598
8699 expect ( result . exitCode ) . toBe ( 0 ) ;
87100 expect ( calls ) . toContainEqual ( [ "brew" , "list" , "--formula" , FORMULA ] ) ;
88101 expect ( calls . some ( ( call ) => call [ 0 ] === "brew" && call [ 1 ] === "uninstall" ) ) . toBe ( false ) ;
89- expect ( removedOpenShellExecutables ( removed ) ) . toEqual ( [ ] ) ;
102+ expect ( removed ) . toEqual ( [ ] ) ;
90103 expect ( logs ) . toContain (
91104 `Kept Homebrew-managed OpenShell. To remove it, run: brew uninstall ${ FORMULA } ` ,
92105 ) ;
@@ -112,12 +125,24 @@ it.each([
112125 report : `Kept OpenShell executables because Homebrew did not confirm ${ FORMULA } . Check the formula before removing OpenShell.` ,
113126 } ,
114127] ) ( "retains OpenShell when $label (#8882)" , ( { brewAvailable, brewStatus, report } ) => {
115- const { calls, logs, removed, result } = uninstallOnMacOs ( { brewAvailable, brewStatus } ) ;
128+ const { calls, logs, removed, result } = uninstallOpenShell ( { brewAvailable, brewStatus } ) ;
116129
117130 expect ( result . exitCode ) . toBe ( 0 ) ;
118131 expect ( calls . filter ( ( call ) => call [ 0 ] === "brew" ) ) . toEqual (
119132 brewAvailable ? [ [ "brew" , "list" , "--formula" , FORMULA ] ] : [ ] ,
120133 ) ;
121- expect ( removedOpenShellExecutables ( removed ) ) . toEqual ( [ ] ) ;
134+ expect ( removed ) . toEqual ( [ ] ) ;
122135 expect ( logs ) . toContain ( report ) ;
123136} ) ;
137+
138+ it ( "removes managed OpenShell executables on Linux (#8882)" , ( ) => {
139+ const { executablePaths, removed, result } = uninstallOpenShell ( {
140+ brewAvailable : false ,
141+ brewStatus : 0 ,
142+ platform : "linux" ,
143+ } ) ;
144+
145+ expect ( result . exitCode ) . toBe ( 0 ) ;
146+ expect ( new Set ( removed ) ) . toEqual ( new Set ( executablePaths ) ) ;
147+ expect ( removed ) . toHaveLength ( executablePaths . length ) ;
148+ } ) ;
0 commit comments