diff --git a/CONSTRUCT.md b/CONSTRUCT.md
index ed5e3ad66..401a3c2d4 100644
--- a/CONSTRUCT.md
+++ b/CONSTRUCT.md
@@ -869,6 +869,14 @@ Allowed keys are:
license text. Only relevant if include_text is True. Any str accepted by open()'s 'errors'
argument is valid. See https://docs.python.org/3/library/functions.html#open.
+### `uninstall_with_conda_exe`
+
+_required:_ no
+_type:_ boolean
+
+Use the standalone binary to perform the uninstallation.
+Requires conda-standalone 24.11.0 or newer.
+
## Available selectors
- `aarch64`
diff --git a/constructor/construct.py b/constructor/construct.py
index 2f80974cb..795284f5b 100644
--- a/constructor/construct.py
+++ b/constructor/construct.py
@@ -643,6 +643,11 @@
- `text_errors` (optional str, default=`None`): How to handle decoding errors when reading the
license text. Only relevant if include_text is True. Any str accepted by open()'s 'errors'
argument is valid. See https://docs.python.org/3/library/functions.html#open.
+'''),
+
+ ('uninstall_with_conda_exe', False, bool, '''
+Use the standalone binary to perform the uninstallation.
+Requires conda-standalone 24.11.0 or newer.
'''),
]
diff --git a/constructor/main.py b/constructor/main.py
index 79fa8c3ef..017670bd8 100644
--- a/constructor/main.py
+++ b/constructor/main.py
@@ -102,6 +102,14 @@ def main_build(dir_path, output_dir='.', platform=cc_platform,
# TODO: Investigate errors on Windows and re-enable
sys.exit("Error: micromamba is not supported on Windows installers.")
+ if (
+ info.get("uninstall_with_conda_exe")
+ and not (
+ exe_type == StandaloneExe.CONDA and exe_version and exe_version >= Version("24.11.0")
+ )
+ ):
+ sys.exit("Error: uninstalling with conda.exe requires conda-standalone 24.11.0 or newer.")
+
logger.debug('conda packages download: %s', info['_download_dir'])
for key in ('welcome_image_text', 'header_image_text'):
@@ -184,7 +192,7 @@ def main_build(dir_path, output_dir='.', platform=cc_platform,
"Will assume it is compatible with shortcuts."
)
elif sys.platform != "win32" and (
- exe_type != StandaloneExe.CONDA or exe_version < Version("23.11.0")
+ exe_type != StandaloneExe.CONDA or (exe_version and exe_version < Version("23.11.0"))
):
logger.warning("conda-standalone 23.11.0 or above is required for shortcuts on Unix.")
info['_enable_shortcuts'] = "incompatible"
diff --git a/constructor/nsis/StandaloneUninstallerOptions.nsh b/constructor/nsis/StandaloneUninstallerOptions.nsh
new file mode 100644
index 000000000..4da385ae5
--- /dev/null
+++ b/constructor/nsis/StandaloneUninstallerOptions.nsh
@@ -0,0 +1,108 @@
+var UninstCustomOptions
+var UninstCustomOptions.RemoveConfigFiles_User
+var UninstCustomOptions.RemoveConfigFiles_System
+var UninstCustomOptions.RemoveUserData
+var UninstCustomOptions.RemoveCaches
+
+# These are the checkbox states, to be used by the uninstaller
+var UninstRemoveConfigFiles_User_State
+var UninstRemoveConfigFiles_System_State
+var UninstRemoveUserData_State
+var UninstRemoveCaches_State
+
+Function un.UninstCustomOptions_InitDefaults
+ StrCpy $UninstRemoveConfigFiles_User_State ${BST_UNCHECKED}
+ StrCpy $UninstRemoveConfigFiles_System_State ${BST_UNCHECKED}
+ StrCpy $UninstRemoveUserData_State ${BST_UNCHECKED}
+ StrCpy $UninstRemoveCaches_State ${BST_UNCHECKED}
+FunctionEnd
+
+Function un.UninstCustomOptions_Show
+ ${If} $UninstRemoveCaches_State == ""
+ Abort
+ ${EndIf}
+ # Create dialog
+ nsDialogs::Create 1018
+ Pop $UninstCustomOptions
+ ${If} $UninstCustomOptions == error
+ Abort
+ ${EndIf}
+
+ !insertmacro MUI_HEADER_TEXT \
+ "Advanced uninstallation options" \
+ "Remove configuration, data, and cache files"
+
+ # We will use $5 as the y axis accumulator, starting at 0
+ # We sum the the number of 'u' units added by 'NSD_Create*' functions
+ IntOp $5 0 + 0
+
+ # Option to remove configuration files
+ ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove user configuration files."
+ IntOp $5 $5 + 11
+ Pop $UninstCustomOptions.RemoveConfigFiles_User
+ ${NSD_SetState} $UninstCustomOptions.RemoveConfigFiles_User $UninstRemoveConfigFiles_User_State
+ ${NSD_OnClick} $UninstCustomOptions.RemoveConfigFiles_User un.UninstRemoveConfigFiles_User_Onclick
+ ${NSD_CreateLabel} 5% "$5u" 90% 10u \
+ "This removes configuration files such as .condarc files in the Users directory."
+ IntOp $5 $5 + 10
+
+ ${If} ${UAC_IsAdmin}
+ ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove system-wide configuration files."
+ IntOp $5 $5 + 11
+ Pop $UninstCustomOptions.RemoveConfigFiles_System
+ ${NSD_SetState} $UninstCustomOptions.RemoveConfigFiles_System $UninstRemoveConfigFiles_System_State
+ ${NSD_OnClick} $UninstCustomOptions.RemoveConfigFiles_System un.UninstRemoveConfigFiles_System_Onclick
+ ${NSD_CreateLabel} 5% "$5u" 90% 10u \
+ "This removes configuration files such as .condarc files in the ProgramData directory."
+ IntOp $5 $5 + 10
+ ${EndIf}
+
+ # Option to remove user data files
+ ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove user data."
+ IntOp $5 $5 + 11
+ Pop $UninstCustomOptions.RemoveUserData
+ ${NSD_SetState} $UninstCustomOptions.RemoveUserData $UninstRemoveUserData_State
+ ${NSD_OnClick} $UninstCustomOptions.RemoveUserData un.UninstRemoveUserData_Onclick
+ ${NSD_CreateLabel} 5% "$5u" 90% 10u \
+ "This removes user data files such as the .conda directory inside the Users folder."
+ IntOp $5 $5 + 10
+
+ # Option to remove caches
+ ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Remove caches."
+ IntOp $5 $5 + 11
+ Pop $UninstCustomOptions.RemoveCaches
+ ${NSD_SetState} $UninstCustomOptions.RemoveCaches $UninstRemoveCaches_State
+ ${NSD_OnClick} $UninstCustomOptions.RemoveCaches un.UninstRemoveCaches_Onclick
+ ${NSD_CreateLabel} 5% "$5u" 90% 10u \
+ "This removes cache directories such as package caches and notices."
+ IntOp $5 $5 + 20
+
+ IntOp $5 $5 + 5
+ ${NSD_CreateLabel} 0 "$5u" 100% 10u \
+ "These options are not recommended if multiple conda installations exist on the same system."
+ IntOp $5 $5 + 10
+ Pop $R0
+ SetCtlColors $R0 ff0000 transparent
+
+ nsDialogs::Show
+FunctionEnd
+
+Function un.UninstRemoveConfigFiles_User_OnClick
+ Pop $0
+ ${NSD_GetState} $0 $UninstRemoveConfigFiles_User_State
+FunctionEnd
+
+Function un.UninstRemoveConfigFiles_System_OnClick
+ Pop $0
+ ${NSD_GetState} $0 $UninstRemoveConfigFiles_System_State
+FunctionEnd
+
+Function un.UninstRemoveUserData_OnClick
+ Pop $0
+ ${NSD_GetState} $0 $UninstRemoveUserData_State
+FunctionEnd
+
+Function un.UninstRemoveCaches_OnClick
+ Pop $0
+ ${NSD_GetState} $0 $UninstRemoveCaches_State
+FunctionEnd
diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl
index 4e2722194..d38fb93b2 100644
--- a/constructor/nsis/main.nsi.tmpl
+++ b/constructor/nsis/main.nsi.tmpl
@@ -67,6 +67,10 @@ var /global StdOutHandleSet
!include "Utils.nsh"
+{%- if uninstall_with_conda_exe %}
+!include "StandaloneUninstallerOptions.nsh"
+{%- endif %}
+
!define NAME {{ installer_name }}
!define VERSION {{ installer_version }}
!define COMPANY {{ company }}
@@ -110,6 +114,11 @@ var /global ARGV_NoScripts
var /global ARGV_NoShortcuts
var /global ARGV_CheckPathLength
var /global ARGV_QuietMode
+{%- if uninstall_with_conda_exe %}
+var /global ARGV_Uninst_RemoveConfigFiles
+var /global ARGV_Uninst_RemoveUserData
+var /global ARGV_Uninst_RemoveCaches
+{%- endif %}
var /global IsDomainUser
var /global CheckPathLength
@@ -195,7 +204,7 @@ Page Custom mui_AnaCustomOptions_Show
{%- if custom_conclusion %}
# Custom conclusion file(s)
{{ CUSTOM_CONCLUSION_FILE }}
-#else
+{%- else %}
!insertmacro MUI_PAGE_FINISH
{%- endif %}
@@ -203,6 +212,9 @@ Page Custom mui_AnaCustomOptions_Show
!insertmacro MUI_UNPAGE_WELCOME
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.OnDirectoryLeave
!insertmacro MUI_UNPAGE_CONFIRM
+{%- if uninstall_with_conda_exe %}
+UninstPage Custom un.UninstCustomOptions_Show
+{%- endif %}
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_UNPAGE_FINISH
@@ -716,7 +728,7 @@ Function .onInit
Pop $0
FunctionEnd
-Function un.onInit
+!macro un.ParseCommandLineArgs
ClearErrors
${GetParameters} $ARGV
${GetOptions} $ARGV "/?" $ARGV_Help
@@ -736,6 +748,11 @@ Function un.onInit
/? (show this help message)$\n\
/S (run in CLI/headless mode)$\n\
/Q (quiet mode, do not print output to console)$\n\
+{%- if uninstall_with_conda_exe %}
+ /RemoveCaches=[0|1] [default: 0]$\n\
+ /RemoveConfigFiles=[none|users|system|all] [default: none]$\n\
+ /RemoveUserData=[0|1] [default: 0]$\n\
+{%- endif %}
/_?=[installation directory] (must be last parameter)$\n\
$\n\
EXAMPLES$\n\
@@ -747,8 +764,7 @@ Function un.onInit
Closing in 10s..."
# Give it some time so users can read it the pop-up console
# The pop-up console happens because the uninstaller copies itself to
- # a temporary location because actually running, so we can't get the parent
- # console handle
+ # a temporary location, so we can't get the parent console handle
Sleep 10000
Abort
${EndIf}
@@ -758,6 +774,59 @@ Function un.onInit
${IfNot} ${Errors}
StrCpy $QuietMode "1"
${EndIf}
+{%- if uninstall_with_conda_exe %}
+ ClearErrors
+ ${GetOptions} $ARGV "/RemoveConfigFiles=" $ARGV_Uninst_RemoveConfigFiles
+ ${IfNot} ${Errors}
+ ${IfNot} ${UAC_IsAdmin}
+ ${If} $ARGV_Uninst_RemoveConfigFiles == "all"
+ ${OrIf} $ARGV_Uninst_RemoveConfigFiles == "system"
+ MessageBox MB_ICONSTOP "Removing system .condarc files requires an elevated prompt."
+ Abort
+ ${EndIf}
+ ${EndIf}
+ ${If} $ARGV_Uninst_RemoveConfigFiles == "user"
+ StrCpy $UninstRemoveConfigFiles_User_State ${BST_CHECKED}
+ StrCpy $UninstRemoveConfigFiles_System_State ${BST_UNCHECKED}
+ ${ElseIf} $ARGV_Uninst_RemoveConfigFiles == "system"
+ StrCpy $UninstRemoveConfigFiles_User_State ${BST_UNCHECKED}
+ StrCpy $UninstRemoveConfigFiles_System_State ${BST_CHECKED}
+ ${ElseIf} $ARGV_Uninst_RemoveConfigFiles == "all"
+ StrCpy $UninstRemoveConfigFiles_User_State ${BST_CHECKED}
+ StrCpy $UninstRemoveConfigFiles_System_State ${BST_CHECKED}
+ ${Else}
+ StrCpy $UninstRemoveConfigFiles_User_State ${BST_UNCHECKED}
+ StrCpy $UninstRemoveConfigFiles_System_State ${BST_UNCHECKED}
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+ ${GetOptions} $ARGV "/RemoveUserData=" $ARGV_Uninst_RemoveUserData
+ ${IfNot} ${Errors}
+ ${If} $ARGV_Uninst_RemoveUserData = "1"
+ StrCpy $UninstRemoveUserData_State ${BST_CHECKED}
+ ${ElseIf} $ARGV_Uninst_RemoveUserData = "0"
+ StrCpy $UninstRemoveUserData_State ${BST_UNCHECKED}
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+ ${GetOptions} $ARGV "/RemoveCaches=" $ARGV_Uninst_RemoveCaches
+ ${IfNot} ${Errors}
+ ${If} $ARGV_Uninst_RemoveCaches = "1"
+ StrCpy $UninstRemoveCaches_State ${BST_CHECKED}
+ ${ElseIf} $ARGV_Uninst_RemoveCaches = "0"
+ StrCpy $UninstRemoveCaches_State ${BST_UNCHECKED}
+ ${EndIf}
+ ${EndIf}
+{%- endif %}
+!macroend
+
+Function un.onInit
+
+{%- if uninstall_with_conda_exe %}
+ Call un.UninstCustomOptions_InitDefaults
+{%- endif %}
Push $0
Push $1
@@ -1384,10 +1453,11 @@ SectionEnd
Section "Uninstall"
${LogSet} on
+ ${If} ${Silent}
+ !insertmacro un.ParseCommandLineArgs
+ ${EndIf}
- # Remove menu items, path entries
System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_ROOT_PREFIX", "$INSTDIR")".r0'
- {{ UNINSTALL_MENUS }}
# ensure that MSVC runtime DLLs are on PATH during uninstallation
ReadEnvStr $0 PATH
@@ -1437,15 +1507,7 @@ Section "Uninstall"
System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_UNATTENDED", "0").r0'
${EndIf}
- !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall"
- !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath"
- !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg"
-
- ${Print} "Removing files and folders..."
- nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"'
-
- # In case the last command fails, run the slow method to remove leftover
- RMDir /r /REBOOTOK "$INSTDIR"
+ {{ UNINSTALL_COMMANDS }}
${If} $INSTALLER_NAME_FULL != ""
DeleteRegKey SHCTX "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$INSTALLER_NAME_FULL"
diff --git a/constructor/winexe.py b/constructor/winexe.py
index 74cf2e4da..ef269d922 100644
--- a/constructor/winexe.py
+++ b/constructor/winexe.py
@@ -12,6 +12,7 @@
from os.path import abspath, basename, dirname, isfile, join
from pathlib import Path
from subprocess import check_output, run
+from textwrap import dedent
from typing import List, Union
from .construct import ns_platform
@@ -196,7 +197,7 @@ def setup_envs_commands(info, dir_path):
return [line.strip() for line in lines]
-def uninstall_menus_commands(info):
+def uninstall_menus_commands(info: dict) -> List[str]:
tmpl = r"""
SetDetailsPrint both
${{Print}} "Deleting {name} menus in {env_name}..."
@@ -214,6 +215,69 @@ def uninstall_menus_commands(info):
return [line.strip() for line in lines]
+def uninstall_commands_default(info: dict) -> List[str]:
+ return uninstall_menus_commands(info) + dedent("""
+ !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall"
+ !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath"
+ !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg"
+
+ ${Print} "Removing files and folders..."
+ nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"'
+
+ # In case the last command fails, run the slow method to remove leftover
+ RMDir /r /REBOOTOK "$INSTDIR"
+ """).splitlines()
+
+
+def uninstall_commands_conda_standalone() -> List[str]:
+ return dedent(r"""
+ !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall"
+ !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath"
+ !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg"
+
+ # Parse arguments
+ StrCpy $R0 ""
+
+ ${If} $UninstRemoveConfigFiles_User_State == ${BST_CHECKED}
+ ${If} $UninstRemoveConfigFiles_System_State == ${BST_CHECKED}
+ StrCpy $R0 "$R0 --remove-config-files=all"
+ ${Else}
+ StrCpy $R0 "$R0 --remove-config-files=user"
+ ${EndIf}
+ ${ElseIf} $UninstRemoveConfigFiles_System_State == ${BST_CHECKED}
+ StrCpy $R0 "$R0 --remove-config-files=system"
+ ${EndIf}
+
+ ${If} $UninstRemoveUserData_State == ${BST_CHECKED}
+ StrCpy $R0 "$R0 --remove-user-data"
+ ${EndIf}
+
+ ${If} $UninstRemoveCaches_State == ${BST_CHECKED}
+ StrCpy $R0 "$R0 --remove-caches"
+ ${EndIf}
+
+ ${Print} "Removing files and folders..."
+ push '"$INSTDIR\_conda.exe" constructor uninstall $R0 --prefix "$INSTDIR"'
+ push 'Failed to remove files and folders. Please see the log for more information.'
+ push 'WithLog'
+ SetDetailsPrint listonly
+ call un.AbortRetryNSExecWait
+ SetDetailsPrint both
+
+ # The uninstallation may leave the install.log, the uninstaller,
+ # and .conda_trash files behind, so remove those manually.
+ ${If} ${FileExists} "$INSTDIR"
+ RMDir /r /REBOOTOK "$INSTDIR"
+ ${EndIf}
+ """).splitlines()
+
+
+def uninstall_commands(info: dict) -> List[str]:
+ if info.get("uninstall_with_conda_exe"):
+ return uninstall_commands_conda_standalone()
+ return uninstall_commands_default(info)
+
+
def make_nsi(
info: dict,
dir_path: str,
@@ -336,6 +400,7 @@ def make_nsi(
variables["custom_conclusion"] = info.get("conclusion_file", "").endswith(".nsi")
variables["has_license"] = bool(info.get("license_file"))
variables["post_install_pages"] = bool(info.get("post_install_pages"))
+ variables["uninstall_with_conda_exe"] = bool(info.get("uninstall_with_conda_exe"))
approx_pkgs_size_kb = approx_size_kb(info, "pkgs")
@@ -352,7 +417,7 @@ def make_nsi(
'uninstall_name',
'${NAME} ${VERSION} (Python ${PYVERSION} ${ARCH})'
)
- variables['UNINSTALL_MENUS'] = '\n '.join(uninstall_menus_commands(info))
+ variables['UNINSTALL_COMMANDS'] = '\n '.join(uninstall_commands(info))
variables['EXTRA_FILES'] = '\n '.join(extra_files_commands(extra_files, dir_path))
variables['SCRIPT_ENV_VARIABLES'] = '\n '.join(setup_script_env_variables(info))
variables['CUSTOM_WELCOME_FILE'] = (
diff --git a/docs/source/cli-options.md b/docs/source/cli-options.md
index a67abc936..b9fa759fe 100644
--- a/docs/source/cli-options.md
+++ b/docs/source/cli-options.md
@@ -54,6 +54,11 @@ $ CONDA_VERBOSITY=3 bash -x my_installer.sh
Windows installers have the following CLI options available:
+- `/NCRC`: disables the CRC check.
+- `/S` (silent): runs the installer in headless mode. Installers created with `constructor 3.10` or
+ later will report information to the active console. See below for different invocation examples.
+- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S`
+ (see above).
- `/InstallationType=[JustMe|AllUsers]`: This flag sets the installation type. The default is
`JustMe`. `AllUsers` might require elevated privileges.
- `/AddToPath=[0|1]`: Whether to add the installation directory to the `PATH` environment
@@ -68,19 +73,37 @@ Windows installers have the following CLI options available:
`0`.
- `/RegisterPython=[0|1]`: Whether to register Python as default in the Windows registry. Defaults
to `1`. This is preferred to `AddToPath`.
-- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S`
- (see below). Also works for the uninstallers.
+- `/D` (directory): sets the default installation directory. Note that even if the path contains
+ spaces, it must be the last parameter used in the command line and must not contain any quotes.
+ Only absolute paths are supported.
-You can also supply [standard NSIS flags](https://nsis.sourceforge.io/Docs/Chapter3.html#installerusage), but only _after_ the ones mentioned above:
+Some of these flags are [standard NSIS flags](https://nsis.sourceforge.io/Docs/Chapter3.html#installerusage).
-- `/NCRC`: disables the CRC check.
-- `/S` (silent): runs the installer or uninstaller in headless mode. Installers created with
- `constructor 3.10` or later will report information to the active console. Note that while the
- installer output will be reported in the active console, the uninstaller output will happen in
- a new console. See below for different invocation examples.
+Windows uninstallers have the following CLI options:
+
+- `/S` (silent): uninstaller in headless mode. Uninstallers created with `constructor 3.10` or
+ later will report information to the console in a new window.
+ See below for different invocation examples.
+- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S`
+ (see above). Also works for the uninstallers.
+- `/RemoveCaches=[0|1]` (only if built with `uninstall_with_conda_exe`):
+ Removes caches such package caches.
+ For details, see the `constructor uninstall` subcommand of the `conda.exe` file.
+- `/RemoveConfigFiles=[none|users|system|all]` (only if built with `uninstall_with_conda_exe`):
+ Removes configuration files such as `.condarc` files. `user` removes the files inside the
+ current user's home directory and `system` removes all files outside of that directory.
+ For details, see the `constructor uninstall` subcommand of the `conda.exe` file.
+- `/RemoveUserData=[0|1]` (only if built with `uninstall_with_conda_exe`):
+ removes user data such as the `~/.conda` directory.
+ For details, see the `constructor uninstall` subcommand of the `conda.exe` file.
+- `/Q` (quiet): do not report to the console in headless mode. Only relevant when used with `/S`
- `/D` (directory): sets the default installation directory. Note that even if the path contains
spaces, it must be the last parameter used in the command line and must not contain any quotes.
- Only absolute paths are supported. The uninstaller uses `_?` instead of `/D`.
+ Only absolute paths are supported.
+- `_?`: like `/D` but prevents the uninstaller from copying itself to the temporary directory.
+
+> [!IMPORTANT]
+> Flags without arguments should precede flags with arguments to avoid parsing errors.
### Examples
@@ -105,12 +128,12 @@ Run the installer in headless mode, for all users, adding to PATH and installing
`````{tab-set}
````{tab-item} CMD
```pwsh
-cmd.exe /C start /wait my_installer.exe /InstallationType=AllUsers /AddToPath=1 /S /D=C:\Program Files\my_app
+cmd.exe /C start /wait my_installer.exe /S /InstallationType=AllUsers /AddToPath=1 /D=C:\Program Files\my_app
```
````
````{tab-item} PowerShell
```pwsh
-Start-Process -FilePath .\my_installer.exe -ArgumentList "/InstallationType=AllUsers /AddToPath=1 /S /D=C:\Program Files\my_app" -NoNewWindow -Wait
+Start-Process -FilePath .\my_installer.exe -ArgumentList "/S /InstallationType=AllUsers /AddToPath=1 /D=C:\Program Files\my_app" -NoNewWindow -Wait
```
````
`````
diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md
index ed5e3ad66..401a3c2d4 100644
--- a/docs/source/construct-yaml.md
+++ b/docs/source/construct-yaml.md
@@ -869,6 +869,14 @@ Allowed keys are:
license text. Only relevant if include_text is True. Any str accepted by open()'s 'errors'
argument is valid. See https://docs.python.org/3/library/functions.html#open.
+### `uninstall_with_conda_exe`
+
+_required:_ no
+_type:_ boolean
+
+Use the standalone binary to perform the uninstallation.
+Requires conda-standalone 24.11.0 or newer.
+
## Available selectors
- `aarch64`
diff --git a/docs/source/howto.md b/docs/source/howto.md
index c6fca5056..970cc17fd 100644
--- a/docs/source/howto.md
+++ b/docs/source/howto.md
@@ -160,4 +160,44 @@ If you want to perform the uninstallation steps manually, you can:
### macOS and Linux
-Remove the installation directory. Usually this is a directory in your user's home directory (user installs), or under `/opt` for system-wide installations.
+:::{note}
+The following sections requires installers to be built using the `uninstall_with_conda_exe` option.
+This is currently only implemented for `conda-standalone` 24.11.0 and higher.
+
+For other installers, all files need to be removed manually.
+:::
+
+Unlike Windows, macOS and Linux installers do not ship an uninstaller executable.
+However, some standalone applications (like `conda-standalone`) provide an uninstaller subcommand.
+The following can be used to uninstall an existing installation:
+
+```bash
+$ $INSTDIR/_conda constructor uninstall --prefix $INSTDIR
+```
+
+where `$INSTDIR` is the installation directory. This command recursively removes all environments
+and removes shell initializers that point to `$INSTDIR`.
+
+The command supports additional options to delete files outside the installation directory:
+
+- `--remove-caches`:
+ Removes cache directories such as package caches and notices.
+ Not recommended with multiple conda installations when softlinks are enabled.
+- `--remove-config-files {user,system,all}`:
+ Removes all configuration files such as `.condarc` files outside the installation directory.
+ `user` removes the files inside the current user's home directory
+ and `system` removes all files outside of that directory.
+- `--remove-user-data`:
+ This removes user data files such as the `~/.conda` directory.
+
+These options are not recommended if multiple conda installations are on the same system because
+they delete commonly used files.
+
+:::{note}
+If removing these files requires superuser privileges, use `sudo -E` instead of `sudo` since
+finding these files may rely on environment variables, especially `$HOME`.
+:::
+
+For more detailed implementation notes, see the documentation of the standalone application:
+
+* [conda-standalone](https://github.com/conda/conda-standalone)
diff --git a/news/897-standalone-uninstallation b/news/897-standalone-uninstallation
new file mode 100644
index 000000000..48d6b9c5c
--- /dev/null
+++ b/news/897-standalone-uninstallation
@@ -0,0 +1,19 @@
+### Enhancements
+
+* Implement feature to run uninstallation via conda-standalone (see https://github.com/conda/conda-standalone/pull/112). (#897)
+
+### Bug fixes
+
+*
+
+### Deprecations
+
+*
+
+### Docs
+
+*
+
+### Other
+
+*
diff --git a/tests/test_examples.py b/tests/test_examples.py
index c0a744783..0cabf2505 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -11,7 +11,7 @@
from functools import lru_cache
from pathlib import Path
from plistlib import load as plist_load
-from typing import Generator, Iterable, Optional, Tuple
+from typing import Generator, Iterable, Optional, Tuple, Union
import pytest
from conda.base.context import context
@@ -33,7 +33,7 @@
pytestmark = pytest.mark.examples
REPO_DIR = Path(__file__).parent.parent
-ON_CI = os.environ.get("CI")
+ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0"
CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE")
CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE)
if CONDA_EXE_VERSION is not None:
@@ -141,7 +141,12 @@ def _run_installer_exe(installer, install_dir, installer_input=None, timeout=420
return process
-def _run_uninstaller_exe(install_dir, timeout=420, check=True):
+def _run_uninstaller_exe(
+ install_dir: Path,
+ timeout: int = 420,
+ check: bool = True,
+ options: Union[list, None] = None,
+) -> Union[subprocess.CompletedProcess, None]:
# Now test the uninstallers
if " " in str(install_dir):
# TODO: We can't seem to run the uninstaller when there are spaces in the PATH
@@ -150,6 +155,7 @@ def _run_uninstaller_exe(install_dir, timeout=420, check=True):
"This is a known issue with our setup, to be fixed."
)
return
+ options = options or []
# Rename install.log
install_log = install_dir / "install.log"
if install_log.exists():
@@ -164,11 +170,13 @@ def _run_uninstaller_exe(install_dir, timeout=420, check=True):
"start",
"/wait",
str(uninstaller),
+ "/S",
+ *options,
# We need silent mode + "uninstaller location" (_?=...) so the command can
# be waited; otherwise, since the uninstaller copies itself to a different
# location so it can be auto-deleted, it returns immediately and it gives
# us problems with the tempdir cleanup later
- f"/S _?={install_dir}",
+ f"_?={install_dir}",
]
process = _execute(cmd, timeout=timeout, check=check)
if check:
@@ -913,6 +921,84 @@ def test_ignore_condarc_files(tmp_path, monkeypatch, request):
assert proc.stderr.count("Bad conversion of configurable") == 4
+@pytest.mark.skipif(
+ CONDA_EXE == StandaloneExe.CONDA and CONDA_EXE_VERSION < Version("24.11.0"),
+ reason="Requires conda-standalone 24.11.x or newer",
+)
+@pytest.mark.skipif(not sys.platform == "win32", reason="Windows only")
+@pytest.mark.skipif(not ON_CI, reason="CI only - Interacts with system files")
+@pytest.mark.parametrize(
+ "remove_user_data,remove_caches,remove_config_files",
+ (
+ pytest.param(False, False, None, id="keep files"),
+ pytest.param(True, True, "all", id="remove all files"),
+ pytest.param(False, False, "system", id="remove system .condarc files"),
+ pytest.param(False, False, "user", id="remove user .condarc files"),
+ )
+)
+def test_uninstallation_standalone(
+ monkeypatch,
+ remove_user_data: bool,
+ remove_caches: bool,
+ remove_config_files: Union[str, None],
+ tmp_path: Path,
+):
+ recipe_path = _example_path("customize_controls")
+ input_path = tmp_path / "input"
+ shutil.copytree(str(recipe_path), str(input_path))
+ with open(input_path / "construct.yaml", "a") as construct:
+ construct.write("uninstall_with_conda_exe: true\n")
+ installer, install_dir = next(create_installer(input_path, tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
+ _run_installer(
+ input_path,
+ installer,
+ install_dir,
+ check_subprocess=True,
+ uninstall=False,
+ )
+
+ # Set up files for removal.
+ # Since conda-standalone is extensively tested upstream,
+ # only set up a minimum set of files.
+ dot_conda_dir = tmp_path / ".conda"
+ assert dot_conda_dir.exists()
+
+ # Minimum set of files needed for an index cache
+ pkg_cache = tmp_path / "pkgs"
+ (pkg_cache / "cache").mkdir(parents=True)
+ (pkg_cache / "urls.txt").touch()
+
+ user_rc = tmp_path / ".condarc"
+ system_rc = Path("C:/ProgramData/conda/.condarc")
+ system_rc.parent.mkdir(parents=True)
+ condarc = f"pkgs_dirs:\n - {pkg_cache}\n"
+ user_rc.write_text(condarc)
+ system_rc.write_text(condarc)
+
+ uninstall_options = []
+ remove_system_rcs = False
+ remove_user_rcs = False
+ if remove_config_files is not None:
+ uninstall_options.append(f"/RemoveConfigFiles={remove_config_files}")
+ remove_system_rcs = remove_config_files != "user"
+ remove_user_rcs = remove_config_files != "system"
+ if remove_user_data:
+ uninstall_options.append("/RemoveUserData=1")
+ if remove_caches:
+ uninstall_options.append("/RemoveCaches=1")
+
+ try:
+ _run_uninstaller_exe(install_dir, check=True, options=uninstall_options)
+ assert dot_conda_dir.exists() != remove_user_data
+ assert pkg_cache.exists() != remove_caches
+ assert system_rc.exists() != remove_system_rcs
+ assert user_rc.exists() != remove_user_rcs
+ finally:
+ if system_rc.parent.exists():
+ shutil.rmtree(system_rc.parent)
+
+
def test_regressions(tmp_path, request):
input_path = _example_path("regressions")
for installer, install_dir in create_installer(input_path, tmp_path):