From 4d95baa1a7dec9bdd97c875fe1e21d8f3cd04bbb Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:11:52 +0000 Subject: [PATCH 01/15] feat(bootstrap): user services Add `scope = "user"` entries to `[bootstrap.services]`: services mise defines for the current user, declared once and installed on every platform through the existing systemd user unit and LaunchAgent implementations plus new Windows Scheduled Task support. Cross-platform `command`, `description`, `restart`, `environment`, `working_directory`, `enabled`, `state` (incl. `absent`), and `requires_tools` have one meaning everywhere; `builtin = "history-watch"` expands to the history watcher run through a durable mise executable. User services converge in the services step, or after tools when they require them; `mise bootstrap services remove` removes an installed definition once. Status, plan, and apply cover both scopes, and user-only fields on a system-scope entry are rejected before anything is written. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test-impl.yml | 1 + docs/.vitepress/cli_commands.ts | 3 + docs/bootstrap/launchd.md | 31 +- docs/bootstrap/services.md | 116 +++- docs/cli/bootstrap.md | 5 +- docs/cli/bootstrap/services.md | 8 +- docs/cli/bootstrap/services/apply.md | 2 +- docs/cli/bootstrap/services/remove.md | 19 + docs/cli/bootstrap/services/status.md | 2 +- docs/cli/index.md | 1 + docs/public/llms.txt | 4 +- e2e-win/services.Tests.ps1 | 94 +++ e2e/cli/test_bootstrap_user_services | 181 ++++++ man/man1/mise.1 | 47 +- mise.usage.kdl | 31 +- scripts/test-bootstrap-linux-host.sh | 14 +- src/cli/bootstrap.rs | 181 +++++- src/cli/command_effects.rs | 1 + src/system/launchd.rs | 84 +++ src/system/mod.rs | 2 + src/system/resources.rs | 7 +- src/system/scheduled_tasks.rs | 526 +++++++++++++++++ src/system/services.rs | 13 +- src/system/services_common.rs | 154 +++++ src/system/services_non_linux.rs | 10 +- src/system/systemd.rs | 47 ++ src/system/user_services.rs | 788 ++++++++++++++++++++++++++ 27 files changed, 2303 insertions(+), 69 deletions(-) create mode 100644 docs/cli/bootstrap/services/remove.md create mode 100644 e2e-win/services.Tests.ps1 create mode 100644 e2e/cli/test_bootstrap_user_services create mode 100644 src/system/scheduled_tasks.rs create mode 100644 src/system/user_services.rs diff --git a/.github/workflows/test-impl.yml b/.github/workflows/test-impl.yml index 83879024309..0a2d61a068f 100644 --- a/.github/workflows/test-impl.yml +++ b/.github/workflows/test-impl.yml @@ -157,6 +157,7 @@ jobs: - run: mise run test:e2e e2e/cli/test_dotfiles_history_policies - run: mise run test:e2e e2e/cli/test_dotfiles_rollback - run: mise run test:e2e e2e/cli/test_dotfiles_rollback_types + - run: mise run test:e2e e2e/cli/test_bootstrap_user_services lint: runs-on: ${{ !inputs.trusted && 'ubuntu-latest' || 'namespace-profile-endev-linux-amd64-large;overrides.cache-tag=cache' }} diff --git a/docs/.vitepress/cli_commands.ts b/docs/.vitepress/cli_commands.ts index a09664d2d02..52591d096ac 100644 --- a/docs/.vitepress/cli_commands.ts +++ b/docs/.vitepress/cli_commands.ts @@ -309,6 +309,9 @@ export const commands: { [key: string]: Command } = { apply: { hide: false, }, + remove: { + hide: false, + }, status: { hide: false, }, diff --git a/docs/bootstrap/launchd.md b/docs/bootstrap/launchd.md index a747db485a2..ba9a01dfcd8 100644 --- a/docs/bootstrap/launchd.md +++ b/docs/bootstrap/launchd.md @@ -33,21 +33,22 @@ as pipes and redirections need an explicitly invoked shell or a wrapper script. ## Supported keys -| TOML key | launchd key | -| ------------------------- | ------------------------- | -| `program` | `ProgramArguments[0]` | -| `args` | `ProgramArguments[1..]` | -| `run_at_load` | `RunAtLoad` | -| `keep_alive` | `KeepAlive` | -| `start_interval` | `StartInterval` | -| `throttle_interval` | `ThrottleInterval` | -| `start_calendar_interval` | `StartCalendarInterval` | -| `queue_directories` | `QueueDirectories` | -| `environment` | `EnvironmentVariables` | -| `working_directory` | `WorkingDirectory` | -| `stdout_path` | `StandardOutPath` | -| `stderr_path` | `StandardErrorPath` | -| `kickstart` | run `launchctl kickstart` | +| TOML key | launchd key | +| ------------------------- | ---------------------------------------- | +| `program` | `ProgramArguments[0]` | +| `args` | `ProgramArguments[1..]` | +| `run_at_load` | `RunAtLoad` | +| `keep_alive` | `KeepAlive` | +| `keep_alive_on_failure` | `KeepAlive = { SuccessfulExit = false }` | +| `start_interval` | `StartInterval` | +| `throttle_interval` | `ThrottleInterval` | +| `start_calendar_interval` | `StartCalendarInterval` | +| `queue_directories` | `QueueDirectories` | +| `environment` | `EnvironmentVariables` | +| `working_directory` | `WorkingDirectory` | +| `stdout_path` | `StandardOutPath` | +| `stderr_path` | `StandardErrorPath` | +| `kickstart` | run `launchctl kickstart` | `program`, `working_directory`, `stdout_path`, `stderr_path`, and each entry in `queue_directories` expand bare `~` and `~/` to the current user's home diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index ef504c3a24d..35c74153f4c 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -1,9 +1,106 @@ -# System services +# Services -`[bootstrap.services]` declaratively manages the lifecycle of existing Linux -systemd system units. Package installation and `[bootstrap.files]` run first, -so a service may be installed by a package or supplied as a managed unit file. -After file changes, mise reloads systemd before applying service changes. +`[bootstrap.services]` declares services in two scopes: + +- **System services** (the default) manage the lifecycle of existing Linux + systemd system units: start, stop, enable, mask, and reload on change. +- **User services** (`scope = "user"`) are services mise defines for the + current user, declared once and installed on every platform: a systemd user + unit on Linux, a LaunchAgent on macOS, a Scheduled Task on Windows. + +## User services + +```toml +[bootstrap.services.mise-history] # the built-in history watcher +builtin = "history-watch" # implies scope = "user" + +[bootstrap.services.my-agent] +scope = "user" +command = "~/.local/bin/my-agent --serve" +description = "My agent" +restart = "on-failure" # "always" | "on-failure" | "never" +environment = { RUST_LOG = "info" } +working_directory = "~" +requires_tools = true # converge after [tools] are installed +``` + +One declaration is rendered for the platform's user service manager: + +| platform | definition | manager | +| -------- | ------------------------------------------------------------------------------------- | ------------------ | +| Linux | `~/.config/systemd/user/dev.mise..service` | `systemctl --user` | +| macOS | `~/Library/LaunchAgents/dev.mise..plist` | `launchctl` | +| Windows | Scheduled Task `mise\` (definition kept under `$MISE_STATE_DIR/user-services/`) | `schtasks` | + +### User service options + +- `command`: the command line to run. `~` and `~/` are expanded. Required + unless `builtin` is set. +- `builtin`: a definition mise supplies. `"history-watch"` runs + `mise history watch` through a durable mise executable with + `restart = "on-failure"` and a low priority. A builtin implies + `scope = "user"`; `command` cannot be combined with it. +- `description`: shown by the service manager. +- `restart`: `"on-failure"` (default), `"always"`, or `"never"`. On Linux this + is `Restart=`; on macOS `KeepAlive` (`{ SuccessfulExit = false }` for + on-failure); on Windows the task restarts up to three times a minute apart + after a failure and runs again at logon. +- `environment` and `working_directory` map directly to the platform + definition. On Windows, environment variables are set through `cmd.exe`. +- `state`: `"running"` (default), `"stopped"` (installed but not running), or + `"absent"` (the installed definition is removed and stays removed while + declared so). +- `enabled`: whether the service starts at login (default `true`). +- `requires_tools`: converge in a second pass after `[tools]` and plugin + package managers, so a service that runs a tool starts after it exists. The + built-in watcher needs only mise and converges in the services step. + +Names must contain only letters, numbers, `.`, `_`, or `-`, and must not also +appear in `[bootstrap.linux.systemd.units]` or +`[bootstrap.macos.launchd.agents]`: both would write the same definition. + +### Durable executable + +A builtin is written with an absolute path to the mise that installed it. +mise uses the running executable unless it lives in a temporary directory or +in the staging directory of `mise bootstrap remote`, and otherwise a `mise` +found on `PATH` outside those. When only a staged binary exists the service is +reported as `unknown: no durable mise executable; install mise on this host +first` and is never written with a path that will be deleted. + +### Remove and disable + +`state = "absent"` removes the installed unit, agent, or task and keeps it +absent on later runs while declared so. Deleting the declaration leaves the +installed service in place until it is removed once: + +```sh +mise bootstrap services remove my-agent +``` + +The next `mise bootstrap` recreates it if it is still declared. + +### Status and apply + +`mise bootstrap services status` and `mise bootstrap services apply` cover +both scopes; `mise bootstrap status` and `mise bootstrap plan` list user +services as `user-service:`. `status --json` includes each user +service's rendered definition, so what mise would install can be inspected +before applying. When the platform's user service manager is unavailable (for +example, no systemd user manager in a container), user services are reported +as `unknown` and skipped with a follow-up note; nothing is written. + +Fields that only apply to user services (`command`, `builtin`, `description`, +`restart`, `environment`, `working_directory`, `requires_tools`, and +`state = "absent"`) are rejected on a system-scope entry, so a missing +`scope = "user"` cannot silently turn a service definition into a lookup of a +system unit. Managed-file notifications apply to system services only. + +## System services + +Package installation and `[bootstrap.files]` run first, so a service may be +installed by a package or supplied as a managed unit file. After file changes, +mise reloads systemd before applying service changes. ```toml [bootstrap.packages] @@ -17,15 +114,16 @@ enabled = true Names without a unit suffix receive `.service`. Explicit unit names such as `postgresql@16-main.service`, sockets, and timers are also accepted. -For user-owned units written under `~/.config/systemd/user`, use -[systemd user units](/bootstrap/systemd.html) instead. This section manages -system units already supplied by packages or [managed files](/bootstrap/files.html). +This section manages system units already supplied by packages or +[managed files](/bootstrap/files.html). A service that runs as your user is a +[user service](#user-services) (`scope = "user"`, above); hand-written user +units go through [systemd user units](/bootstrap/systemd.html). Preview with `mise bootstrap services apply --dry-run`. If the unit will be created by the same configuration, use the full bootstrap to install its package or file before converging the service. -## Options +### System service options - `state`: `"running"` (default) or `"stopped"` - `enabled`: whether the unit starts at boot (default `true`) diff --git a/docs/cli/bootstrap.md b/docs/cli/bootstrap.md index 040ded27cca..84178e1c3ec 100644 --- a/docs/cli/bootstrap.md +++ b/docs/cli/bootstrap.md @@ -17,8 +17,9 @@ Runs the bootstrap steps for the current config in order: 2. Install built-in-manager entries from `[bootstrap.packages]` 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and `[bootstrap.directories]` -4. `mise bootstrap services apply` — converge `[bootstrap.services]` - systemd system services (Linux) +4. `mise bootstrap services apply` — converge `[bootstrap.services]`: + systemd system services (Linux) and user-scope services on every + platform (those with `requires_tools = true` converge after step 14) 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` host firewall policy and rules (Linux) 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` diff --git a/docs/cli/bootstrap/services.md b/docs/cli/bootstrap/services.md index 6e9d064dcd3..7bac5ea308a 100644 --- a/docs/cli/bootstrap/services.md +++ b/docs/cli/bootstrap/services.md @@ -5,7 +5,12 @@ - **Effect:** read-only - **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) -Manage Linux system services from `[bootstrap.services]` +Manage services from `[bootstrap.services]` + +System-scope entries (the default) converge existing Linux systemd system +units. `scope = "user"` entries are services mise defines for the current +user on every platform: a systemd user unit on Linux, a LaunchAgent on +macOS, a Scheduled Task on Windows. ## Flags - **`-h --help`** — Print help @@ -13,4 +18,5 @@ Manage Linux system services from `[bootstrap.services]` ## Subcommands - [`mise bootstrap services apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/services/apply.md) +- [`mise bootstrap services remove [-n --dry-run] `](/cli/bootstrap/services/remove.md) - [`mise bootstrap services status [-J --json] [--missing]`](/cli/bootstrap/services/status.md) diff --git a/docs/cli/bootstrap/services/apply.md b/docs/cli/bootstrap/services/apply.md index 3b92c0bf1f0..0ba88a897d2 100644 --- a/docs/cli/bootstrap/services/apply.md +++ b/docs/cli/bootstrap/services/apply.md @@ -5,7 +5,7 @@ - **Effect:** destructive — may delete or irreversibly overwrite - **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) -Apply configured Linux system service state +Apply configured service state (system and user scope) ## Flags - **`-n --dry-run`** — Print what would change without changing anything diff --git a/docs/cli/bootstrap/services/remove.md b/docs/cli/bootstrap/services/remove.md new file mode 100644 index 00000000000..d985edd4381 --- /dev/null +++ b/docs/cli/bootstrap/services/remove.md @@ -0,0 +1,19 @@ + +# `mise bootstrap services remove` + +- **Usage:** `mise bootstrap services remove [-n --dry-run] ` +- **Effect:** destructive — may delete or irreversibly overwrite +- **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) + +Remove an installed user-scope service, declared or not + +Deleting a `scope = "user"` declaration leaves its installed unit, agent, +or task in place; this removes it once. The next `mise bootstrap` +recreates it if it is still declared. + +## Arguments +- **``** — The service name as declared in `[bootstrap.services]` + +## Flags +- **`-n --dry-run`** — Print what would change without changing anything +- **`-h --help`** — Print help diff --git a/docs/cli/bootstrap/services/status.md b/docs/cli/bootstrap/services/status.md index 742ff0d619d..ff867b1b3e6 100644 --- a/docs/cli/bootstrap/services/status.md +++ b/docs/cli/bootstrap/services/status.md @@ -5,7 +5,7 @@ - **Effect:** read-only - **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) -Show configured Linux system service state +Show configured service state (system and user scope) ## Flags - **`-J --json`** — Output in JSON format diff --git a/docs/cli/index.md b/docs/cli/index.md index 1c09f2abe47..d2a3f1ad966 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -128,6 +128,7 @@ - [`mise bootstrap secrets status [-J --json] [--missing]`](/cli/bootstrap/secrets/status.md) - [`mise bootstrap services `](/cli/bootstrap/services.md) - [`mise bootstrap services apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/services/apply.md) +- [`mise bootstrap services remove [-n --dry-run] `](/cli/bootstrap/services/remove.md) - [`mise bootstrap services status [-J --json] [--missing]`](/cli/bootstrap/services/status.md) - [`mise bootstrap status [FLAGS]`](/cli/bootstrap/status.md) - [`mise bootstrap systemd apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/systemd/apply.md) diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 66bb608e14f..ff99e51d7cb 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -82,7 +82,7 @@ - [Package Plugins](https://mise.jdx.dev/bootstrap/packages/plugins.html): Package manager plugins extend [bootstrap.packages] without adding a manager to mise core. They are useful for machine-global state owned by another tool, such as VS Code extensions, Helm plugins,… - [Linux Users and Groups](https://mise.jdx.dev/bootstrap/accounts.html): [bootstrap.groups] and [bootstrap.users] declaratively manage local Linux accounts. mise applies groups before users and applies accounts before privileged files, so a managed file can safely refer to… - [System Files](https://mise.jdx.dev/bootstrap/files.html): [bootstrap.files] and [bootstrap.directories] declaratively manage absolute paths that may require root privileges. They are separate from [dotfiles], which manages files in a user's home directory. -- [System Services](https://mise.jdx.dev/bootstrap/services.html): [bootstrap.services] declaratively manages the lifecycle of existing Linux systemd system units. Package installation and [bootstrap.files] run first, so a service may be installed by a package or… +- [System Services](https://mise.jdx.dev/bootstrap/services.html): [bootstrap.services] declares services in two scopes. - [Docker Compose Projects](https://mise.jdx.dev/bootstrap/compose.html): [bootstrap.compose] declaratively manages long-running Docker Compose projects after packages, privileged files, directories, and system services have converged. - [Secret Inputs](https://mise.jdx.dev/bootstrap/secrets.html): [bootstrap.secrets] declares the sensitive inputs a bootstrap configuration needs without storing their values in mise configuration. - [Repos](https://mise.jdx.dev/bootstrap/repos.html): mise can declare git repositories in [bootstrap.repos] and apply them with mise bootstrap repos apply or as part of mise bootstrap. @@ -186,7 +186,7 @@ - [mise bootstrap remote](https://mise.jdx.dev/cli/bootstrap/remote.html): Bootstrap one or more machines over OpenSSH - [mise bootstrap repos](https://mise.jdx.dev/cli/bootstrap/repos.html): Manage git repo checkouts from [bootstrap.repos] - [mise bootstrap secrets](https://mise.jdx.dev/cli/bootstrap/secrets.html): Inspect bootstrap secret inputs without revealing their values -- [mise bootstrap services](https://mise.jdx.dev/cli/bootstrap/services.html): Manage Linux system services from [bootstrap.services] +- [mise bootstrap services](https://mise.jdx.dev/cli/bootstrap/services.html): Manage services from [bootstrap.services] - [mise bootstrap status](https://mise.jdx.dev/cli/bootstrap/status.html): Show the aggregate bootstrap status - [mise bootstrap user](https://mise.jdx.dev/cli/bootstrap/user.html): Manage current-user bootstrap settings from [bootstrap.user] - [mise cache](https://mise.jdx.dev/cli/cache.html): Manage the mise cache diff --git a/e2e-win/services.Tests.ps1 b/e2e-win/services.Tests.ps1 new file mode 100644 index 00000000000..1683d4b13e3 --- /dev/null +++ b/e2e-win/services.Tests.ps1 @@ -0,0 +1,94 @@ +Describe 'bootstrap user services' { + BeforeAll { + $script:OriginalDir = Get-Location + Set-Location TestDrive: + + $script:OriginalTrusted = [Environment]::GetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', 'Process') + $env:MISE_TRUSTED_CONFIG_PATHS = $TestDrive + $script:Task = 'mise\mise-e2e-sleep' + } + + AfterAll { + schtasks /delete /tn $script:Task /f 2>&1 | Out-Null + Set-Location $script:OriginalDir + if ($null -eq $script:OriginalTrusted) { + Remove-Item Env:MISE_TRUSTED_CONFIG_PATHS -ErrorAction Ignore + } else { + [Environment]::SetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', $script:OriginalTrusted, 'Process') + } + } + + It 'renders and validates a user service without installing it' { + @" +[bootstrap.services.agent] +scope = "user" +command = "C:\\Tools\\agent.exe --serve" +environment = { RUST_LOG = "info" } + +[bootstrap.services.mise-history] +builtin = "history-watch" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + + $json = mise bootstrap services status --json 2>&1 | Out-String + $LASTEXITCODE | Should -Be 0 + $status = $json | ConvertFrom-Json + $agent = $status.user_services | Where-Object { $_.name -eq 'agent' } + $agent.definition | Should -BeLike '*cmd.exe*' + $agent.definition | Should -BeLike '*RUST_LOG=info*' + $agent.current | Should -Be 'not installed' + $history = $status.user_services | Where-Object { $_.name -eq 'mise-history' } + $history.command | Should -BeLike '*history watch*' + $history.definition | Should -BeLike '**' + + @" +[bootstrap.services.docker] +command = "dockerd" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + $out = mise bootstrap services status 2>&1 | Out-String + $LASTEXITCODE | Should -Not -Be 0 + $out | Should -BeLike '*only applies to `scope = "user"` services*' + } + + It 'installs, runs, and removes a scheduled task' { + @" +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "powershell.exe -NoProfile -Command Start-Sleep 300" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + + mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null + $LASTEXITCODE | Should -Be 0 + schtasks /query /tn $script:Task 2>&1 | Out-Null + $LASTEXITCODE | Should -Be 0 + $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json).user_services[0] + $status.current | Should -Be 'running' + $status.action | Should -Be 'noop' + + @" +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "powershell.exe -NoProfile -Command Start-Sleep 300" +state = "absent" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json).user_services[0] + $status.action | Should -Be 'remove' + mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null + $LASTEXITCODE | Should -Be 0 + schtasks /query /tn $script:Task 2>&1 | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + + @" +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "powershell.exe -NoProfile -Command Start-Sleep 300" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null + $LASTEXITCODE | Should -Be 0 + "[tools]" | Out-File -FilePath mise.toml -Encoding utf8NoBOM + $out = mise bootstrap services remove mise-e2e-sleep 2>&1 | Out-String + $LASTEXITCODE | Should -Be 0 + $out | Should -BeLike '*removed its Scheduled Task*' + schtasks /query /tn $script:Task 2>&1 | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } +} diff --git a/e2e/cli/test_bootstrap_user_services b/e2e/cli/test_bootstrap_user_services new file mode 100644 index 00000000000..2db46f327f9 --- /dev/null +++ b/e2e/cli/test_bootstrap_user_services @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# `[bootstrap.services]` with `scope = "user"`: one declaration rendered for the +# platform's user service manager, validated before anything is written, and +# reported as unknown (never written) when that manager is unavailable. +# shellcheck disable=SC2016 + +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "~/.local/bin/agent --serve" +description = "My agent" +restart = "always" +environment = { RUST_LOG = "info" } +working_directory = "~" + +[bootstrap.services.mise-history] +builtin = "history-watch" + +[bootstrap.services.later] +scope = "user" +command = "node server.js" +requires_tools = true +state = "stopped" +enabled = false + +[bootstrap.services.gone] +scope = "user" +command = "x" +state = "absent" +EOF + +# every user service is listed by the services, aggregate status, and plan +# commands with its desired state +assert_contains "mise bootstrap services status" "user-service:agent" +assert_contains "mise bootstrap services status" "user-service:mise-history" +assert_contains "mise bootstrap services status" "stopped (not at login)" +assert_contains "mise bootstrap services status" "absent" +assert_contains "mise bootstrap status" "user-service" +assert_contains "mise bootstrap plan" "user-service:later" + +# the rendered definition is inspectable before anything is installed, and a +# builtin runs through the mise that installed it +assert "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"agent\") | .restart'" "always" +assert "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"later\") | .requires_tools'" "true" +assert "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .builtin'" "history-watch" +assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .command'" "history watch" +if [[ "$(uname -s)" == "Linux" ]]; then + assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" "Restart=always" + assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" 'Environment="RUST_LOG=info"' + assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .definition'" "Nice=10" + assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .path'" "/.config/systemd/user/dev.mise.mise-history.service" +fi + +# without a user service manager (this container has none) nothing is written: +# status reports unknown, apply and the full bootstrap skip with a follow-up +if ! mise bootstrap services status --json | jq -e '.user_services | all(.action != "unknown")' >/dev/null; then + assert_contains "mise bootstrap services status --json" '"action": "unknown"' + assert_fail "mise bootstrap services status --missing" + assert_contains "mise bootstrap services apply --dry-run --yes 2>&1" "skipped" + assert_contains "mise bootstrap --only services --dry-run --yes 2>&1" "skipped" + assert_fail "test -e $HOME/.config/systemd/user/dev.mise.agent.service" + assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.agent.plist" + assert_fail "mise bootstrap services remove agent" "cannot remove user service 'agent'" +fi + +# validation happens before any change: a system-scope entry cannot carry +# user-only fields, a user service needs a command or a builtin, and names +# shared with the platform-specific tables are rejected +cat <<'EOF' >mise.toml +[bootstrap.services.docker] +command = "dockerd" +EOF +assert_fail "mise bootstrap services status" 'sets command, which only applies to `scope = "user"` services' +cat <<'EOF' >mise.toml +[bootstrap.services.docker] +state = "absent" +EOF +assert_fail "mise bootstrap plan" 'sets state = "absent", which only applies to' +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +EOF +assert_fail "mise bootstrap services status" 'must set `command` or `builtin`' +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +builtin = "history-watch" +command = "x" +EOF +assert_fail "mise bootstrap services status" 'sets both `builtin` and `command`' +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +builtin = "nope" +EOF +assert_fail "mise bootstrap services status" "unknown builtin 'nope'; available: history-watch" +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" +masked = true +EOF +assert_fail "mise bootstrap services status" "cannot be masked" +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" + +[bootstrap.linux.systemd.units.agent] +exec_start = "agent" +EOF +assert_fail "mise bootstrap services status" "also declared in [bootstrap.linux.systemd.units]" +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" + +[bootstrap.macos.launchd.agents.agent] +program = "agent" +EOF +assert_fail "mise bootstrap services status" "also declared in [bootstrap.macos.launchd.agents]" + +# managed-file notifications apply to system services only +cat <mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" + +[bootstrap.files."$PWD/notify"] +content = "x" +notify = ["agent"] +EOF +assert_fail "mise bootstrap plan" "notifies user-scope bootstrap service 'agent'" + +# a name is never guessed: the removal of an undeclared service is explicit +cat <<'EOF' >mise.toml +[tools] +EOF +assert_fail "mise bootstrap services remove 'bad name'" "must contain only letters" + +# the effective scope follows `builtin`, and skipping the services part skips +# user services too +cat <<'EOF' >mise.toml +[bootstrap.services.mise-history] +builtin = "history-watch" +EOF +assert_not_contains "mise bootstrap --skip services --dry-run --yes 2>&1" "user services" +assert_contains "mise bootstrap services status --json | jq -r '.user_services[0].scope'" "user" + +# on macOS the LaunchAgent is really installed, started, and removed +if [[ "$(uname -s)" == "Darwin" ]] && mise bootstrap services status --json | jq -e '.user_services | all(.action != "unknown")' >/dev/null; then + cat <<'TOML' >mise.toml +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "/bin/sleep 300" +TOML + assert_succeed "mise bootstrap services apply --yes" + assert "test -f $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist && echo yes" "yes" + assert "mise bootstrap services status --json | jq -r '.user_services[0] | \"\\(.current) \\(.action)\"'" "running noop" + assert_contains "mise bootstrap services status --json | jq -r '.user_services[0].definition'" "SuccessfulExit" + assert_succeed "mise bootstrap services status --missing" + cat <<'TOML' >mise.toml +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "/bin/sleep 300" +state = "absent" +TOML + assert "mise bootstrap services status --json | jq -r '.user_services[0] | \"\\(.current) \\(.action)\"'" "installed remove" + assert_succeed "mise bootstrap services apply --yes" + assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist" + assert "mise bootstrap services status --json | jq -r '.user_services[0] | \"\\(.current) \\(.action)\"'" "absent noop" + # an undeclared leftover is removed once, explicitly + cat <<'TOML' >mise.toml +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "/bin/sleep 300" +TOML + assert_succeed "mise bootstrap services apply --yes" + echo "[tools]" >mise.toml + assert_contains "mise bootstrap services remove mise-e2e-sleep 2>&1" "removed its LaunchAgent" + assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist" + assert_contains "mise bootstrap services remove mise-e2e-sleep 2>&1" "no LaunchAgent installed" +fi diff --git a/man/man1/mise.1 b/man/man1/mise.1 index a1ab397ed7a..c4677b65d97 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -398,13 +398,16 @@ Inspect bootstrap secret inputs without revealing their values Show whether declared bootstrap secret inputs are available .TP \fBbootstrap services\fR -Manage Linux system services from `[bootstrap.services]` +Manage services from `[bootstrap.services]` .TP \fBbootstrap services apply\fR -Apply configured Linux system service state +Apply configured service state (system and user scope) +.TP +\fBbootstrap services remove\fR +Remove an installed user\-scope service, declared or not .TP \fBbootstrap services status\fR -Show configured Linux system service state +Show configured service state (system and user scope) .TP \fBbootstrap status\fR Show the aggregate bootstrap status @@ -1094,8 +1097,9 @@ Runs the bootstrap steps for the current config in order: 2. Install built\-in\-manager entries from `[bootstrap.packages]` 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and `[bootstrap.directories]` -4. `mise bootstrap services apply` — converge `[bootstrap.services]` - systemd system services (Linux) +4. `mise bootstrap services apply` — converge `[bootstrap.services]`: + systemd system services (Linux) and user\-scope services on every + platform (those with `requires_tools = true` converge after step 14) 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` host firewall policy and rules (Linux) 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` @@ -2612,7 +2616,12 @@ Exit with code 1 if a declared secret input is unavailable \fB\-h, \-\-help\fR Print help .SH "MISE BOOTSTRAP SERVICES" -Manage Linux system services from `[bootstrap.services]` +Manage services from `[bootstrap.services]` + +System\-scope entries (the default) converge existing Linux systemd system +units. `scope = "user"` entries are services mise defines for the current +user on every platform: a systemd user unit on Linux, a LaunchAgent on +macOS, a Scheduled Task on Windows. .PP \fBUsage:\fR mise bootstrap services [OPTIONS] .PP @@ -2622,7 +2631,7 @@ Manage Linux system services from `[bootstrap.services]` \fB\-h, \-\-help\fR Print help .SH "MISE BOOTSTRAP SERVICES APPLY" -Apply configured Linux system service state +Apply configured service state (system and user scope) .PP \fBUsage:\fR mise bootstrap services apply [OPTIONS] .PP @@ -2637,8 +2646,30 @@ Skip the confirmation prompt .TP \fB\-h, \-\-help\fR Print help +.SH "MISE BOOTSTRAP SERVICES REMOVE" +Remove an installed user\-scope service, declared or not + +Deleting a `scope = "user"` declaration leaves its installed unit, agent, +or task in place; this removes it once. The next `mise bootstrap` +recreates it if it is still declared. +.PP +\fBUsage:\fR mise bootstrap services remove [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-n, \-\-dry\-run\fR +Print what would change without changing anything +.TP +\fB\-h, \-\-help\fR +Print help +\fBArguments:\fR +.PP +.TP +\fB\fR +The service name as declared in `[bootstrap.services]` .SH "MISE BOOTSTRAP SERVICES STATUS" -Show configured Linux system service state +Show configured service state (system and user scope) .PP \fBUsage:\fR mise bootstrap services status [OPTIONS] .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index fcc0f40a524..12ae7db076d 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -409,8 +409,9 @@ Runs the bootstrap steps for the current config in order: 2. Install built-in-manager entries from `[bootstrap.packages]` 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and `[bootstrap.directories]` -4. `mise bootstrap services apply` — converge `[bootstrap.services]` - systemd system services (Linux) +4. `mise bootstrap services apply` — converge `[bootstrap.services]`: + systemd system services (Linux) and user-scope services on every + platform (those with `requires_tools = true` converge after step 14) 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` host firewall policy and rules (Linux) 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` @@ -1347,14 +1348,34 @@ Defaults to `~/.local/bin/mise`; pass `--install-mise=` for another path. flag "-h --help" help="Print help" action=help builtin=#true } } - cmd services subcommand_required=#true help="Manage Linux system services from `[bootstrap.services]`" effect=read { + cmd services subcommand_required=#true help="Manage services from `[bootstrap.services]`" effect=read { + long_help #""" +Manage services from `[bootstrap.services]` + +System-scope entries (the default) converge existing Linux systemd system +units. `scope = "user"` entries are services mise defines for the current +user on every platform: a systemd user unit on Linux, a LaunchAgent on +macOS, a Scheduled Task on Windows. +"""# flag "-h --help" help="Print help" action=help builtin=#true - cmd apply help="Apply configured Linux system service state" effect=destructive { + cmd apply help="Apply configured service state (system and user scope)" effect=destructive { flag "-n --dry-run" help="Print what would change without changing anything" flag "-y --yes" help="Skip the confirmation prompt" flag "-h --help" help="Print help" action=help builtin=#true } - cmd status help="Show configured Linux system service state" effect=read { + cmd remove help="Remove an installed user-scope service, declared or not" effect=destructive { + long_help #""" +Remove an installed user-scope service, declared or not + +Deleting a `scope = "user"` declaration leaves its installed unit, agent, +or task in place; this removes it once. The next `mise bootstrap` +recreates it if it is still declared. +"""# + flag "-n --dry-run" help="Print what would change without changing anything" + flag "-h --help" help="Print help" action=help builtin=#true + arg help="The service name as declared in `[bootstrap.services]`" + } + cmd status help="Show configured service state (system and user scope)" effect=read { flag "-J --json" help="Output in JSON format" flag --missing help="Exit with code 1 when any service is not converged" flag "-h --help" help="Print help" action=help builtin=#true diff --git a/scripts/test-bootstrap-linux-host.sh b/scripts/test-bootstrap-linux-host.sh index da1e78c7c5b..3dd65cf4d9c 100755 --- a/scripts/test-bootstrap-linux-host.sh +++ b/scripts/test-bootstrap-linux-host.sh @@ -94,6 +94,11 @@ state = "running" enabled = true on_change = "restart" +[bootstrap.services.mise-case-user] +scope = "user" +command = "/bin/sleep 300" +description = "mise bootstrap user-service smoke" + [bootstrap.linux.firewall] backend = "nftables" state = "enabled" @@ -201,7 +206,14 @@ ssh "${ssh_args[@]}" \ systemctl is-enabled --quiet mise-case.service systemctl is-active --quiet docker.service nft list table inet mise_bootstrap | grep -q mise-bootstrap - docker compose --project-directory /opt/mise-case --file /opt/mise-case/compose.yaml --project-name mise-bootstrap-smoke ps --status running --quiet | grep -q .' + docker compose --project-directory /opt/mise-case --file /opt/mise-case/compose.yaml --project-name mise-bootstrap-smoke ps --status running --quiet | grep -q . + if systemctl --user is-system-running >/dev/null 2>&1; then + test -f "$HOME/.config/systemd/user/dev.mise.mise-case-user.service" + systemctl --user is-active --quiet dev.mise.mise-case-user.service + else + echo "no systemd user manager for root on this host; user-service leg reports only" + ! test -e "$HOME/.config/systemd/user/dev.mise.mise-case-user.service" + fi' ssh "${ssh_args[@]}" \ 'set -eu diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index ede2bedbc0c..57e1fe7ee5e 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -48,8 +48,9 @@ use crate::ui::table::MiseTable; /// 2. Install built-in-manager entries from `[bootstrap.packages]` /// 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and /// `[bootstrap.directories]` -/// 4. `mise bootstrap services apply` — converge `[bootstrap.services]` -/// systemd system services (Linux) +/// 4. `mise bootstrap services apply` — converge `[bootstrap.services]`: +/// systemd system services (Linux) and user-scope services on every +/// platform (those with `requires_tools = true` converge after step 14) /// 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` /// host firewall policy and rules (Linux) /// 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` @@ -494,7 +495,12 @@ struct BootstrapFilesStatus { prompt_secrets: bool, } -/// Manage Linux system services from `[bootstrap.services]` +/// Manage services from `[bootstrap.services]` +/// +/// System-scope entries (the default) converge existing Linux systemd system +/// units. `scope = "user"` entries are services mise defines for the current +/// user on every platform: a systemd user unit on Linux, a LaunchAgent on +/// macOS, a Scheduled Task on Windows. #[derive(Debug, usage_rs::Args)] #[usage(verbatim_doc_comment)] struct BootstrapServices { @@ -505,10 +511,11 @@ struct BootstrapServices { #[derive(Debug, usage_rs::Subcommands)] enum BootstrapServicesCommands { Apply(BootstrapServicesApply), + Remove(BootstrapServicesRemove), Status(BootstrapServicesStatus), } -/// Apply configured Linux system service state +/// Apply configured service state (system and user scope) #[derive(Debug, usage_rs::Args)] struct BootstrapServicesApply { /// Print what would change without changing anything @@ -520,7 +527,23 @@ struct BootstrapServicesApply { yes: bool, } -/// Show configured Linux system service state +/// Remove an installed user-scope service, declared or not +/// +/// Deleting a `scope = "user"` declaration leaves its installed unit, agent, +/// or task in place; this removes it once. The next `mise bootstrap` +/// recreates it if it is still declared. +#[derive(Debug, usage_rs::Args)] +#[usage(verbatim_doc_comment)] +struct BootstrapServicesRemove { + /// The service name as declared in `[bootstrap.services]` + name: String, + + /// Print what would change without changing anything + #[usage(long, short = 'n')] + dry_run: bool, +} + +/// Show configured service state (system and user scope) #[derive(Debug, usage_rs::Args)] struct BootstrapServicesStatus { /// Output in JSON format @@ -1313,10 +1336,16 @@ impl Bootstrap { let services = configured_services .as_ref() .expect("configured notifications prepared services"); - system::services::validate_notifications(files, directories, services)?; + let user_services = system::services_common::user_service_names(&config)?; + system::services::validate_notifications(files, directories, services, &user_services)?; } let mut managed_services = services_enabled.then_some(configured_services.unwrap_or_default()); + let user_services = if services_enabled { + system::user_services::requests_from_config(&config)? + } else { + vec![] + }; let mut managed_firewall = if skip.contains(&BootstrapPart::Firewall) { None } else { @@ -1459,8 +1488,14 @@ impl Bootstrap { self.yes, )?; } + let early = user_services + .iter() + .filter(|request| !request.requires_tools) + .cloned() + .collect::>(); + apply_user_services(&early, self.dry_run, self.yes, Some(&mut follow_up)).await?; } else { - debug!("bootstrap: system services skipped"); + debug!("bootstrap: services skipped"); } if skip.contains(&BootstrapPart::Firewall) { @@ -1727,6 +1762,18 @@ impl Bootstrap { } } + let late = user_services + .iter() + .filter(|request| request.requires_tools) + .cloned() + .collect::>(); + if !late.is_empty() { + if skip.contains(&BootstrapPart::Tools) { + info!("bootstrap: tools skipped; user services with requires_tools still converge"); + } + apply_user_services(&late, self.dry_run, self.yes, Some(&mut follow_up)).await?; + } + if skip.contains(&BootstrapPart::Task) { debug!("bootstrap: `bootstrap` task skipped"); } else { @@ -2519,7 +2566,13 @@ impl BootstrapFilesApply { .any(|directory| !directory.notify.is_empty()) { let services = system::services::prepare_requests_from_config(&config)?; - system::services::validate_notifications(&files, &directories, &services)?; + let user_services = system::services_common::user_service_names(&config)?; + system::services::validate_notifications( + &files, + &directories, + &services, + &user_services, + )?; Some(services) } else { None @@ -2611,11 +2664,47 @@ impl BootstrapServices { async fn run(self) -> Result<()> { match self.command { BootstrapServicesCommands::Apply(command) => command.run().await, + BootstrapServicesCommands::Remove(command) => command.run().await, BootstrapServicesCommands::Status(command) => command.run().await, } } } +impl BootstrapServicesRemove { + async fn run(self) -> Result<()> { + OperationScope::wrap( + "bootstrap services remove", + "services", + self.dry_run, + self.run_inner(), + ) + .await + } + + async fn run_inner(self) -> Result<()> { + let config = Config::get().await?; + let declared = system::user_services::requests_from_config(&config)? + .iter() + .any(|request| request.name == self.name); + let removed = system::user_services::remove_named(&self.name, self.dry_run).await?; + let manager = system::user_services::manager_name(); + if !removed { + info!("user service {}: no {manager} installed", self.name); + } else if self.dry_run { + info!("user service {}: would remove its {manager}", self.name); + } else { + info!("user service {}: removed its {manager}", self.name); + } + if declared { + info!( + "user service {} is still declared in [bootstrap.services]; the next `mise bootstrap` recreates it", + self.name + ); + } + Ok(()) + } +} + impl BootstrapServicesApply { async fn run(self) -> Result<()> { OperationScope::wrap( @@ -2630,25 +2719,61 @@ impl BootstrapServicesApply { async fn run_inner(self) -> Result<()> { let config = Config::get().await?; let requests = system::services::requests_from_config(&config)?; - system::services::apply(&requests, self.dry_run, self.yes) + let user_requests = system::user_services::requests_from_config(&config)?; + system::services::apply(&requests, self.dry_run, self.yes)?; + apply_user_services(&user_requests, self.dry_run, self.yes, None).await + } +} + +/// Converge user-scope services, reporting an unavailable service manager as +/// a skipped follow-up instead of a failure. +async fn apply_user_services( + requests: &[system::user_services::UserServiceRequest], + dry_run: bool, + yes: bool, + follow_up: Option<&mut BootstrapFollowUp>, +) -> Result<()> { + if requests.is_empty() { + return Ok(()); } + info!("bootstrap: user services"); + if let Some(reason) = system::user_services::apply(requests, dry_run, yes).await? { + let message = format!( + "user services: {} service(s) skipped ({reason})", + requests.len() + ); + match follow_up { + Some(follow_up) => follow_up.add_skipped(message), + None => warn!("{message}"), + } + } + Ok(()) } impl BootstrapServicesStatus { async fn run(self) -> Result<()> { let config = Config::get().await?; let requests = system::services::requests_from_config(&config)?; - let resources = system::services::plans_with_notifications( + let mut resources = system::services::plans_with_notifications( &requests, &system::services::ServiceNotifications::default(), ); + let user_requests = system::user_services::requests_from_config(&config)?; + let user_statuses = system::user_services::status(&user_requests).await?; + resources.extend(user_statuses.iter().map(|status| status.plan())); let missing = resources .iter() .any(|resource| resource.action != system::resources::ResourceAction::Noop); if self.json { - miseprintln!("{}", serde_json::to_string_pretty(&resources)?); + miseprintln!( + "{}", + serde_json::to_string_pretty(&json!({ + "resources": resources, + "user_services": user_statuses, + }))? + ); } else if resources.is_empty() { - info!("no bootstrap system services configured"); + info!("no bootstrap services configured"); } else { let mut table = MiseTable::new(false, &["Action", "Resource", "Current", "Desired"]); for resource in resources { @@ -3074,7 +3199,13 @@ impl BootstrapStatus { )?; let service_requests = system::services::status_requests_from_config(config)?; let firewall_request = system::firewall::status_request_from_config(config)?; - system::services::validate_notifications(&files, &directories, &service_requests)?; + let user_services = system::services_common::user_service_names(config)?; + system::services::validate_notifications( + &files, + &directories, + &service_requests, + &user_services, + )?; let notified_services = system::managed_files::pending_notifications(&files, &directories)?; let compose_requests = system::compose::requests_from_config(config)?; self.collect_secrets(&secrets.used_statuses()?, &mut report); @@ -3082,6 +3213,7 @@ impl BootstrapStatus { self.collect_accounts(&accounts, &mut report); self.collect_files(files, directories, unavailable_files, &mut report)?; self.collect_services(&service_requests, ¬ified_services, &mut report); + self.collect_user_services(config, &mut report).await?; self.collect_firewall(firewall_request.as_ref(), &mut report); self.collect_compose(&compose_requests, &mut report); self.collect_repos(config, &mut report).await?; @@ -3181,6 +3313,29 @@ impl BootstrapStatus { report.json.insert("services".to_string(), json!(resources)); } + async fn collect_user_services( + &self, + config: &Arc, + report: &mut BootstrapStatusReport, + ) -> Result<()> { + let requests = system::user_services::requests_from_config(config)?; + let statuses = system::user_services::status(&requests).await?; + for status in &statuses { + let missing = status.action != system::resources::ResourceAction::Noop; + report.row( + "user-service", + status.name.clone(), + status.current.clone(), + status.action.to_string(), + missing, + ); + } + report + .json + .insert("user_services".to_string(), json!(statuses)); + Ok(()) + } + fn collect_firewall( &self, request: Option<&system::firewall::FirewallRequest>, diff --git a/src/cli/command_effects.rs b/src/cli/command_effects.rs index 25cb58fb181..b3b05adbda1 100644 --- a/src/cli/command_effects.rs +++ b/src/cli/command_effects.rs @@ -66,6 +66,7 @@ pub(super) const EFFECTS: &[(&str, SpecCommandEffect)] = &[ ("bootstrap firewall status", Read), ("bootstrap services", Read), ("bootstrap services apply", Destructive), + ("bootstrap services remove", Destructive), ("bootstrap services status", Read), // Hidden compatibility spellings of the nested macos/linux subcommands. ("bootstrap launchd", Read), diff --git a/src/system/launchd.rs b/src/system/launchd.rs index 186f0649524..8fcbc7fdf83 100644 --- a/src/system/launchd.rs +++ b/src/system/launchd.rs @@ -22,6 +22,9 @@ pub(crate) struct LaunchdTomlConfig { pub run_at_load: bool, #[serde(default)] pub keep_alive: bool, + /// `KeepAlive = { SuccessfulExit = false }`: relaunch only after a failure. + #[serde(default)] + pub keep_alive_on_failure: bool, #[serde(default)] pub start_interval: Option, #[serde(default)] @@ -71,6 +74,7 @@ pub(crate) struct LaunchdRequest { pub args: Vec, pub run_at_load: bool, pub keep_alive: bool, + pub keep_alive_on_failure: bool, pub start_interval: Option, pub throttle_interval: Option, pub start_calendar_interval: Option, @@ -109,6 +113,9 @@ impl LaunchdRequest { if program.is_empty() { bail!("agent '{name}' must set a non-empty `program`"); } + if config.keep_alive && config.keep_alive_on_failure { + bail!("agent '{name}' cannot set both `keep_alive` and `keep_alive_on_failure`"); + } if let Some(interval) = &config.start_calendar_interval { interval.validate(&name)?; } @@ -134,6 +141,7 @@ impl LaunchdRequest { args: config.args, run_at_load: config.run_at_load, keep_alive: config.keep_alive, + keep_alive_on_failure: config.keep_alive_on_failure, start_interval: config.start_interval, throttle_interval: config.throttle_interval, start_calendar_interval: config.start_calendar_interval, @@ -317,6 +325,76 @@ pub(crate) async fn apply(requests: &[LaunchdRequest], dry_run: bool) -> Result< Ok(()) } +/// Whether the agent's process is currently running (not merely loaded). +pub(crate) async fn is_running(label: &str) -> Result { + let target = format!("{}/{}", launchctl_domain(), label); + let output = tokio::process::Command::new("launchctl") + .args(["print", &target]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + if !output.status.success() { + return Ok(false); + } + Ok(print_reports_running(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +fn print_reports_running(output: &str) -> bool { + output.lines().map(str::trim).any(|line| { + line.strip_prefix("state = ") + .is_some_and(|state| state.trim() == "running") + }) +} + +/// Unload (stopping its process) the agent `label` if it is loaded, keeping +/// the plist in place. +pub(crate) async fn unload(label: &str, dry_run: bool) -> Result<()> { + let domain = launchctl_domain(); + let path = launch_agents_dir().join(format!("{label}.plist")); + if dry_run { + miseprintln!( + "{}", + shell_words::join([ + "launchctl".to_string(), + "bootout".to_string(), + domain, + path.display().to_string(), + ]) + ); + return Ok(()); + } + bootout(&domain, &path).await +} + +/// Unload and delete the LaunchAgent mise wrote for `name` +/// (`dev.mise.`). Returns whether a plist existed. +pub(crate) async fn remove_agent(name: &str, dry_run: bool) -> Result { + let label = format!("dev.mise.{name}"); + let path = launch_agents_dir().join(format!("{label}.plist")); + if !path.exists() { + return Ok(false); + } + unload(&label, dry_run).await?; + if dry_run { + miseprintln!( + "{}", + shell_words::join(["rm".to_string(), path.display().to_string()]) + ); + return Ok(true); + } + std::fs::remove_file(&path)?; + Ok(true) +} + +/// The plist path mise uses for an agent named `name`. +pub(crate) fn agent_plist_path(name: &str) -> PathBuf { + launch_agents_dir().join(format!("dev.mise.{name}.plist")) +} + pub(crate) fn render_plist(request: &LaunchdRequest) -> Result> { let mut out = vec![]; plist::to_writer_xml(&mut out, &plist_value(request))?; @@ -334,6 +412,10 @@ fn plist_value(request: &LaunchdRequest) -> Value { } if request.keep_alive { dict.insert("KeepAlive".into(), Value::Boolean(true)); + } else if request.keep_alive_on_failure { + let mut keep_alive = Dictionary::new(); + keep_alive.insert("SuccessfulExit".into(), Value::Boolean(false)); + dict.insert("KeepAlive".into(), Value::Dictionary(keep_alive)); } if let Some(interval) = request.start_interval { dict.insert("StartInterval".into(), Value::Integer(interval.into())); @@ -615,6 +697,7 @@ mod tests { args: vec!["hello".to_string()], run_at_load: true, keep_alive: true, + keep_alive_on_failure: false, start_interval: Some(60), throttle_interval: Some(300), start_calendar_interval: Some(LaunchdCalendarIntervals::Single( @@ -725,6 +808,7 @@ mod tests { args: vec![], run_at_load: false, keep_alive: false, + keep_alive_on_failure: false, start_interval: None, throttle_interval: None, start_calendar_interval: Some(LaunchdCalendarIntervals::Multiple(vec![ diff --git a/src/system/mod.rs b/src/system/mod.rs index 1cb271741ff..aa3fda53f6a 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -63,6 +63,7 @@ pub(crate) mod remote; pub(crate) mod remote_repository; pub(crate) mod repos; pub(crate) mod resources; +pub(crate) mod scheduled_tasks; pub(crate) mod secrets; #[cfg(target_os = "linux")] pub(crate) mod services; @@ -73,6 +74,7 @@ pub(crate) mod services_common; pub(crate) mod shell_activation; pub(crate) mod sudo; pub(crate) mod systemd; +pub(crate) mod user_services; /// `[bootstrap]` as parsed from a single mise.toml #[derive(Debug, Default, Clone, Deserialize)] diff --git a/src/system/resources.rs b/src/system/resources.rs index 06f48fb79da..53dd0d96fe5 100644 --- a/src/system/resources.rs +++ b/src/system/resources.rs @@ -412,7 +412,8 @@ pub(crate) async fn plan( cfg!(target_os = "linux"), )?; let services = super::services::status_requests_from_config(config)?; - super::services::validate_notifications(&files, &directories, &services)?; + let user_services = super::services_common::user_service_names(config)?; + super::services::validate_notifications(&files, &directories, &services, &user_services)?; let notified_services = super::managed_files::pending_notifications(&files, &directories)?; let directory_states = directories .iter() @@ -537,6 +538,10 @@ pub(crate) async fn plan( plan.add_dependency(&id, dependency.clone())?; } } + let user_service_requests = super::user_services::requests_from_config(config)?; + for status in super::user_services::status(&user_service_requests).await? { + plan.insert(status.plan())?; + } if let Some(mut firewall) = super::firewall::prepare_request_from_config(config)? { super::firewall::inspect_request(&mut firewall)?; let dependencies = plan diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs new file mode 100644 index 00000000000..259bfde9940 --- /dev/null +++ b/src/system/scheduled_tasks.rs @@ -0,0 +1,526 @@ +//! Windows Scheduled Tasks for user-scope `[bootstrap.services]` entries. +//! +//! A task named `mise\` is registered from a rendered task definition +//! with `schtasks /create /xml`. The rendered definition is kept under +//! `$MISE_STATE_DIR/user-services/.xml` so drift is detected against +//! what mise wrote, independent of the exporter's formatting. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use eyre::{Result, bail, eyre}; +use indexmap::IndexMap; + +const SCHTASKS_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScheduledTaskRequest { + pub name: String, + pub task: String, + pub description: Option, + pub command: String, + pub restart_on_failure: bool, + pub environment: IndexMap, + pub working_directory: Option, + /// Whether the task should be running now. + pub start: bool, + /// Whether the logon trigger is enabled. + pub at_logon: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ScheduledTaskState { + Running, + Ready, + Disabled, + Differs, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScheduledTaskStatus { + pub request: ScheduledTaskRequest, + pub path: PathBuf, + pub state: ScheduledTaskState, +} + +impl ScheduledTaskStatus { + pub(crate) fn is_desired(&self) -> bool { + match self.state { + ScheduledTaskState::Running => self.request.start, + ScheduledTaskState::Ready => !self.request.start, + ScheduledTaskState::Disabled + | ScheduledTaskState::Differs + | ScheduledTaskState::Missing => false, + } + } +} + +impl ScheduledTaskRequest { + pub(crate) fn new(name: &str) -> Self { + Self { + name: name.to_string(), + task: task_name(name), + description: None, + command: String::new(), + restart_on_failure: false, + environment: IndexMap::new(), + working_directory: None, + start: true, + at_logon: true, + } + } +} + +pub(crate) fn is_available() -> bool { + cfg!(windows) && crate::file::which("schtasks").is_some() +} + +pub(crate) fn unavailable_reason() -> String { + if cfg!(windows) { + "`schtasks` not found".to_string() + } else { + "only available on windows".to_string() + } +} + +pub(crate) fn task_name(name: &str) -> String { + format!("mise\\{name}") +} + +/// Where the rendered definition mise registered is kept. +pub(crate) fn definition_path(name: &str) -> PathBuf { + crate::dirs::STATE + .join("user-services") + .join(format!("{name}.xml")) +} + +/// The account the task runs as and whose logon triggers it. +fn current_user_id() -> String { + let user = crate::env::var("USERNAME").unwrap_or_else(|_| "".to_string()); + match crate::env::var("USERDOMAIN") { + Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"), + _ => user, + } +} + +/// Render the task definition (Task Scheduler XML, UTF-16LE with a BOM as +/// `schtasks /create /xml` expects). +pub(crate) fn render_definition(request: &ScheduledTaskRequest, user_id: &str) -> Vec { + let xml = render_xml(request, user_id); + let mut out = vec![0xFF, 0xFE]; + for unit in xml.encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + +pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> String { + let (command, arguments) = exec_action(request); + let mut out = String::new(); + out.push_str("\n"); + out.push_str( + "\n", + ); + out.push_str(" \n"); + out.push_str(&format!( + " {}\n", + escape( + request + .description + .as_deref() + .unwrap_or("managed by mise bootstrap") + ) + )); + out.push_str(" \n"); + out.push_str(" \n \n"); + out.push_str(&format!( + " {}\n", + yes_no(request.at_logon) + )); + out.push_str(&format!(" {}\n", escape(user_id))); + out.push_str(" \n \n"); + out.push_str(" \n \n"); + out.push_str(&format!(" {}\n", escape(user_id))); + out.push_str(" InteractiveToken\n"); + out.push_str(" LeastPrivilege\n"); + out.push_str(" \n \n"); + out.push_str(" \n"); + out.push_str(" IgnoreNew\n"); + out.push_str(" false\n"); + out.push_str(" false\n"); + out.push_str(" true\n"); + out.push_str(" true\n"); + out.push_str(" false\n"); + out.push_str(" true\n"); + out.push_str(" true\n"); + out.push_str(" false\n"); + out.push_str(" false\n"); + out.push_str(" PT0S\n"); + if request.restart_on_failure { + out.push_str(" \n PT1M\n 3\n \n"); + } + out.push_str(" 7\n"); + out.push_str(" \n"); + out.push_str(" \n \n"); + out.push_str(&format!(" {}\n", escape(&command))); + if !arguments.is_empty() { + out.push_str(&format!( + " {}\n", + escape(&arguments) + )); + } + if let Some(dir) = &request.working_directory { + out.push_str(&format!( + " {}\n", + escape(&expand_path_string(dir)) + )); + } + out.push_str(" \n \n"); + out.push_str("\n"); + out +} + +/// Split the command line into the executable and its arguments. Task +/// Scheduler has no environment block, so variables are set through `cmd.exe`. +fn exec_action(request: &ScheduledTaskRequest) -> (String, String) { + let (program, args) = split_command(&request.command); + if request.environment.is_empty() { + return (program, args); + } + let sets = request + .environment + .iter() + .map(|(key, value)| format!("set \"{key}={value}\"")) + .collect::>() + .join(" && "); + let rest = if args.is_empty() { + program + } else { + format!("{program} {args}") + }; + ("cmd.exe".to_string(), format!("/c {sets} && {rest}")) +} + +fn split_command(command: &str) -> (String, String) { + let trimmed = command.trim(); + if let Some(rest) = trimmed.strip_prefix('"') + && let Some(end) = rest.find('"') + { + let program = rest[..end].to_string(); + let args = rest[end + 1..].trim().to_string(); + return (program, args); + } + match trimmed.split_once(char::is_whitespace) { + Some((program, args)) => (program.to_string(), args.trim().to_string()), + None => (trimmed.to_string(), String::new()), + } +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn yes_no(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn expand_path_string(path: &str) -> String { + if path == "~" { + return crate::dirs::HOME.to_string_lossy().to_string(); + } + crate::file::replace_path(Path::new(path)) + .to_string_lossy() + .to_string() +} + +pub(crate) async fn status(requests: &[ScheduledTaskRequest]) -> Result> { + let user_id = current_user_id(); + let mut out = vec![]; + for req in requests { + let path = definition_path(&req.name); + let registered = query(&req.task).await?; + let state = match registered { + None => ScheduledTaskState::Missing, + Some(query) => { + let stored = std::fs::read(&path).unwrap_or_default(); + if stored != render_definition(req, &user_id) { + ScheduledTaskState::Differs + } else if query.running { + ScheduledTaskState::Running + } else if query.disabled { + ScheduledTaskState::Disabled + } else { + ScheduledTaskState::Ready + } + } + }; + out.push(ScheduledTaskStatus { + request: req.clone(), + path, + state, + }); + } + Ok(out) +} + +pub(crate) async fn exists(name: &str) -> Result { + Ok(query(&task_name(name)).await?.is_some()) +} + +pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> Result<()> { + let user_id = current_user_id(); + for req in requests { + let path = definition_path(&req.name); + let create = [ + "/create".to_string(), + "/tn".to_string(), + req.task.clone(), + "/xml".to_string(), + path.display().to_string(), + "/f".to_string(), + ]; + let run_or_end = if req.start { + ["/run".to_string(), "/tn".to_string(), req.task.clone()] + } else { + ["/end".to_string(), "/tn".to_string(), req.task.clone()] + }; + if dry_run { + miseprintln!("write {}", shell_words::join([path.display().to_string()])); + miseprintln!("schtasks {}", shell_words::join(&create)); + miseprintln!("schtasks {}", shell_words::join(&run_or_end)); + continue; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, render_definition(req, &user_id))?; + schtasks(&create).await?; + match schtasks(&run_or_end).await { + Ok(()) => {} + // ending a task that is not running is not an error worth failing on + Err(err) if !req.start && end_error_is_noop(&err.to_string()) => {} + Err(err) => return Err(err), + } + } + Ok(()) +} + +/// Delete the task mise registered for `name`. Returns whether one existed. +pub(crate) async fn remove_task(name: &str, dry_run: bool) -> Result { + let task = task_name(name); + let path = definition_path(name); + if !exists(name).await? { + if path.exists() && !dry_run { + std::fs::remove_file(&path)?; + } + return Ok(false); + } + let args = [ + "/delete".to_string(), + "/tn".to_string(), + task, + "/f".to_string(), + ]; + if dry_run { + miseprintln!("schtasks {}", shell_words::join(&args)); + if path.exists() { + miseprintln!( + "{}", + shell_words::join(["rm".to_string(), path.display().to_string()]) + ); + } + return Ok(true); + } + schtasks(&args).await?; + if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(true) +} + +struct Query { + running: bool, + disabled: bool, +} + +async fn query(task: &str) -> Result> { + let args = [ + "/query".to_string(), + "/tn".to_string(), + task.to_string(), + "/fo".to_string(), + "LIST".to_string(), + "/v".to_string(), + ]; + debug!("$ schtasks {}", shell_words::join(&args)); + let mut cmd = tokio::process::Command::new("schtasks"); + cmd.args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output()) + .await + .map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(&args)))??; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if query_error_is_missing(&stderr) { + return Ok(None); + } + bail!( + "`schtasks {}` failed: {}", + shell_words::join(&args), + stderr.trim() + ); + } + Ok(Some(parse_query(&String::from_utf8_lossy(&output.stdout)))) +} + +fn parse_query(output: &str) -> Query { + let mut query = Query { + running: false, + disabled: false, + }; + for line in output.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "Status" => query.running = value.eq_ignore_ascii_case("running"), + "Scheduled Task State" => query.disabled = value.eq_ignore_ascii_case("disabled"), + _ => {} + } + } + query +} + +fn query_error_is_missing(stderr: &str) -> bool { + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("cannot find the file specified") + || stderr.contains("does not exist") + || stderr.contains("cannot find the path specified") +} + +fn end_error_is_noop(error: &str) -> bool { + let error = error.to_ascii_lowercase(); + error.contains("not running") || error.contains("no running instance") +} + +async fn schtasks(args: &[String]) -> Result<()> { + debug!("$ schtasks {}", shell_words::join(args)); + let mut cmd = tokio::process::Command::new("schtasks"); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output()) + .await + .map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(args)))??; + if !output.status.success() { + bail!( + "`schtasks {}` failed: {}", + shell_words::join(args), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> ScheduledTaskRequest { + let mut request = ScheduledTaskRequest::new("agent"); + request.command = "C:\\Tools\\agent.exe --serve".to_string(); + request.description = Some("My ".to_string()); + request.restart_on_failure = true; + request + } + + #[test] + fn renders_a_logon_task() { + let xml = render_xml(&request(), "HOST\\me"); + assert!(xml.contains("My <agent>")); + assert!(xml.contains( + "\n true\n HOST\\me" + )); + assert!(xml.contains("C:\\Tools\\agent.exe")); + assert!(xml.contains("--serve")); + assert!(xml.contains("")); + assert!(!xml.contains("")); + } + + #[test] + fn environment_goes_through_cmd() { + let mut request = request(); + request.environment.insert("RUST_LOG".into(), "info".into()); + request.at_logon = false; + request.restart_on_failure = false; + let xml = render_xml(&request, "me"); + assert!(xml.contains("cmd.exe")); + assert!(xml.contains( + "/c set "RUST_LOG=info" && C:\\Tools\\agent.exe --serve" + )); + assert!(xml.contains("false\n me")); + assert!(!xml.contains("")); + } + + #[test] + fn quoted_programs_keep_their_spaces() { + assert_eq!( + split_command("\"C:\\Program Files\\x\\a.exe\" --flag one"), + ( + "C:\\Program Files\\x\\a.exe".to_string(), + "--flag one".to_string() + ) + ); + assert_eq!( + split_command("agent.exe"), + ("agent.exe".to_string(), String::new()) + ); + } + + #[test] + fn definition_is_utf16_with_bom() { + let bytes = render_definition(&request(), "me"); + assert_eq!(&bytes[..2], &[0xFF, 0xFE]); + assert_eq!(&bytes[2..4], &[b'<', 0]); + } + + #[test] + fn parses_query_output() { + let query = parse_query( + "HostName: PC\nTaskName: \\mise\\agent\nStatus: Running\nScheduled Task State: Enabled\n", + ); + assert!(query.running); + assert!(!query.disabled); + let query = parse_query("Status: Ready\nScheduled Task State: Disabled\n"); + assert!(!query.running); + assert!(query.disabled); + } + + #[test] + fn desired_state_follows_start() { + let mut status = ScheduledTaskStatus { + request: request(), + path: PathBuf::from("x"), + state: ScheduledTaskState::Running, + }; + assert!(status.is_desired()); + status.request.start = false; + assert!(!status.is_desired()); + status.state = ScheduledTaskState::Ready; + assert!(status.is_desired()); + status.state = ScheduledTaskState::Differs; + assert!(!status.is_desired()); + } +} diff --git a/src/system/services.rs b/src/system/services.rs index 239d82b9427..997f9038ddd 100644 --- a/src/system/services.rs +++ b/src/system/services.rs @@ -71,7 +71,7 @@ struct ServicePlan { } pub(crate) fn prepare_requests_from_config(config: &Config) -> Result> { - compose_declarations(config)? + compose_system_declarations(config)? .into_iter() .map(|(name, (config, origin))| { ServiceRequest::from_toml_with_origin(name, config, Some(origin)) @@ -219,10 +219,7 @@ impl ServiceRequest { fn desired(&self) -> String { format!( "{}; {}; {}; on change {}", - match self.state { - ServiceState::Running => "running", - ServiceState::Stopped => "stopped", - }, + self.state.as_str(), if self.enabled { "enabled" } else { "disabled" }, if self.masked { "masked" } else { "unmasked" }, match self.on_change { @@ -518,6 +515,8 @@ impl ServiceAction { ServiceChangeAction::None => None, }, ServiceState::Running => Some("start"), + // rejected for system services by `compose_system_declarations` + ServiceState::Absent => Some("stop"), } } } @@ -641,7 +640,7 @@ fn describe_current(active_state: &str, unit_file_state: &str, need_daemon_reloa fn active_state_matches(desired: ServiceState, current: &str) -> bool { match desired { ServiceState::Running => matches!(current, "active" | "reloading"), - ServiceState::Stopped => current == "inactive", + ServiceState::Stopped | ServiceState::Absent => current == "inactive", } } @@ -700,7 +699,7 @@ mod tests { state: ServiceState::Stopped, enabled: false, masked: true, - on_change: ServiceChangeAction::default(), + ..Default::default() }, ) .is_ok() diff --git a/src/system/services_common.rs b/src/system/services_common.rs index b4ccf12e339..85ebaf67f32 100644 --- a/src/system/services_common.rs +++ b/src/system/services_common.rs @@ -17,6 +17,53 @@ pub(crate) enum ServiceState { #[default] Running, Stopped, + /// User scope only: the installed service definition is removed. + Absent, +} + +impl ServiceState { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Stopped => "stopped", + Self::Absent => "absent", + } + } +} + +/// Which service manager a `[bootstrap.services]` entry targets. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ServiceScope { + /// An existing Linux systemd system unit (the original behaviour). + #[default] + System, + /// A service mise defines for the current user: a systemd user unit on + /// Linux, a LaunchAgent on macOS, a Scheduled Task on Windows. + User, +} + +/// Restart policy of a user-scope service, with one meaning on every platform. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ServiceRestart { + /// Restart whenever the process exits, even successfully. + Always, + /// Restart only after a failure. + #[default] + OnFailure, + /// Never restart automatically. + Never, +} + +impl ServiceRestart { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Always => "always", + Self::OnFailure => "on-failure", + Self::Never => "never", + } + } } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -39,6 +86,30 @@ pub(crate) struct ServiceTomlConfig { pub masked: bool, #[serde(default)] pub on_change: ServiceChangeAction, + /// `"system"` (default) or `"user"`. A `builtin` implies `"user"`. + #[serde(default)] + pub scope: Option, + /// A service definition mise supplies (for example `"history-watch"`). + #[serde(default)] + pub builtin: Option, + /// User scope: the command line to run. + #[serde(default)] + pub command: Option, + /// User scope: a human-readable description. + #[serde(default)] + pub description: Option, + /// User scope: restart policy (default `"on-failure"`). + #[serde(default)] + pub restart: Option, + /// User scope: environment variables for the process. + #[serde(default)] + pub environment: IndexMap, + /// User scope: working directory (`~` is expanded). + #[serde(default)] + pub working_directory: Option, + /// User scope: converge after `[tools]` are installed. + #[serde(default)] + pub requires_tools: bool, } impl Default for ServiceTomlConfig { @@ -48,10 +119,47 @@ impl Default for ServiceTomlConfig { enabled: true, masked: false, on_change: ServiceChangeAction::default(), + scope: None, + builtin: None, + command: None, + description: None, + restart: None, + environment: IndexMap::new(), + working_directory: None, + requires_tools: false, } } } +impl ServiceTomlConfig { + /// The effective scope: explicit `scope`, else `"user"` when a `builtin` + /// is named, else `"system"`. + pub(crate) fn scope(&self) -> ServiceScope { + self.scope.unwrap_or(if self.builtin.is_some() { + ServiceScope::User + } else { + ServiceScope::System + }) + } + + /// Fields that only apply to user-scope services, when set. + fn user_only_fields(&self) -> Vec<&'static str> { + [ + (self.builtin.is_some(), "builtin"), + (self.command.is_some(), "command"), + (self.description.is_some(), "description"), + (self.restart.is_some(), "restart"), + (!self.environment.is_empty(), "environment"), + (self.working_directory.is_some(), "working_directory"), + (self.requires_tools, "requires_tools"), + (self.state == ServiceState::Absent, "state = \"absent\""), + ] + .into_iter() + .filter_map(|(is_set, field)| is_set.then_some(field)) + .collect() + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct ServiceNotifications { pub(super) sources: IndexMap>, @@ -84,6 +192,44 @@ impl ServiceNotifications { } } +/// The system-scope entries of `[bootstrap.services]`, validated: fields that +/// only apply to user services are rejected here so a typo in `scope` cannot +/// silently turn a user service into a lookup of a system unit. +pub(crate) fn compose_system_declarations( + config: &Config, +) -> Result> { + let mut out = IndexMap::new(); + for (name, (declaration, origin)) in compose_declarations(config)? { + if declaration.scope() != ServiceScope::System { + continue; + } + let user_only = declaration.user_only_fields(); + if !user_only.is_empty() { + bail!( + "bootstrap service '{name}' sets {}, which only applies to `scope = \"user\"` services", + user_only.join(", ") + ); + } + out.insert(name, (declaration, origin)); + } + Ok(out) +} + +/// The user-scope entries of `[bootstrap.services]`. +pub(crate) fn compose_user_declarations( + config: &Config, +) -> Result> { + Ok(compose_declarations(config)? + .into_iter() + .filter(|(_, (declaration, _))| declaration.scope() == ServiceScope::User) + .collect()) +} + +/// Names of every user-scope service, for notification validation. +pub(crate) fn user_service_names(config: &Config) -> Result> { + Ok(compose_user_declarations(config)?.into_keys().collect()) +} + /// `[bootstrap.services]` from every config map, rejecting redeclarations that /// disagree. pub(crate) fn compose_declarations( @@ -134,6 +280,7 @@ pub(crate) fn validate_notifications( files: &[super::managed_files::ManagedFileRequest], directories: &[super::managed_files::ManagedDirectoryRequest], services: &[ServiceRequest], + user_services: &[String], ) -> Result<()> { let configured = services .iter() @@ -153,6 +300,13 @@ pub(crate) fn validate_notifications( .map(move |notification| (directory.path.as_path(), notification)) })) { + if user_services.iter().any(|name| name == notification) { + bail!( + "managed path '{}' notifies user-scope bootstrap service '{}'; notifications apply to system services only", + resource.display(), + notification + ); + } if !configured.contains(notification.as_str()) { bail!( "managed path '{}' notifies unconfigured bootstrap service '{}'", diff --git a/src/system/services_non_linux.rs b/src/system/services_non_linux.rs index 662b40b87cf..570d9f7be3c 100644 --- a/src/system/services_non_linux.rs +++ b/src/system/services_non_linux.rs @@ -11,7 +11,7 @@ pub(crate) struct ServiceRequest { } pub(crate) fn prepare_requests_from_config(config: &Config) -> Result> { - Ok(compose_declarations(config)? + Ok(compose_system_declarations(config)? .into_iter() .map(|(name, _)| ServiceRequest { name }) .collect()) @@ -54,8 +54,12 @@ pub(crate) fn apply_privileged_plan_from_stdin() -> Result<()> { fn reject_configured(config: &Config) -> Result> { let configured = config.bootstrap_config_maps().any(|config_files| { config_files.values().any(|cf| { - cf.bootstrap_config() - .is_some_and(|bootstrap| !bootstrap.services.is_empty()) + cf.bootstrap_config().is_some_and(|bootstrap| { + bootstrap + .services + .values() + .any(|service| service.scope() == ServiceScope::System) + }) }) }); if configured { diff --git a/src/system/systemd.rs b/src/system/systemd.rs index 69b4712aaec..f9530450f30 100644 --- a/src/system/systemd.rs +++ b/src/system/systemd.rs @@ -479,6 +479,53 @@ pub(crate) async fn apply(requests: &[SystemdRequest], dry_run: bool) -> Result< Ok(()) } +/// Stop, disable, and delete the service unit mise wrote for `name` +/// (`dev.mise..service`), then reload the user manager. Returns whether +/// a unit file existed. +pub(crate) async fn remove_service(name: &str, dry_run: bool) -> Result { + let unit = format!("dev.mise.{name}.service"); + let path = user_units_dir().join(&unit); + if !path.exists() { + return Ok(false); + } + if dry_run { + for verb in ["stop", "disable"] { + miseprintln!( + "{}", + shell_words::join([ + "systemctl".to_string(), + "--user".to_string(), + verb.to_string(), + unit.clone(), + ]) + ); + } + miseprintln!( + "{}", + shell_words::join(["rm".to_string(), path.display().to_string()]) + ); + miseprintln!( + "{}", + shell_words::join([ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ]) + ); + return Ok(true); + } + stop_unit(&unit).await?; + disable_unit(&unit).await?; + std::fs::remove_file(&path)?; + systemctl(&["daemon-reload".to_string()]).await?; + Ok(true) +} + +/// The unit file path mise uses for a service named `name`. +pub(crate) fn service_unit_path(name: &str) -> PathBuf { + user_units_dir().join(format!("dev.mise.{name}.service")) +} + pub(crate) fn render_unit(request: &SystemdRequest) -> String { let mut out = String::new(); out.push_str("[Unit]\n"); diff --git a/src/system/user_services.rs b/src/system/user_services.rs new file mode 100644 index 00000000000..9625c0beea4 --- /dev/null +++ b/src/system/user_services.rs @@ -0,0 +1,788 @@ +//! User-scope services from `[bootstrap.services]` (`scope = "user"`). +//! +//! One declaration is rendered for the platform's user service manager: a +//! systemd user unit on Linux (`dev.mise..service`), a LaunchAgent on +//! macOS (`dev.mise.`), a Scheduled Task on Windows (`mise\`). +//! `builtin = ""` selects a definition mise supplies, run through a +//! durable mise executable. + +use std::path::{Path, PathBuf}; + +use eyre::{Result, bail}; +use indexmap::IndexMap; +use serde::Serialize; + +use crate::config::Config; +use crate::system::launchd::{self, LaunchdRequest, LaunchdState, LaunchdTomlConfig}; +use crate::system::resources::{ResourceAction, ResourceId, ResourceOrigin, ResourcePlan}; +use crate::system::scheduled_tasks::{self, ScheduledTaskRequest, ScheduledTaskState}; +use crate::system::services_common::{ + ServiceRestart, ServiceState, ServiceTomlConfig, compose_user_declarations, +}; +use crate::system::systemd::{self, SystemdRequest, SystemdState, SystemdTomlConfig}; + +/// A service definition mise supplies. +struct Builtin { + args: &'static [&'static str], + description: &'static str, + restart: ServiceRestart, + nice: Option, +} + +const BUILTIN_NAMES: &[&str] = &["history-watch"]; + +fn builtin(name: &str) -> Option { + match name { + "history-watch" => Some(Builtin { + args: &["history", "watch"], + description: "mise history: save tracked files as they change", + restart: ServiceRestart::OnFailure, + nice: Some(10), + }), + _ => None, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct UserServiceRequest { + pub name: String, + pub description: Option, + /// The resolved command line; `None` when a builtin has no durable + /// executable to run through (see `unresolved`). + pub command: Option, + pub unresolved: Option, + pub builtin: Option, + pub restart: ServiceRestart, + pub nice: Option, + pub environment: IndexMap, + pub working_directory: Option, + pub requires_tools: bool, + pub state: ServiceState, + pub enabled: bool, + pub origin: Option, +} + +impl UserServiceRequest { + pub(crate) fn from_toml( + name: String, + config: ServiceTomlConfig, + origin: Option, + ) -> Result { + Self::from_toml_with_executable(name, config, origin, durable_mise_executable()) + } + + fn from_toml_with_executable( + name: String, + config: ServiceTomlConfig, + origin: Option, + executable: Option, + ) -> Result { + if !valid_name(&name) { + bail!( + "user service name '{name}' must contain only letters, numbers, '.', '_', or '-'" + ); + } + if config.masked { + bail!("user service '{name}' cannot be masked; use `state = \"absent\"` to remove it"); + } + let mut description = config.description; + let mut restart = config.restart; + let mut nice = None; + let mut unresolved = None; + let command = match (config.builtin.as_deref(), config.command.as_deref()) { + (Some(_), Some(_)) => { + bail!("user service '{name}' sets both `builtin` and `command`; choose one") + } + (None, None) => { + bail!("user service '{name}' must set `command` or `builtin`") + } + (Some(builtin_name), None) => { + let Some(definition) = builtin(builtin_name) else { + bail!( + "user service '{name}' names unknown builtin '{builtin_name}'; available: {}", + BUILTIN_NAMES.join(", ") + ); + }; + description.get_or_insert_with(|| definition.description.to_string()); + restart.get_or_insert(definition.restart); + nice = definition.nice; + match executable { + Some(exe) => Some(shell_words::join( + std::iter::once(exe.to_string_lossy().to_string()) + .chain(definition.args.iter().map(|arg| arg.to_string())), + )), + None => { + unresolved = Some( + "no durable mise executable; install mise on this host first" + .to_string(), + ); + None + } + } + } + (None, Some(command)) => { + let command = command.trim(); + if command.is_empty() { + bail!("user service '{name}' must set a non-empty `command`"); + } + Some(command.to_string()) + } + }; + Ok(Self { + name, + description, + command, + unresolved, + builtin: config.builtin, + restart: restart.unwrap_or_default(), + nice, + environment: config.environment, + working_directory: config.working_directory, + requires_tools: config.requires_tools, + state: config.state, + enabled: config.enabled, + origin, + }) + } + + pub(crate) fn desired(&self) -> String { + match self.state { + ServiceState::Absent => "absent".to_string(), + state if self.enabled => state.as_str().to_string(), + state => format!("{} (not at login)", state.as_str()), + } + } + + fn start(&self) -> bool { + self.state == ServiceState::Running + } + + pub(crate) fn systemd_request(&self) -> Result { + let config = SystemdTomlConfig { + description: self.description.clone(), + exec_start: self.command.clone(), + environment: self.environment.clone(), + working_directory: self.working_directory.clone(), + nice: self.nice, + restart: Some( + match self.restart { + ServiceRestart::Always => "always", + ServiceRestart::OnFailure => "on-failure", + ServiceRestart::Never => "no", + } + .to_string(), + ), + restart_sec: Some("5s".to_string()), + start: self.start(), + wanted_by: (!self.enabled).then(Vec::new), + ..Default::default() + }; + SystemdRequest::from_toml(self.name.clone(), config) + } + + pub(crate) fn launchd_request(&self) -> Result { + let command = self.command.clone().unwrap_or_default(); + let mut words = shell_words::split(&command) + .map_err(|err| eyre::eyre!("user service '{}': invalid `command`: {err}", self.name))?; + let program = if words.is_empty() { + None + } else { + Some(words.remove(0)) + }; + let config = LaunchdTomlConfig { + program, + args: words, + run_at_load: self.enabled && self.start(), + keep_alive: self.start() && self.restart == ServiceRestart::Always, + keep_alive_on_failure: self.start() && self.restart == ServiceRestart::OnFailure, + environment: self.environment.clone(), + working_directory: self.working_directory.clone(), + kickstart: self.start(), + ..Default::default() + }; + LaunchdRequest::from_toml(self.name.clone(), config) + } + + pub(crate) fn scheduled_task_request(&self) -> ScheduledTaskRequest { + let mut request = ScheduledTaskRequest::new(&self.name); + request.description = self.description.clone(); + request.command = self.command.clone().unwrap_or_default(); + request.restart_on_failure = self.restart != ServiceRestart::Never; + request.environment = self.environment.clone(); + request.working_directory = self.working_directory.clone(); + request.start = self.start(); + request.at_logon = self.enabled; + request + } +} + +impl std::fmt::Display for UserServiceRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (user)", self.name) + } +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Every user-scope service, validated, with names that collide with +/// `[bootstrap.linux.systemd.units]` or `[bootstrap.macos.launchd.agents]` +/// rejected (both would write the same unit or plist). +pub(crate) fn requests_from_config(config: &Config) -> Result> { + let requests = compose_user_declarations(config)? + .into_iter() + .map(|(name, (declaration, origin))| { + UserServiceRequest::from_toml(name, declaration, Some(origin)) + }) + .collect::>>()?; + if requests.is_empty() { + return Ok(requests); + } + let units = crate::system::systemd_from_config(config); + let agents = crate::system::launchd_from_config(config); + for request in &requests { + if units.iter().any(|unit| unit.name == request.name) { + bail!( + "user service '{}' is also declared in [bootstrap.linux.systemd.units]; declare it once", + request.name + ); + } + if agents.iter().any(|agent| agent.name == request.name) { + bail!( + "user service '{}' is also declared in [bootstrap.macos.launchd.agents]; declare it once", + request.name + ); + } + } + Ok(requests) +} + +/// The mise executable a service definition may reference: the running +/// binary unless it lives in a temporary or remote-bootstrap staging +/// directory, else a `mise` on `PATH` outside those. +pub(crate) fn durable_mise_executable() -> Option { + let current = std::fs::canonicalize(&*crate::env::MISE_BIN).ok(); + if let Some(current) = current.filter(|path| is_durable(path)) { + return Some(current); + } + crate::file::which("mise") + .and_then(|path| std::fs::canonicalize(path).ok()) + .filter(|path| is_durable(path)) +} + +fn is_durable(path: &Path) -> bool { + if path.starts_with(std::env::temp_dir()) { + return false; + } + let text = path.to_string_lossy(); + !(text.contains("/mise-bootstrap.") || text.contains("\\mise-bootstrap.")) +} + +pub(crate) fn is_available() -> bool { + if cfg!(target_os = "linux") { + systemd::is_available() + } else if cfg!(target_os = "macos") { + launchd::is_available() + } else if cfg!(windows) { + scheduled_tasks::is_available() + } else { + false + } +} + +pub(crate) fn unavailable_reason() -> String { + if cfg!(target_os = "linux") { + systemd::unavailable_reason() + } else if cfg!(target_os = "macos") { + launchd::unavailable_reason() + } else if cfg!(windows) { + scheduled_tasks::unavailable_reason() + } else { + "user services are only supported on linux, macos, and windows".to_string() + } +} + +/// The name of the platform's user service manager, for messages. +pub(crate) fn manager_name() -> &'static str { + if cfg!(target_os = "linux") { + "systemd user unit" + } else if cfg!(target_os = "macos") { + "LaunchAgent" + } else { + "Scheduled Task" + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct UserServiceStatus { + pub name: String, + pub scope: &'static str, + pub current: String, + pub desired: String, + pub action: ResourceAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builtin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub requires_tools: bool, + pub restart: &'static str, + /// The definition mise renders for this platform, for inspection. + #[serde(skip_serializing_if = "Option::is_none")] + pub definition: Option, + #[serde(skip)] + pub request: UserServiceRequest, +} + +impl UserServiceStatus { + pub(crate) fn plan(&self) -> ResourcePlan { + let plan = ResourcePlan::new( + ResourceId::new("user-service", &self.name), + self.current.clone(), + self.desired.clone(), + self.action, + ); + match &self.request.origin { + Some(origin) => plan.with_origin(origin.clone()), + None => plan, + } + } + + fn new(request: &UserServiceRequest, current: String, action: ResourceAction) -> Self { + Self { + name: request.name.clone(), + scope: "user", + current, + desired: request.desired(), + action, + path: None, + builtin: request.builtin.clone(), + command: request.command.clone(), + requires_tools: request.requires_tools, + restart: request.restart.as_str(), + definition: None, + request: request.clone(), + } + } +} + +pub(crate) async fn status(requests: &[UserServiceRequest]) -> Result> { + let mut out = vec![]; + for request in requests { + out.push(status_one(request).await?); + } + Ok(out) +} + +/// The definition mise would install for this platform, for inspection. +fn render_definition(request: &UserServiceRequest) -> Result { + if cfg!(target_os = "linux") { + Ok(systemd::render_unit(&request.systemd_request()?)) + } else if cfg!(target_os = "macos") { + let plist = launchd::render_plist(&request.launchd_request()?)?; + Ok(String::from_utf8_lossy(&plist).to_string()) + } else { + Ok(scheduled_tasks::render_xml( + &request.scheduled_task_request(), + "", + )) + } +} + +fn definition_path(name: &str) -> PathBuf { + if cfg!(target_os = "linux") { + systemd::service_unit_path(name) + } else if cfg!(target_os = "macos") { + launchd::agent_plist_path(name) + } else { + scheduled_tasks::definition_path(name) + } +} + +async fn status_one(request: &UserServiceRequest) -> Result { + if let Some(reason) = &request.unresolved { + return Ok(UserServiceStatus::new( + request, + format!("unknown: {reason}"), + ResourceAction::Unknown, + )); + } + let definition = render_definition(request)?; + let path = definition_path(&request.name); + if !is_available() { + let mut out = UserServiceStatus::new( + request, + format!("unavailable: {}", unavailable_reason()), + ResourceAction::Unknown, + ); + out.path = Some(path); + out.definition = Some(definition); + return Ok(out); + } + let (current, action) = if cfg!(target_os = "linux") { + let unit = request.systemd_request()?; + if request.state == ServiceState::Absent { + absent_state(path.exists()) + } else { + let status = systemd::status(std::slice::from_ref(&unit)) + .await? + .pop() + .expect("one status per request"); + let current = match status.state { + SystemdState::Missing => "not installed", + SystemdState::Differs => "installed, differs", + SystemdState::Active => "running", + SystemdState::Inactive => "stopped", + }; + ( + current, + converge_action(status.is_desired(), status.state == SystemdState::Missing), + ) + } + } else if cfg!(target_os = "macos") { + let agent = request.launchd_request()?; + if request.state == ServiceState::Absent { + absent_state(path.exists()) + } else { + let status = launchd::status(std::slice::from_ref(&agent)) + .await? + .pop() + .expect("one status per request"); + let running = status.loaded && launchd::is_running(&agent.label).await?; + let (current, desired) = match status.state { + LaunchdState::Missing => ("not installed", false), + LaunchdState::Differs => ("installed, differs", false), + LaunchdState::Unloaded => ("installed, not loaded", false), + LaunchdState::Loaded if running => ("running", request.start()), + LaunchdState::Loaded => ("stopped", !request.start()), + }; + ( + current, + converge_action(desired, status.state == LaunchdState::Missing), + ) + } + } else { + let task = request.scheduled_task_request(); + if request.state == ServiceState::Absent { + absent_state(scheduled_tasks::exists(&request.name).await?) + } else { + let status = scheduled_tasks::status(std::slice::from_ref(&task)) + .await? + .pop() + .expect("one status per request"); + let current = match status.state { + ScheduledTaskState::Missing => "not installed", + ScheduledTaskState::Differs => "installed, differs", + ScheduledTaskState::Disabled => "installed, disabled", + ScheduledTaskState::Running => "running", + ScheduledTaskState::Ready => "stopped", + }; + ( + current, + converge_action( + status.is_desired(), + status.state == ScheduledTaskState::Missing, + ), + ) + } + }; + let mut out = UserServiceStatus::new(request, current.to_string(), action); + out.path = Some(path); + out.definition = Some(definition); + Ok(out) +} + +fn absent_state(installed: bool) -> (&'static str, ResourceAction) { + if installed { + ("installed", ResourceAction::Remove) + } else { + ("absent", ResourceAction::Noop) + } +} + +fn converge_action(desired: bool, missing: bool) -> ResourceAction { + if desired { + ResourceAction::Noop + } else if missing { + ResourceAction::Create + } else { + ResourceAction::Update + } +} + +/// Converge the given user services. Returns a reason when the platform's +/// user service manager is unavailable and nothing was applied. +pub(crate) async fn apply( + requests: &[UserServiceRequest], + dry_run: bool, + yes: bool, +) -> Result> { + if requests.is_empty() { + return Ok(None); + } + if !is_available() { + let reason = unavailable_reason(); + debug!("user services: skipping, {reason}"); + return Ok(Some(reason)); + } + let statuses = status(requests).await?; + let mut targets = vec![]; + for status in &statuses { + match status.action { + ResourceAction::Noop => {} + ResourceAction::Unknown => { + warn!( + "user service {}: {}; not written", + status.name, status.current + ); + } + _ => targets.push(status.request.clone()), + } + } + let applied = statuses.len() - targets.len(); + if applied > 0 { + info!("user services: {applied} service(s) already applied"); + } + if targets.is_empty() { + return Ok(None); + } + let list = targets.iter().map(|r| r.name.clone()).collect::>(); + if !dry_run && !yes && console::user_attended_stderr() { + let msg = format!("user services: apply {}?", list.join(", ")); + if !crate::ui::prompt::confirm(msg)?.is_yes() { + info!("user services: skipped"); + return Ok(None); + } + } + for request in &targets { + apply_one(request, dry_run).await?; + } + if !dry_run { + info!("user services: applied {}", list.join(", ")); + } + Ok(None) +} + +async fn apply_one(request: &UserServiceRequest, dry_run: bool) -> Result<()> { + if request.state == ServiceState::Absent { + remove_named(&request.name, dry_run).await?; + return Ok(()); + } + if cfg!(target_os = "linux") { + systemd::apply(&[request.systemd_request()?], dry_run).await + } else if cfg!(target_os = "macos") { + launchd::apply(&[request.launchd_request()?], dry_run).await + } else { + scheduled_tasks::apply(&[request.scheduled_task_request()], dry_run).await + } +} + +/// Remove the installed definition for `name`, declared or not. Returns +/// whether one existed. +pub(crate) async fn remove_named(name: &str, dry_run: bool) -> Result { + if !valid_name(name) { + bail!("user service name '{name}' must contain only letters, numbers, '.', '_', or '-'"); + } + if !is_available() { + bail!( + "cannot remove user service '{name}': {}", + unavailable_reason() + ); + } + if cfg!(target_os = "linux") { + systemd::remove_service(name, dry_run).await + } else if cfg!(target_os = "macos") { + launchd::remove_agent(name, dry_run).await + } else { + scheduled_tasks::remove_task(name, dry_run).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::system::services_common::ServiceScope; + + fn user_config(command: &str) -> ServiceTomlConfig { + ServiceTomlConfig { + scope: Some(ServiceScope::User), + command: Some(command.to_string()), + ..Default::default() + } + } + + fn request(config: ServiceTomlConfig) -> UserServiceRequest { + UserServiceRequest::from_toml_with_executable( + "agent".to_string(), + config, + None, + Some(PathBuf::from("/usr/bin/mise")), + ) + .unwrap() + } + + #[test] + fn requires_a_command_or_builtin() { + let err = request_result(ServiceTomlConfig { + scope: Some(ServiceScope::User), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("must set `command` or `builtin`")); + let err = request_result(ServiceTomlConfig { + builtin: Some("history-watch".into()), + command: Some("x".into()), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("both `builtin` and `command`")); + let err = request_result(ServiceTomlConfig { + builtin: Some("nope".into()), + ..Default::default() + }) + .unwrap_err(); + assert!( + err.to_string() + .contains("unknown builtin 'nope'; available: history-watch") + ); + let err = request_result(ServiceTomlConfig { + masked: true, + ..user_config("agent") + }) + .unwrap_err(); + assert!(err.to_string().contains("cannot be masked")); + } + + fn request_result(config: ServiceTomlConfig) -> Result { + UserServiceRequest::from_toml_with_executable( + "agent".to_string(), + config, + None, + Some(PathBuf::from("/usr/bin/mise")), + ) + } + + #[test] + fn builtin_expands_through_the_durable_executable() { + let request = request(ServiceTomlConfig { + builtin: Some("history-watch".into()), + ..Default::default() + }); + assert_eq!( + request.command.as_deref(), + Some("/usr/bin/mise history watch") + ); + assert_eq!(request.restart, ServiceRestart::OnFailure); + assert_eq!(request.nice, Some(10)); + assert!(request.description.is_some()); + assert!(request.unresolved.is_none()); + + let staged = UserServiceRequest::from_toml_with_executable( + "agent".to_string(), + ServiceTomlConfig { + builtin: Some("history-watch".into()), + ..Default::default() + }, + None, + None, + ) + .unwrap(); + assert!(staged.command.is_none()); + assert!( + staged + .unresolved + .as_deref() + .unwrap() + .contains("no durable mise executable") + ); + } + + #[test] + fn staged_binaries_are_not_durable() { + let temp = std::env::temp_dir(); + assert!(!is_durable( + &temp.join("mise-bootstrap.abc123").join("mise") + )); + assert!(!is_durable(Path::new("/tmp/mise-bootstrap.abc123/mise"))); + assert!(is_durable(Path::new("/usr/bin/mise"))); + assert!(is_durable(Path::new("/home/me/.local/bin/mise"))); + } + + #[test] + fn renders_a_systemd_unit() { + let mut config = user_config("~/.local/bin/agent --serve"); + config.restart = Some(ServiceRestart::Always); + config.environment.insert("RUST_LOG".into(), "info".into()); + config.working_directory = Some("~".into()); + let unit = systemd::render_unit(&request(config).systemd_request().unwrap()); + assert!(unit.contains("ExecStart=")); + assert!(unit.contains("agent --serve\n")); + assert!(unit.contains("Restart=always\n")); + assert!(unit.contains("RestartSec=5s\n")); + assert!(unit.contains("Environment=\"RUST_LOG=info\"\n")); + assert!(unit.contains("WorkingDirectory=")); + assert!(unit.contains("WantedBy=default.target\n")); + + let mut config = user_config("agent"); + config.enabled = false; + config.restart = Some(ServiceRestart::Never); + let unit = systemd::render_unit(&request(config).systemd_request().unwrap()); + assert!(unit.contains("Restart=no\n")); + assert!(!unit.contains("[Install]")); + } + + #[test] + fn renders_a_launch_agent() { + let mut config = user_config("\"/Applications/My Agent.app/agent\" --serve"); + config.environment.insert("RUST_LOG".into(), "info".into()); + let agent = request(config).launchd_request().unwrap(); + assert_eq!(agent.program, "/Applications/My Agent.app/agent"); + assert_eq!(agent.args, vec!["--serve".to_string()]); + assert!(agent.run_at_load); + assert!(agent.kickstart); + assert!(!agent.keep_alive); + assert!(agent.keep_alive_on_failure); + let plist = String::from_utf8(launchd::render_plist(&agent).unwrap()).unwrap(); + assert!(plist.contains("SuccessfulExit")); + assert!(plist.contains("RUST_LOG")); + + let mut config = user_config("agent"); + config.state = ServiceState::Stopped; + config.restart = Some(ServiceRestart::Always); + let agent = request(config).launchd_request().unwrap(); + assert!(!agent.run_at_load); + assert!(!agent.kickstart); + assert!(!agent.keep_alive); + assert!(!agent.keep_alive_on_failure); + } + + #[test] + fn renders_a_scheduled_task() { + let mut config = user_config("C:\\Tools\\agent.exe --serve"); + config.enabled = false; + config.restart = Some(ServiceRestart::Never); + let task = request(config).scheduled_task_request(); + assert_eq!(task.task, "mise\\agent"); + assert!(!task.at_logon); + assert!(!task.restart_on_failure); + assert!(task.start); + let xml = scheduled_tasks::render_xml(&task, "me"); + assert!(xml.contains("C:\\Tools\\agent.exe")); + } + + #[test] + fn desired_state_is_readable() { + assert_eq!(request(user_config("agent")).desired(), "running"); + let mut config = user_config("agent"); + config.enabled = false; + assert_eq!(request(config).desired(), "running (not at login)"); + let mut config = user_config("agent"); + config.state = ServiceState::Absent; + assert_eq!(request(config).desired(), "absent"); + } +} From 42f8de554892e129415ec17dc3cf89d674204eaa Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:45:09 +0000 Subject: [PATCH 02/15] fix(bootstrap): address review feedback on user services - `restart` values are kebab-case (`on-failure`), matching the docs and the status output - a builtin's executable is quoted for the platform (double quotes on Windows), and the Windows environment wrapper quotes the program and rejects values `cmd.exe` would reinterpret - scheduled task state comes from the Task Scheduler API through PowerShell instead of the localized `schtasks` text - `state = "absent"` is handled before a builtin's executable resolves, so a staged binary can still remove an installed builtin - `bootstrap services status --json` keeps its resource-array shape; rendered definitions live under `user_services` in `bootstrap status --json` - docs state the Windows restart and macOS RunAtLoad limitations Co-Authored-By: Claude Fable 5.1 --- docs/bootstrap/services.md | 21 ++-- e2e-win/services.Tests.ps1 | 6 +- e2e/cli/test_bootstrap_user_services | 31 +++--- src/cli/bootstrap.rs | 16 ++- src/system/scheduled_tasks.rs | 160 +++++++++++++++------------ src/system/services_common.rs | 2 +- src/system/user_services.rs | 68 ++++++++---- 7 files changed, 182 insertions(+), 122 deletions(-) diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index 35c74153f4c..85dc0041a9a 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -43,14 +43,21 @@ One declaration is rendered for the platform's user service manager: - `description`: shown by the service manager. - `restart`: `"on-failure"` (default), `"always"`, or `"never"`. On Linux this is `Restart=`; on macOS `KeepAlive` (`{ SuccessfulExit = false }` for - on-failure); on Windows the task restarts up to three times a minute apart - after a failure and runs again at logon. + on-failure). Task Scheduler restarts only failed runs, so on Windows + `"always"` and `"on-failure"` both restart up to three times a minute apart + after a failure and run again at logon; a clean exit is not restarted. - `environment` and `working_directory` map directly to the platform - definition. On Windows, environment variables are set through `cmd.exe`. + definition. On Windows, environment variables are set through `cmd.exe`, + so values containing characters it would reinterpret (`%`, `"`, `&`, `|`, + `<`, `>`, `^`) are rejected; set those inside the program instead. - `state`: `"running"` (default), `"stopped"` (installed but not running), or `"absent"` (the installed definition is removed and stays removed while declared so). -- `enabled`: whether the service starts at login (default `true`). +- `enabled`: whether the service starts at login (default `true`). On macOS + this is `RunAtLoad`, which launchd also honours when the agent is loaded, + so a stopped agent is written without it (it starts at login again once it + is set running), and `restart = "always"` (`KeepAlive`) starts the agent at + login regardless of `enabled`. - `requires_tools`: converge in a second pass after `[tools]` and plugin package managers, so a service that runs a tool starts after it exists. The built-in watcher needs only mise and converges in the services step. @@ -84,9 +91,9 @@ The next `mise bootstrap` recreates it if it is still declared. `mise bootstrap services status` and `mise bootstrap services apply` cover both scopes; `mise bootstrap status` and `mise bootstrap plan` list user -services as `user-service:`. `status --json` includes each user -service's rendered definition, so what mise would install can be inspected -before applying. When the platform's user service manager is unavailable (for +services as `user-service:`. `mise bootstrap status --json` includes +each user service's rendered definition under `user_services`, so what mise +would install can be inspected before applying. When the platform's user service manager is unavailable (for example, no systemd user manager in a container), user services are reported as `unknown` and skipped with a follow-up note; nothing is written. diff --git a/e2e-win/services.Tests.ps1 b/e2e-win/services.Tests.ps1 index 1683d4b13e3..5be57fdaa93 100644 --- a/e2e-win/services.Tests.ps1 +++ b/e2e-win/services.Tests.ps1 @@ -29,7 +29,7 @@ environment = { RUST_LOG = "info" } builtin = "history-watch" "@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM - $json = mise bootstrap services status --json 2>&1 | Out-String + $json = mise bootstrap status --json 2>&1 | Out-String $LASTEXITCODE | Should -Be 0 $status = $json | ConvertFrom-Json $agent = $status.user_services | Where-Object { $_.name -eq 'agent' } @@ -60,7 +60,7 @@ command = "powershell.exe -NoProfile -Command Start-Sleep 300" $LASTEXITCODE | Should -Be 0 schtasks /query /tn $script:Task 2>&1 | Out-Null $LASTEXITCODE | Should -Be 0 - $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json).user_services[0] + $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json)[0] $status.current | Should -Be 'running' $status.action | Should -Be 'noop' @@ -70,7 +70,7 @@ scope = "user" command = "powershell.exe -NoProfile -Command Start-Sleep 300" state = "absent" "@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM - $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json).user_services[0] + $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json)[0] $status.action | Should -Be 'remove' mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null $LASTEXITCODE | Should -Be 0 diff --git a/e2e/cli/test_bootstrap_user_services b/e2e/cli/test_bootstrap_user_services index 2db46f327f9..6d2442bc4b2 100644 --- a/e2e/cli/test_bootstrap_user_services +++ b/e2e/cli/test_bootstrap_user_services @@ -40,20 +40,20 @@ assert_contains "mise bootstrap plan" "user-service:later" # the rendered definition is inspectable before anything is installed, and a # builtin runs through the mise that installed it -assert "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"agent\") | .restart'" "always" -assert "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"later\") | .requires_tools'" "true" -assert "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .builtin'" "history-watch" -assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .command'" "history watch" +assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .restart'" "always" +assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"later\") | .requires_tools'" "true" +assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .builtin'" "history-watch" +assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .command'" "history watch" if [[ "$(uname -s)" == "Linux" ]]; then - assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" "Restart=always" - assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" 'Environment="RUST_LOG=info"' - assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .definition'" "Nice=10" - assert_contains "mise bootstrap services status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .path'" "/.config/systemd/user/dev.mise.mise-history.service" + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" "Restart=always" + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" 'Environment="RUST_LOG=info"' + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .definition'" "Nice=10" + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .path'" "/.config/systemd/user/dev.mise.mise-history.service" fi # without a user service manager (this container has none) nothing is written: # status reports unknown, apply and the full bootstrap skip with a follow-up -if ! mise bootstrap services status --json | jq -e '.user_services | all(.action != "unknown")' >/dev/null; then +if ! mise bootstrap services status --json | jq -e 'all(.action != "unknown")' >/dev/null; then assert_contains "mise bootstrap services status --json" '"action": "unknown"' assert_fail "mise bootstrap services status --missing" assert_contains "mise bootstrap services apply --dry-run --yes 2>&1" "skipped" @@ -143,10 +143,11 @@ cat <<'EOF' >mise.toml builtin = "history-watch" EOF assert_not_contains "mise bootstrap --skip services --dry-run --yes 2>&1" "user services" -assert_contains "mise bootstrap services status --json | jq -r '.user_services[0].scope'" "user" +assert_contains "mise bootstrap status --json | jq -r '.user_services[0].scope'" "user" +assert_contains "mise bootstrap services status --json | jq -r '.[0].id.kind'" "user-service" # on macOS the LaunchAgent is really installed, started, and removed -if [[ "$(uname -s)" == "Darwin" ]] && mise bootstrap services status --json | jq -e '.user_services | all(.action != "unknown")' >/dev/null; then +if [[ "$(uname -s)" == "Darwin" ]] && mise bootstrap services status --json | jq -e 'all(.action != "unknown")' >/dev/null; then cat <<'TOML' >mise.toml [bootstrap.services.mise-e2e-sleep] scope = "user" @@ -154,8 +155,8 @@ command = "/bin/sleep 300" TOML assert_succeed "mise bootstrap services apply --yes" assert "test -f $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist && echo yes" "yes" - assert "mise bootstrap services status --json | jq -r '.user_services[0] | \"\\(.current) \\(.action)\"'" "running noop" - assert_contains "mise bootstrap services status --json | jq -r '.user_services[0].definition'" "SuccessfulExit" + assert "mise bootstrap services status --json | jq -r '.[0] | \"\\(.current) \\(.action)\"'" "running noop" + assert_contains "mise bootstrap status --json | jq -r '.user_services[0].definition'" "SuccessfulExit" assert_succeed "mise bootstrap services status --missing" cat <<'TOML' >mise.toml [bootstrap.services.mise-e2e-sleep] @@ -163,10 +164,10 @@ scope = "user" command = "/bin/sleep 300" state = "absent" TOML - assert "mise bootstrap services status --json | jq -r '.user_services[0] | \"\\(.current) \\(.action)\"'" "installed remove" + assert "mise bootstrap services status --json | jq -r '.[0] | \"\\(.current) \\(.action)\"'" "installed remove" assert_succeed "mise bootstrap services apply --yes" assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist" - assert "mise bootstrap services status --json | jq -r '.user_services[0] | \"\\(.current) \\(.action)\"'" "absent noop" + assert "mise bootstrap services status --json | jq -r '.[0] | \"\\(.current) \\(.action)\"'" "absent noop" # an undeclared leftover is removed once, explicitly cat <<'TOML' >mise.toml [bootstrap.services.mise-e2e-sleep] diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index 57e1fe7ee5e..0383670c091 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -2759,19 +2759,17 @@ impl BootstrapServicesStatus { &system::services::ServiceNotifications::default(), ); let user_requests = system::user_services::requests_from_config(&config)?; - let user_statuses = system::user_services::status(&user_requests).await?; - resources.extend(user_statuses.iter().map(|status| status.plan())); + resources.extend( + system::user_services::status(&user_requests) + .await? + .iter() + .map(|status| status.plan()), + ); let missing = resources .iter() .any(|resource| resource.action != system::resources::ResourceAction::Noop); if self.json { - miseprintln!( - "{}", - serde_json::to_string_pretty(&json!({ - "resources": resources, - "user_services": user_statuses, - }))? - ); + miseprintln!("{}", serde_json::to_string_pretty(&resources)?); } else if resources.is_empty() { info!("no bootstrap services configured"); } else { diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 259bfde9940..2295eaab5e0 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -107,17 +107,17 @@ fn current_user_id() -> String { /// Render the task definition (Task Scheduler XML, UTF-16LE with a BOM as /// `schtasks /create /xml` expects). -pub(crate) fn render_definition(request: &ScheduledTaskRequest, user_id: &str) -> Vec { - let xml = render_xml(request, user_id); +pub(crate) fn render_definition(request: &ScheduledTaskRequest, user_id: &str) -> Result> { + let xml = render_xml(request, user_id)?; let mut out = vec![0xFF, 0xFE]; for unit in xml.encode_utf16() { out.extend_from_slice(&unit.to_le_bytes()); } - out + Ok(out) } -pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> String { - let (command, arguments) = exec_action(request); +pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> Result { + let (command, arguments) = exec_action(request)?; let mut out = String::new(); out.push_str("\n"); out.push_str( @@ -179,28 +179,51 @@ pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> Strin } out.push_str(" \n \n"); out.push_str("\n"); - out + Ok(out) } /// Split the command line into the executable and its arguments. Task -/// Scheduler has no environment block, so variables are set through `cmd.exe`. -fn exec_action(request: &ScheduledTaskRequest) -> (String, String) { +/// Scheduler has no environment block, so variables are set through +/// `cmd.exe`, which reinterprets some characters; values that it would +/// change are rejected rather than passed through differently. +fn exec_action(request: &ScheduledTaskRequest) -> Result<(String, String)> { let (program, args) = split_command(&request.command); if request.environment.is_empty() { - return (program, args); + return Ok((program, args)); + } + let mut sets = vec![]; + for (key, value) in &request.environment { + if key.is_empty() || key.contains(['=', '"', '%', '\n', '\r']) { + bail!( + "user service '{}': environment key {key:?} cannot be set through cmd.exe", + request.name + ); + } + if let Some(c) = value + .chars() + .find(|c| matches!(c, '"' | '%' | '&' | '|' | '<' | '>' | '^' | '\n' | '\r')) + { + bail!( + "user service '{}': environment value for {key} contains {c:?}, which cmd.exe would reinterpret; set it inside the program instead", + request.name + ); + } + sets.push(format!("set \"{key}={value}\"")); } - let sets = request - .environment - .iter() - .map(|(key, value)| format!("set \"{key}={value}\"")) - .collect::>() - .join(" && "); + let program = if program.contains(char::is_whitespace) { + format!("\"{program}\"") + } else { + program + }; let rest = if args.is_empty() { program } else { format!("{program} {args}") }; - ("cmd.exe".to_string(), format!("/c {sets} && {rest}")) + Ok(( + "cmd.exe".to_string(), + format!("/c {} && {rest}", sets.join(" && ")), + )) } fn split_command(command: &str) -> (String, String) { @@ -249,7 +272,7 @@ pub(crate) async fn status(requests: &[ScheduledTaskRequest]) -> Result ScheduledTaskState::Missing, Some(query) => { let stored = std::fs::read(&path).unwrap_or_default(); - if stored != render_definition(req, &user_id) { + if stored != render_definition(req, &user_id)? { ScheduledTaskState::Differs } else if query.running { ScheduledTaskState::Running @@ -299,7 +322,7 @@ pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> R if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&path, render_definition(req, &user_id))?; + std::fs::write(&path, render_definition(req, &user_id)?)?; schtasks(&create).await?; match schtasks(&run_or_end).await { Ok(()) => {} @@ -349,17 +372,22 @@ struct Query { disabled: bool, } +/// The task's state through the Task Scheduler API rather than the +/// localized text `schtasks /query` prints. Prints `MISSING` for an +/// unregistered task and the `TaskState` name otherwise. +const QUERY_SCRIPT: &str = "$t = Get-ScheduledTask -TaskPath '\\mise\\' -TaskName $args[0] -ErrorAction SilentlyContinue; if ($null -eq $t) { 'MISSING' } else { $t.State.ToString() }"; + async fn query(task: &str) -> Result> { + let name = task.strip_prefix("mise\\").unwrap_or(task); let args = [ - "/query".to_string(), - "/tn".to_string(), - task.to_string(), - "/fo".to_string(), - "LIST".to_string(), - "/v".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + QUERY_SCRIPT.to_string(), + name.to_string(), ]; - debug!("$ schtasks {}", shell_words::join(&args)); - let mut cmd = tokio::process::Command::new("schtasks"); + debug!("$ powershell {}", shell_words::join(&args)); + let mut cmd = tokio::process::Command::new("powershell.exe"); cmd.args(&args) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -367,45 +395,25 @@ async fn query(task: &str) -> Result> { .kill_on_drop(true); let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output()) .await - .map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(&args)))??; + .map_err(|_| eyre!("querying scheduled task {task} timed out"))??; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if query_error_is_missing(&stderr) { - return Ok(None); - } bail!( - "`schtasks {}` failed: {}", - shell_words::join(&args), - stderr.trim() + "querying scheduled task {task} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() ); } - Ok(Some(parse_query(&String::from_utf8_lossy(&output.stdout)))) + Ok(parse_query(&String::from_utf8_lossy(&output.stdout))) } -fn parse_query(output: &str) -> Query { - let mut query = Query { - running: false, - disabled: false, - }; - for line in output.lines() { - let Some((key, value)) = line.split_once(':') else { - continue; - }; - let value = value.trim(); - match key.trim() { - "Status" => query.running = value.eq_ignore_ascii_case("running"), - "Scheduled Task State" => query.disabled = value.eq_ignore_ascii_case("disabled"), - _ => {} - } +fn parse_query(output: &str) -> Option { + let state = output.trim(); + if state.eq_ignore_ascii_case("MISSING") || state.is_empty() { + return None; } - query -} - -fn query_error_is_missing(stderr: &str) -> bool { - let stderr = stderr.to_ascii_lowercase(); - stderr.contains("cannot find the file specified") - || stderr.contains("does not exist") - || stderr.contains("cannot find the path specified") + Some(Query { + running: state.eq_ignore_ascii_case("Running"), + disabled: state.eq_ignore_ascii_case("Disabled"), + }) } fn end_error_is_noop(error: &str) -> bool { @@ -438,7 +446,7 @@ async fn schtasks(args: &[String]) -> Result<()> { mod tests { use super::*; - fn request() -> ScheduledTaskRequest { + fn sample() -> ScheduledTaskRequest { let mut request = ScheduledTaskRequest::new("agent"); request.command = "C:\\Tools\\agent.exe --serve".to_string(); request.description = Some("My ".to_string()); @@ -448,7 +456,7 @@ mod tests { #[test] fn renders_a_logon_task() { - let xml = render_xml(&request(), "HOST\\me"); + let xml = render_xml(&sample(), "HOST\\me").unwrap(); assert!(xml.contains("My <agent>")); assert!(xml.contains( "\n true\n HOST\\me" @@ -461,17 +469,32 @@ mod tests { #[test] fn environment_goes_through_cmd() { - let mut request = request(); + let mut request = sample(); request.environment.insert("RUST_LOG".into(), "info".into()); request.at_logon = false; request.restart_on_failure = false; - let xml = render_xml(&request, "me"); + let xml = render_xml(&request, "me").unwrap(); assert!(xml.contains("cmd.exe")); assert!(xml.contains( "/c set "RUST_LOG=info" && C:\\Tools\\agent.exe --serve" )); assert!(xml.contains("false\n me")); assert!(!xml.contains("")); + + let mut request = sample(); + request.command = "\"C:\\Program Files\\x\\a.exe\" --serve".to_string(); + request.environment.insert("A".into(), "1".into()); + let xml = render_xml(&request, "me").unwrap(); + assert!(xml.contains( + "/c set "A=1" && "C:\\Program Files\\x\\a.exe" --serve" + )); + + let mut request = sample(); + request + .environment + .insert("P".into(), "%PATH%;C:\\x".into()); + let err = render_xml(&request, "me").unwrap_err().to_string(); + assert!(err.contains("cmd.exe would reinterpret"), "{err}"); } #[test] @@ -491,27 +514,28 @@ mod tests { #[test] fn definition_is_utf16_with_bom() { - let bytes = render_definition(&request(), "me"); + let bytes = render_definition(&sample(), "me").unwrap(); assert_eq!(&bytes[..2], &[0xFF, 0xFE]); assert_eq!(&bytes[2..4], &[b'<', 0]); } #[test] fn parses_query_output() { - let query = parse_query( - "HostName: PC\nTaskName: \\mise\\agent\nStatus: Running\nScheduled Task State: Enabled\n", - ); + let query = parse_query("Running\r\n").unwrap(); assert!(query.running); assert!(!query.disabled); - let query = parse_query("Status: Ready\nScheduled Task State: Disabled\n"); + let query = parse_query("Disabled\n").unwrap(); assert!(!query.running); assert!(query.disabled); + let query = parse_query("Ready\n").unwrap(); + assert!(!query.running && !query.disabled); + assert!(parse_query("MISSING\n").is_none()); } #[test] fn desired_state_follows_start() { let mut status = ScheduledTaskStatus { - request: request(), + request: sample(), path: PathBuf::from("x"), state: ScheduledTaskState::Running, }; diff --git a/src/system/services_common.rs b/src/system/services_common.rs index 85ebaf67f32..e755182d221 100644 --- a/src/system/services_common.rs +++ b/src/system/services_common.rs @@ -45,7 +45,7 @@ pub(crate) enum ServiceScope { /// Restart policy of a user-scope service, with one meaning on every platform. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "kebab-case")] pub(crate) enum ServiceRestart { /// Restart whenever the process exits, even successfully. Always, diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 9625c0beea4..9b0c23d45aa 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -107,10 +107,12 @@ impl UserServiceRequest { restart.get_or_insert(definition.restart); nice = definition.nice; match executable { - Some(exe) => Some(shell_words::join( - std::iter::once(exe.to_string_lossy().to_string()) - .chain(definition.args.iter().map(|arg| arg.to_string())), - )), + Some(exe) => Some( + std::iter::once(quote_program(&exe.to_string_lossy())) + .chain(definition.args.iter().map(|arg| arg.to_string())) + .collect::>() + .join(" "), + ), None => { unresolved = Some( "no durable mise executable; install mise on this host first" @@ -222,6 +224,21 @@ impl std::fmt::Display for UserServiceRequest { } } +/// Quote an executable path for the platform's command line: double quotes +/// on Windows (what Task Scheduler and `cmd.exe` understand), POSIX shell +/// quoting elsewhere. +fn quote_program(path: &str) -> String { + if cfg!(windows) { + if path.contains(char::is_whitespace) { + format!("\"{path}\"") + } else { + path.to_string() + } + } else { + shell_words::quote(path).to_string() + } +} + fn valid_name(name: &str) -> bool { !name.is_empty() && name @@ -387,10 +404,7 @@ fn render_definition(request: &UserServiceRequest) -> Result { let plist = launchd::render_plist(&request.launchd_request()?)?; Ok(String::from_utf8_lossy(&plist).to_string()) } else { - Ok(scheduled_tasks::render_xml( - &request.scheduled_task_request(), - "", - )) + scheduled_tasks::render_xml(&request.scheduled_task_request(), "") } } @@ -405,6 +419,29 @@ fn definition_path(name: &str) -> PathBuf { } async fn status_one(request: &UserServiceRequest) -> Result { + let path = definition_path(&request.name); + // removal needs no command: an absent builtin is removed even when no + // durable executable resolves + if request.state == ServiceState::Absent { + if !is_available() { + let mut out = UserServiceStatus::new( + request, + format!("unavailable: {}", unavailable_reason()), + ResourceAction::Unknown, + ); + out.path = Some(path); + return Ok(out); + } + let installed = if cfg!(windows) { + scheduled_tasks::exists(&request.name).await? + } else { + path.exists() + }; + let (current, action) = absent_state(installed); + let mut out = UserServiceStatus::new(request, current.to_string(), action); + out.path = Some(path); + return Ok(out); + } if let Some(reason) = &request.unresolved { return Ok(UserServiceStatus::new( request, @@ -413,7 +450,6 @@ async fn status_one(request: &UserServiceRequest) -> Result { )); } let definition = render_definition(request)?; - let path = definition_path(&request.name); if !is_available() { let mut out = UserServiceStatus::new( request, @@ -426,9 +462,7 @@ async fn status_one(request: &UserServiceRequest) -> Result { } let (current, action) = if cfg!(target_os = "linux") { let unit = request.systemd_request()?; - if request.state == ServiceState::Absent { - absent_state(path.exists()) - } else { + { let status = systemd::status(std::slice::from_ref(&unit)) .await? .pop() @@ -446,9 +480,7 @@ async fn status_one(request: &UserServiceRequest) -> Result { } } else if cfg!(target_os = "macos") { let agent = request.launchd_request()?; - if request.state == ServiceState::Absent { - absent_state(path.exists()) - } else { + { let status = launchd::status(std::slice::from_ref(&agent)) .await? .pop() @@ -468,9 +500,7 @@ async fn status_one(request: &UserServiceRequest) -> Result { } } else { let task = request.scheduled_task_request(); - if request.state == ServiceState::Absent { - absent_state(scheduled_tasks::exists(&request.name).await?) - } else { + { let status = scheduled_tasks::status(std::slice::from_ref(&task)) .await? .pop() @@ -771,7 +801,7 @@ mod tests { assert!(!task.at_logon); assert!(!task.restart_on_failure); assert!(task.start); - let xml = scheduled_tasks::render_xml(&task, "me"); + let xml = scheduled_tasks::render_xml(&task, "me").unwrap(); assert!(xml.contains("C:\\Tools\\agent.exe")); } From 2b43645343fad0b9d6e175f2b7bc2d71cfb01aa8 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:53:46 +0000 Subject: [PATCH 03/15] fix(bootstrap): harden user services on Windows and remote bootstraps - the scheduled task query embeds the task name in the PowerShell script instead of passing it after `-Command`, where it was lost - `~` in a user service `command` expands on Windows too - user-only fields on a system-scope entry are rejected on every platform, not only where system services are supported - the temporary directory is canonicalized before deciding whether a mise executable is durable (`/private/var`, `\\?\` prefixes) - user services that require tools are resolved again after tools and packages install, so a mise installed by the same run is durable Co-Authored-By: Claude Fable 5.1 --- src/cli/bootstrap.rs | 15 ++++++--- src/system/scheduled_tasks.rs | 52 ++++++++++++++++++++++++-------- src/system/services_non_linux.rs | 13 ++------ src/system/user_services.rs | 5 ++- 4 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index 0383670c091..ff885c80be4 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -1762,11 +1762,16 @@ impl Bootstrap { } } - let late = user_services - .iter() - .filter(|request| request.requires_tools) - .cloned() - .collect::>(); + // resolved again: the run may have installed a durable mise since + // the requests were first built (a remote-staged bootstrap) + let late = if services_enabled { + system::user_services::requests_from_config(&config)? + .into_iter() + .filter(|request| request.requires_tools) + .collect::>() + } else { + vec![] + }; if !late.is_empty() { if skip.contains(&BootstrapPart::Tools) { info!("bootstrap: tools skipped; user services with requires_tools still converge"); diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 2295eaab5e0..bfbd3403117 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -228,17 +228,23 @@ fn exec_action(request: &ScheduledTaskRequest) -> Result<(String, String)> { fn split_command(command: &str) -> (String, String) { let trimmed = command.trim(); - if let Some(rest) = trimmed.strip_prefix('"') + let (program, args) = if let Some(rest) = trimmed.strip_prefix('"') && let Some(end) = rest.find('"') { - let program = rest[..end].to_string(); - let args = rest[end + 1..].trim().to_string(); - return (program, args); - } - match trimmed.split_once(char::is_whitespace) { - Some((program, args)) => (program.to_string(), args.trim().to_string()), - None => (trimmed.to_string(), String::new()), - } + (rest[..end].to_string(), rest[end + 1..].trim().to_string()) + } else { + match trimmed.split_once(char::is_whitespace) { + Some((program, args)) => (program.to_string(), args.trim().to_string()), + None => (trimmed.to_string(), String::new()), + } + }; + // `~` and `~/` expand on every platform, as the docs promise + let program = if program == "~" || program.starts_with("~/") || program.starts_with("~\\") { + expand_path_string(&program) + } else { + program + }; + (program, args) } fn escape(value: &str) -> String { @@ -374,17 +380,29 @@ struct Query { /// The task's state through the Task Scheduler API rather than the /// localized text `schtasks /query` prints. Prints `MISSING` for an -/// unregistered task and the `TaskState` name otherwise. -const QUERY_SCRIPT: &str = "$t = Get-ScheduledTask -TaskPath '\\mise\\' -TaskName $args[0] -ErrorAction SilentlyContinue; if ($null -eq $t) { 'MISSING' } else { $t.State.ToString() }"; +/// unregistered task and the `TaskState` name otherwise. The name is +/// embedded in the script (arguments after `-Command` are more command +/// text, not `$args`); names are validated to letters, digits, `.`, `_`, +/// and `-` before they get here. +fn query_script(name: &str) -> String { + format!( + "$t = Get-ScheduledTask -TaskPath '\\mise\\' -TaskName '{name}' -ErrorAction SilentlyContinue; if ($null -eq $t) {{ 'MISSING' }} else {{ $t.State.ToString() }}" + ) +} async fn query(task: &str) -> Result> { let name = task.strip_prefix("mise\\").unwrap_or(task); + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + bail!("scheduled task name {name:?} contains characters that cannot be queried"); + } let args = [ "-NoProfile".to_string(), "-NonInteractive".to_string(), "-Command".to_string(), - QUERY_SCRIPT.to_string(), - name.to_string(), + query_script(name), ]; debug!("$ powershell {}", shell_words::join(&args)); let mut cmd = tokio::process::Command::new("powershell.exe"); @@ -497,6 +515,14 @@ mod tests { assert!(err.contains("cmd.exe would reinterpret"), "{err}"); } + #[test] + fn tilde_expands_in_the_program() { + let (program, args) = split_command("~/.local/bin/agent --serve"); + assert!(!program.starts_with('~'), "{program}"); + assert!(program.ends_with("agent"), "{program}"); + assert_eq!(args, "--serve"); + } + #[test] fn quoted_programs_keep_their_spaces() { assert_eq!( diff --git a/src/system/services_non_linux.rs b/src/system/services_non_linux.rs index 570d9f7be3c..74fa04ed7a0 100644 --- a/src/system/services_non_linux.rs +++ b/src/system/services_non_linux.rs @@ -52,16 +52,9 @@ pub(crate) fn apply_privileged_plan_from_stdin() -> Result<()> { } fn reject_configured(config: &Config) -> Result> { - let configured = config.bootstrap_config_maps().any(|config_files| { - config_files.values().any(|cf| { - cf.bootstrap_config().is_some_and(|bootstrap| { - bootstrap - .services - .values() - .any(|service| service.scope() == ServiceScope::System) - }) - }) - }); + // validated like on Linux first, so a forgotten `scope = "user"` is + // reported as such rather than as a platform limitation + let configured = !compose_system_declarations(config)?.is_empty(); if configured { bail!("bootstrap system services are only supported on Linux"); } diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 9b0c23d45aa..828253b745b 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -292,7 +292,9 @@ pub(crate) fn durable_mise_executable() -> Option { } fn is_durable(path: &Path) -> bool { - if path.starts_with(std::env::temp_dir()) { + let temp = std::env::temp_dir(); + let temp = std::fs::canonicalize(&temp).unwrap_or(temp); + if path.starts_with(&temp) { return false; } let text = path.to_string_lossy(); @@ -735,6 +737,7 @@ mod tests { #[test] fn staged_binaries_are_not_durable() { let temp = std::env::temp_dir(); + let temp = std::fs::canonicalize(&temp).unwrap_or(temp); assert!(!is_durable( &temp.join("mise-bootstrap.abc123").join("mise") )); From e35bfc83cdc14f2706e843f8b6b927deaef4d2d3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:31:20 +0000 Subject: [PATCH 04/15] refactor(bootstrap): the built-in watcher runs mise bootstrap dotfiles watch Co-Authored-By: Claude Fable 5.1 --- docs/bootstrap/services.md | 2 +- e2e-win/services.Tests.ps1 | 2 +- e2e/cli/test_bootstrap_user_services | 2 +- src/system/user_services.rs | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index 85dc0041a9a..b7297730721 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -37,7 +37,7 @@ One declaration is rendered for the platform's user service manager: - `command`: the command line to run. `~` and `~/` are expanded. Required unless `builtin` is set. - `builtin`: a definition mise supplies. `"history-watch"` runs - `mise history watch` through a durable mise executable with + `mise bootstrap dotfiles watch` through a durable mise executable with `restart = "on-failure"` and a low priority. A builtin implies `scope = "user"`; `command` cannot be combined with it. - `description`: shown by the service manager. diff --git a/e2e-win/services.Tests.ps1 b/e2e-win/services.Tests.ps1 index 5be57fdaa93..c83d54fea4c 100644 --- a/e2e-win/services.Tests.ps1 +++ b/e2e-win/services.Tests.ps1 @@ -37,7 +37,7 @@ builtin = "history-watch" $agent.definition | Should -BeLike '*RUST_LOG=info*' $agent.current | Should -Be 'not installed' $history = $status.user_services | Where-Object { $_.name -eq 'mise-history' } - $history.command | Should -BeLike '*history watch*' + $history.command | Should -BeLike '*dotfiles watch*' $history.definition | Should -BeLike '**' @" diff --git a/e2e/cli/test_bootstrap_user_services b/e2e/cli/test_bootstrap_user_services index 6d2442bc4b2..8f8cde9b187 100644 --- a/e2e/cli/test_bootstrap_user_services +++ b/e2e/cli/test_bootstrap_user_services @@ -43,7 +43,7 @@ assert_contains "mise bootstrap plan" "user-service:later" assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .restart'" "always" assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"later\") | .requires_tools'" "true" assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .builtin'" "history-watch" -assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .command'" "history watch" +assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .command'" "bootstrap dotfiles watch" if [[ "$(uname -s)" == "Linux" ]]; then assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" "Restart=always" assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" 'Environment="RUST_LOG=info"' diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 828253b745b..0f776df2891 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -34,8 +34,8 @@ const BUILTIN_NAMES: &[&str] = &["history-watch"]; fn builtin(name: &str) -> Option { match name { "history-watch" => Some(Builtin { - args: &["history", "watch"], - description: "mise history: save tracked files as they change", + args: &["bootstrap", "dotfiles", "watch"], + description: "mise dotfiles history: save tracked files as they change", restart: ServiceRestart::OnFailure, nice: Some(10), }), @@ -707,7 +707,7 @@ mod tests { }); assert_eq!( request.command.as_deref(), - Some("/usr/bin/mise history watch") + Some("/usr/bin/mise bootstrap dotfiles watch") ); assert_eq!(request.restart, ServiceRestart::OnFailure); assert_eq!(request.nice, Some(10)); From a130e5e4cc3b8b5a35930f63edeb73e0bda5c1f0 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:19:37 +0000 Subject: [PATCH 05/15] fix(bootstrap): address review feedback on user services - Windows apply queries the task state first: a running task is ended only when its definition changed or it should stop, and started only when it is not running; the rendered definition is stored only after Task Scheduler accepted it, so a failed create never looks converged - `services remove` never blocks on a broken declaration, and its argument is documented as the installed name - services already applied are counted without the ones skipped as unknown - the Windows restart limitation (a clean exit is not restarted) is documented with `enabled` qualified and covered by a unit test - the Windows e2e asserts the status command's exit code; the Linux host script probes the user manager with `show-environment` Co-Authored-By: Claude Fable 5.1 --- docs/bootstrap/services.md | 5 ++- docs/cli/bootstrap/services/remove.md | 2 +- e2e-win/services.Tests.ps1 | 8 +++- man/man1/mise.1 | 2 +- mise.usage.kdl | 2 +- scripts/test-bootstrap-linux-host.sh | 2 +- src/cli/bootstrap.rs | 10 +++-- src/system/scheduled_tasks.rs | 54 ++++++++++++++++++++------- src/system/user_services.rs | 23 +++++++++++- 9 files changed, 82 insertions(+), 26 deletions(-) diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index b7297730721..d51f0c6916c 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -45,7 +45,10 @@ One declaration is rendered for the platform's user service manager: is `Restart=`; on macOS `KeepAlive` (`{ SuccessfulExit = false }` for on-failure). Task Scheduler restarts only failed runs, so on Windows `"always"` and `"on-failure"` both restart up to three times a minute apart - after a failure and run again at logon; a clean exit is not restarted. + after a failure and run again at logon (when `enabled = true`); a clean + exit is not restarted. Strict `"always"` semantics are a Linux and macOS + feature; a service that must survive a clean exit on Windows should loop + inside its own program. - `environment` and `working_directory` map directly to the platform definition. On Windows, environment variables are set through `cmd.exe`, so values containing characters it would reinterpret (`%`, `"`, `&`, `|`, diff --git a/docs/cli/bootstrap/services/remove.md b/docs/cli/bootstrap/services/remove.md index d985edd4381..74cd5fbf3ec 100644 --- a/docs/cli/bootstrap/services/remove.md +++ b/docs/cli/bootstrap/services/remove.md @@ -12,7 +12,7 @@ or task in place; this removes it once. The next `mise bootstrap` recreates it if it is still declared. ## Arguments -- **``** — The service name as declared in `[bootstrap.services]` +- **``** — The installed user-service name to remove (declared or not) ## Flags - **`-n --dry-run`** — Print what would change without changing anything diff --git a/e2e-win/services.Tests.ps1 b/e2e-win/services.Tests.ps1 index c83d54fea4c..ac5d979d974 100644 --- a/e2e-win/services.Tests.ps1 +++ b/e2e-win/services.Tests.ps1 @@ -60,7 +60,9 @@ command = "powershell.exe -NoProfile -Command Start-Sleep 300" $LASTEXITCODE | Should -Be 0 schtasks /query /tn $script:Task 2>&1 | Out-Null $LASTEXITCODE | Should -Be 0 - $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json)[0] + $json = mise bootstrap services status --json | Out-String + $LASTEXITCODE | Should -Be 0 + $status = ($json | ConvertFrom-Json)[0] $status.current | Should -Be 'running' $status.action | Should -Be 'noop' @@ -70,7 +72,9 @@ scope = "user" command = "powershell.exe -NoProfile -Command Start-Sleep 300" state = "absent" "@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM - $status = (mise bootstrap services status --json | Out-String | ConvertFrom-Json)[0] + $json = mise bootstrap services status --json | Out-String + $LASTEXITCODE | Should -Be 0 + $status = ($json | ConvertFrom-Json)[0] $status.action | Should -Be 'remove' mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null $LASTEXITCODE | Should -Be 0 diff --git a/man/man1/mise.1 b/man/man1/mise.1 index c4677b65d97..6b28dfe81e8 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -2667,7 +2667,7 @@ Print help .PP .TP \fB\fR -The service name as declared in `[bootstrap.services]` +The installed user\-service name to remove (declared or not) .SH "MISE BOOTSTRAP SERVICES STATUS" Show configured service state (system and user scope) .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index 12ae7db076d..a8982ba7ee6 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -1373,7 +1373,7 @@ recreates it if it is still declared. """# flag "-n --dry-run" help="Print what would change without changing anything" flag "-h --help" help="Print help" action=help builtin=#true - arg help="The service name as declared in `[bootstrap.services]`" + arg help="The installed user-service name to remove (declared or not)" } cmd status help="Show configured service state (system and user scope)" effect=read { flag "-J --json" help="Output in JSON format" diff --git a/scripts/test-bootstrap-linux-host.sh b/scripts/test-bootstrap-linux-host.sh index 3dd65cf4d9c..793f9874929 100755 --- a/scripts/test-bootstrap-linux-host.sh +++ b/scripts/test-bootstrap-linux-host.sh @@ -207,7 +207,7 @@ ssh "${ssh_args[@]}" \ systemctl is-active --quiet docker.service nft list table inet mise_bootstrap | grep -q mise-bootstrap docker compose --project-directory /opt/mise-case --file /opt/mise-case/compose.yaml --project-name mise-bootstrap-smoke ps --status running --quiet | grep -q . - if systemctl --user is-system-running >/dev/null 2>&1; then + if systemctl --user show-environment >/dev/null 2>&1; then test -f "$HOME/.config/systemd/user/dev.mise.mise-case-user.service" systemctl --user is-active --quiet dev.mise.mise-case-user.service else diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index ff885c80be4..38a1f745f86 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -535,7 +535,7 @@ struct BootstrapServicesApply { #[derive(Debug, usage_rs::Args)] #[usage(verbatim_doc_comment)] struct BootstrapServicesRemove { - /// The service name as declared in `[bootstrap.services]` + /// The installed user-service name to remove (declared or not) name: String, /// Print what would change without changing anything @@ -2688,9 +2688,11 @@ impl BootstrapServicesRemove { async fn run_inner(self) -> Result<()> { let config = Config::get().await?; - let declared = system::user_services::requests_from_config(&config)? - .iter() - .any(|request| request.name == self.name); + // best effort: a broken declaration must not block removal, which is + // the recovery path for exactly that state + let declared = system::user_services::requests_from_config(&config) + .map(|requests| requests.iter().any(|request| request.name == self.name)) + .unwrap_or(false); let removed = system::user_services::remove_named(&self.name, self.dry_run).await?; let manager = system::user_services::manager_name(); if !removed { diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index bfbd3403117..98c79525185 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -306,35 +306,61 @@ pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> R let user_id = current_user_id(); for req in requests { let path = definition_path(&req.name); + // the definition is registered from a staging file and stored only + // once Task Scheduler accepted it, so a failed create never leaves a + // definition on disk that status would take for the registered one + let staging = path.with_extension("xml.new"); + let rendered = render_definition(req, &user_id)?; let create = [ "/create".to_string(), "/tn".to_string(), req.task.clone(), "/xml".to_string(), - path.display().to_string(), + staging.display().to_string(), "/f".to_string(), ]; - let run_or_end = if req.start { - ["/run".to_string(), "/tn".to_string(), req.task.clone()] - } else { - ["/end".to_string(), "/tn".to_string(), req.task.clone()] - }; + let end = ["/end".to_string(), "/tn".to_string(), req.task.clone()]; + let run = ["/run".to_string(), "/tn".to_string(), req.task.clone()]; + // what is registered now: a running instance keeps its old process + // (`IgnoreNew`), so a changed definition or a stop ends it first, and + // a task that is not running is never ended (its message is + // localized, so it is not parsed) + let registered = query(&req.task).await?; + let running = registered.as_ref().is_some_and(|query| query.running); + let changed = registered.is_some() + && std::fs::read(&path).ok().as_deref() != Some(rendered.as_slice()); + let end_first = running && (!req.start || changed); + let start = req.start && (!running || changed); if dry_run { miseprintln!("write {}", shell_words::join([path.display().to_string()])); miseprintln!("schtasks {}", shell_words::join(&create)); - miseprintln!("schtasks {}", shell_words::join(&run_or_end)); + if end_first { + miseprintln!("schtasks {}", shell_words::join(&end)); + } + if start { + miseprintln!("schtasks {}", shell_words::join(&run)); + } continue; } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(&path, render_definition(req, &user_id)?)?; - schtasks(&create).await?; - match schtasks(&run_or_end).await { - Ok(()) => {} - // ending a task that is not running is not an error worth failing on - Err(err) if !req.start && end_error_is_noop(&err.to_string()) => {} - Err(err) => return Err(err), + std::fs::write(&staging, &rendered)?; + if let Err(err) = schtasks(&create).await { + let _ = std::fs::remove_file(&staging); + return Err(err); + } + std::fs::rename(&staging, &path)?; + if end_first { + match schtasks(&end).await { + Ok(()) => {} + // it exited between the query and now + Err(err) if end_error_is_noop(&err.to_string()) => {} + Err(err) => return Err(err), + } + } + if start { + schtasks(&run).await?; } } Ok(()) diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 0f776df2891..4a5fac848dd 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -564,10 +564,12 @@ pub(crate) async fn apply( } let statuses = status(requests).await?; let mut targets = vec![]; + let mut skipped = 0; for status in &statuses { match status.action { ResourceAction::Noop => {} ResourceAction::Unknown => { + skipped += 1; warn!( "user service {}: {}; not written", status.name, status.current @@ -576,7 +578,7 @@ pub(crate) async fn apply( _ => targets.push(status.request.clone()), } } - let applied = statuses.len() - targets.len(); + let applied = statuses.len() - targets.len() - skipped; if applied > 0 { info!("user services: {applied} service(s) already applied"); } @@ -808,6 +810,25 @@ mod tests { assert!(xml.contains("C:\\Tools\\agent.exe")); } + #[test] + fn windows_restarts_failed_runs_only_for_always_and_on_failure() { + // Task Scheduler cannot restart a clean exit: both policies become + // "restart failed runs" (documented), and `never` sets nothing + for restart in [ServiceRestart::Always, ServiceRestart::OnFailure] { + let mut config = user_config("agent --serve"); + config.restart = Some(restart); + let task = request(config).scheduled_task_request(); + assert!(task.restart_on_failure, "{restart:?}"); + let xml = scheduled_tasks::render_xml(&task, "me").unwrap(); + assert!(xml.contains(""), "{restart:?}"); + } + let mut config = user_config("agent --serve"); + config.restart = Some(ServiceRestart::Never); + let task = request(config).scheduled_task_request(); + let xml = scheduled_tasks::render_xml(&task, "me").unwrap(); + assert!(!xml.contains("")); + } + #[test] fn desired_state_is_readable() { assert_eq!(request(user_config("agent")).desired(), "running"); From 353708e8029e8dc97aaa84c8c408cfde8c8c92d8 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:30:28 +0000 Subject: [PATCH 06/15] fix(bootstrap): reject cmd.exe metacharacters in a Windows service command with an environment Co-Authored-By: Claude Fable 5.1 --- src/system/scheduled_tasks.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 98c79525185..09092fc1f68 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -210,6 +210,17 @@ fn exec_action(request: &ScheduledTaskRequest) -> Result<(String, String)> { } sets.push(format!("set \"{key}={value}\"")); } + // the command line goes through cmd.exe too: what it would split or + // chain is rejected the same way, rather than run differently + if let Some(c) = format!("{program} {args}") + .chars() + .find(|c| matches!(c, '%' | '&' | '|' | '<' | '>' | '^' | '\n' | '\r')) + { + bail!( + "user service '{}': the command contains {c:?}, which cmd.exe would reinterpret when `environment` is set; move it into a script", + request.name + ); + } let program = if program.contains(char::is_whitespace) { format!("\"{program}\"") } else { From af2b4b728ce700de6cfea1600a95b6687820dc68 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:25:40 +0000 Subject: [PATCH 07/15] fix(bootstrap): store a Windows task definition by writing it, find schtasks.exe, and document the command restriction Co-Authored-By: Claude Fable 5.1 --- docs/bootstrap/services.md | 5 ++++- src/system/scheduled_tasks.rs | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index d51f0c6916c..a34c852fc83 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -52,7 +52,10 @@ One declaration is rendered for the platform's user service manager: - `environment` and `working_directory` map directly to the platform definition. On Windows, environment variables are set through `cmd.exe`, so values containing characters it would reinterpret (`%`, `"`, `&`, `|`, - `<`, `>`, `^`) are rejected; set those inside the program instead. + `<`, `>`, `^`) are rejected, and so is a `command` containing `%`, `&`, + `|`, `<`, `>`, or `^` once `environment` is set (without `environment` + the command runs directly). Move such a command into a script, or set the + variables inside the program. - `state`: `"running"` (default), `"stopped"` (installed but not running), or `"absent"` (the installed definition is removed and stays removed while declared so). diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 09092fc1f68..4067f947b6c 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -74,7 +74,8 @@ impl ScheduledTaskRequest { } pub(crate) fn is_available() -> bool { - cfg!(windows) && crate::file::which("schtasks").is_some() + // spawnable as-is: `schtasks.exe`, which a plain lookup does not find + cfg!(windows) && crate::file::which_spawnable("schtasks").is_some() } pub(crate) fn unavailable_reason() -> String { @@ -361,7 +362,10 @@ pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> R let _ = std::fs::remove_file(&staging); return Err(err); } - std::fs::rename(&staging, &path)?; + // written, not renamed: a rename does not replace an existing + // definition on Windows + std::fs::write(&path, &rendered)?; + let _ = std::fs::remove_file(&staging); if end_first { match schtasks(&end).await { Ok(()) => {} From 8f9e60b673c36daf22a53aaa355102d0b8c42bb2 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:11:59 +0000 Subject: [PATCH 08/15] fix(bootstrap): the durable mise lookup scans every PATH entry, mise.exe included Co-Authored-By: Claude Fable 5.1 --- src/system/user_services.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 4a5fac848dd..5c898c69863 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -286,9 +286,16 @@ pub(crate) fn durable_mise_executable() -> Option { if let Some(current) = current.filter(|path| is_durable(path)) { return Some(current); } - crate::file::which("mise") - .and_then(|path| std::fs::canonicalize(path).ok()) - .filter(|path| is_durable(path)) + // every `mise` on PATH, not only the first: a staged binary earlier on + // PATH (a remote bootstrap) must not hide a durable one behind it; on + // Windows that is `mise.exe` + let names = crate::file::executable_names("mise"); + crate::env::PATH + .iter() + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) + .filter(|candidate| candidate.is_file()) + .filter_map(|candidate| std::fs::canonicalize(candidate).ok()) + .find(|path| is_durable(path)) } fn is_durable(path: &Path) -> bool { From 571a981bc62d3e587e302e441496f556d2d4f8e3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:42:31 +0000 Subject: [PATCH 09/15] fix(bootstrap): the durable lookup accepts executables only, and a builtin's niceness reaches launchd and Task Scheduler Co-Authored-By: Claude Fable 5.1 --- src/system/launchd.rs | 11 +++++++++++ src/system/scheduled_tasks.rs | 11 ++++++++++- src/system/user_services.rs | 4 +++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/system/launchd.rs b/src/system/launchd.rs index 8fcbc7fdf83..eaf5ed5e39e 100644 --- a/src/system/launchd.rs +++ b/src/system/launchd.rs @@ -29,6 +29,9 @@ pub(crate) struct LaunchdTomlConfig { pub start_interval: Option, #[serde(default)] pub throttle_interval: Option, + /// Niceness of the process (`Nice` in the plist). + #[serde(default)] + pub nice: Option, #[serde(default)] pub start_calendar_interval: Option, #[serde(default)] @@ -77,6 +80,7 @@ pub(crate) struct LaunchdRequest { pub keep_alive_on_failure: bool, pub start_interval: Option, pub throttle_interval: Option, + pub nice: Option, pub start_calendar_interval: Option, pub queue_directories: Vec, pub environment: IndexMap, @@ -144,6 +148,7 @@ impl LaunchdRequest { keep_alive_on_failure: config.keep_alive_on_failure, start_interval: config.start_interval, throttle_interval: config.throttle_interval, + nice: config.nice, start_calendar_interval: config.start_calendar_interval, queue_directories: config.queue_directories, environment: config.environment, @@ -423,6 +428,9 @@ fn plist_value(request: &LaunchdRequest) -> Value { if let Some(interval) = request.throttle_interval { dict.insert("ThrottleInterval".into(), Value::Integer(interval.into())); } + if let Some(nice) = request.nice { + dict.insert("Nice".into(), Value::Integer(nice.into())); + } if let Some(interval) = &request.start_calendar_interval { dict.insert( "StartCalendarInterval".into(), @@ -700,6 +708,7 @@ mod tests { keep_alive_on_failure: false, start_interval: Some(60), throttle_interval: Some(300), + nice: None, start_calendar_interval: Some(LaunchdCalendarIntervals::Single( LaunchdCalendarInterval { hour: Some(2), @@ -811,6 +820,7 @@ mod tests { keep_alive_on_failure: false, start_interval: None, throttle_interval: None, + nice: None, start_calendar_interval: Some(LaunchdCalendarIntervals::Multiple(vec![ LaunchdCalendarInterval { hour: Some(3), @@ -866,6 +876,7 @@ mod tests { LaunchdTomlConfig { program: Some("/bin/echo".to_string()), throttle_interval: Some(10), + nice: None, queue_directories: vec![ "~/Library/Queues/sync".to_string(), "/var/spool/sync".to_string(), diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 4067f947b6c..55a30bcfa1d 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -25,6 +25,8 @@ pub(crate) struct ScheduledTaskRequest { pub working_directory: Option, /// Whether the task should be running now. pub start: bool, + /// A niceness above zero lowers the task's priority. + pub nice: Option, /// Whether the logon trigger is enabled. pub at_logon: bool, } @@ -69,6 +71,7 @@ impl ScheduledTaskRequest { working_directory: None, start: true, at_logon: true, + nice: None, } } } @@ -162,7 +165,13 @@ pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> Resul if request.restart_on_failure { out.push_str(" \n PT1M\n 3\n \n"); } - out.push_str(" 7\n"); + // 7 is the default; a nice service runs at the lowest normal priority + let priority = if request.nice.is_some_and(|nice| nice > 0) { + 9 + } else { + 7 + }; + out.push_str(&format!(" {priority}\n")); out.push_str(" \n"); out.push_str(" \n \n"); out.push_str(&format!(" {}\n", escape(&command))); diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 5c898c69863..bfbf6b1ee9d 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -200,6 +200,7 @@ impl UserServiceRequest { environment: self.environment.clone(), working_directory: self.working_directory.clone(), kickstart: self.start(), + nice: self.nice, ..Default::default() }; LaunchdRequest::from_toml(self.name.clone(), config) @@ -214,6 +215,7 @@ impl UserServiceRequest { request.working_directory = self.working_directory.clone(); request.start = self.start(); request.at_logon = self.enabled; + request.nice = self.nice; request } } @@ -293,7 +295,7 @@ pub(crate) fn durable_mise_executable() -> Option { crate::env::PATH .iter() .flat_map(|dir| names.iter().map(move |name| dir.join(name))) - .filter(|candidate| candidate.is_file()) + .filter(|candidate| candidate.is_file() && crate::file::is_executable(candidate)) .filter_map(|candidate| std::fs::canonicalize(candidate).ok()) .find(|path| is_durable(path)) } From 399aa5c61a01ca1032700afa80c5c564490e5bf3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:16:43 +0000 Subject: [PATCH 10/15] fix(bootstrap): a failed schtasks call reports what Task Scheduler printed Co-Authored-By: Claude Fable 5.1 --- src/system/scheduled_tasks.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 55a30bcfa1d..1ae46e93573 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -501,11 +501,17 @@ async fn schtasks(args: &[String]) -> Result<()> { .await .map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(args)))??; if !output.status.success() { - bail!( - "`schtasks {}` failed: {}", - shell_words::join(args), - String::from_utf8_lossy(&output.stderr).trim() - ); + // schtasks writes its SUCCESS and ERROR lines to stdout + let printed = [ + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ] + .iter() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .collect::>() + .join("; "); + bail!("`schtasks {}` failed: {printed}", shell_words::join(args)); } Ok(()) } From 031aaeb60db710a91e7e0ba44cc6c7f3c713601e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:26:07 +0000 Subject: [PATCH 11/15] test(bootstrap): match the user-only field error with a regex on Windows Co-Authored-By: Claude Fable 5.1 --- e2e-win/services.Tests.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e-win/services.Tests.ps1 b/e2e-win/services.Tests.ps1 index ac5d979d974..a5323fdd18d 100644 --- a/e2e-win/services.Tests.ps1 +++ b/e2e-win/services.Tests.ps1 @@ -46,7 +46,8 @@ command = "dockerd" "@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM $out = mise bootstrap services status 2>&1 | Out-String $LASTEXITCODE | Should -Not -Be 0 - $out | Should -BeLike '*only applies to `scope = "user"` services*' + # a regex: the backtick escapes wildcard characters in -BeLike patterns + $out | Should -Match 'only applies to `scope = "user"` services' } It 'installs, runs, and removes a scheduled task' { From e9461101da27454665af0ec1f87a318bc1d3211c Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:01:10 +0000 Subject: [PATCH 12/15] fix(bootstrap): recognise a stopped Scheduled Task by its HRESULT in every locale Co-Authored-By: Claude Fable 5.1 --- src/system/scheduled_tasks.rs | 58 +++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs index 1ae46e93573..78e980f6389 100644 --- a/src/system/scheduled_tasks.rs +++ b/src/system/scheduled_tasks.rs @@ -340,7 +340,12 @@ pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> R staging.display().to_string(), "/f".to_string(), ]; - let end = ["/end".to_string(), "/tn".to_string(), req.task.clone()]; + let end = [ + "/end".to_string(), + "/tn".to_string(), + req.task.clone(), + "/HRESULT".to_string(), + ]; let run = ["/run".to_string(), "/tn".to_string(), req.task.clone()]; // what is registered now: a running instance keeps its old process // (`IgnoreNew`), so a changed definition or a stop ends it first, and @@ -376,11 +381,14 @@ pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> R std::fs::write(&path, &rendered)?; let _ = std::fs::remove_file(&staging); if end_first { - match schtasks(&end).await { - Ok(()) => {} - // it exited between the query and now - Err(err) if end_error_is_noop(&err.to_string()) => {} - Err(err) => return Err(err), + // it may have exited between the query and now: the HRESULT + // says so in every locale; the message is matched as a fallback + let (status, printed) = schtasks_output(&end).await?; + if !status.success() + && status.code() != Some(SCHED_E_TASK_NOT_RUNNING) + && !end_error_is_noop(&printed) + { + bail!("`schtasks {}` failed: {printed}", shell_words::join(&end)); } } if start { @@ -484,12 +492,26 @@ fn parse_query(output: &str) -> Option { }) } +/// `SCHED_E_TASK_NOT_RUNNING`: the HRESULT `schtasks /end /HRESULT` exits +/// with when the task has no running instance. +const SCHED_E_TASK_NOT_RUNNING: i32 = 0x8004130Bu32 as i32; + fn end_error_is_noop(error: &str) -> bool { let error = error.to_ascii_lowercase(); error.contains("not running") || error.contains("no running instance") } async fn schtasks(args: &[String]) -> Result<()> { + let (status, printed) = schtasks_output(args).await?; + if !status.success() { + bail!("`schtasks {}` failed: {printed}", shell_words::join(args)); + } + Ok(()) +} + +/// Runs schtasks; its exit status and what it printed (schtasks writes its +/// SUCCESS and ERROR lines to stdout). +async fn schtasks_output(args: &[String]) -> Result<(std::process::ExitStatus, String)> { debug!("$ schtasks {}", shell_words::join(args)); let mut cmd = tokio::process::Command::new("schtasks"); cmd.args(args) @@ -500,20 +522,16 @@ async fn schtasks(args: &[String]) -> Result<()> { let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output()) .await .map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(args)))??; - if !output.status.success() { - // schtasks writes its SUCCESS and ERROR lines to stdout - let printed = [ - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ] - .iter() - .map(|text| text.trim().to_string()) - .filter(|text| !text.is_empty()) - .collect::>() - .join("; "); - bail!("`schtasks {}` failed: {printed}", shell_words::join(args)); - } - Ok(()) + let printed = [ + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ] + .iter() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .collect::>() + .join("; "); + Ok((output.status, printed)) } #[cfg(test)] From d293ce744a75c0cec98c82269be741471f34bc53 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:41:27 +0000 Subject: [PATCH 13/15] fix(bootstrap): a disabled launch agent carries no KeepAlive launchd reads any KeepAlive as run-at-load, so an agent with `enabled = false` and the default `restart = "on-failure"` started at login anyway. A disabled agent is now written without KeepAlive and the services page says what that means. Co-Authored-By: Claude Fable 5.1 --- docs/bootstrap/services.md | 6 ++++-- src/system/user_services.rs | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index a34c852fc83..46c0efedd1b 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -62,8 +62,10 @@ One declaration is rendered for the platform's user service manager: - `enabled`: whether the service starts at login (default `true`). On macOS this is `RunAtLoad`, which launchd also honours when the agent is loaded, so a stopped agent is written without it (it starts at login again once it - is set running), and `restart = "always"` (`KeepAlive`) starts the agent at - login regardless of `enabled`. + is set running). launchd reads any `KeepAlive` as run-at-load too, so an + agent with `enabled = false` is written without one: it is started once by + the apply but neither starts at login nor is restarted after a failure + until it is enabled again. - `requires_tools`: converge in a second pass after `[tools]` and plugin package managers, so a service that runs a tool starts after it exists. The built-in watcher needs only mise and converges in the services step. diff --git a/src/system/user_services.rs b/src/system/user_services.rs index bfbf6b1ee9d..716b827d9ae 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -195,8 +195,13 @@ impl UserServiceRequest { program, args: words, run_at_load: self.enabled && self.start(), - keep_alive: self.start() && self.restart == ServiceRestart::Always, - keep_alive_on_failure: self.start() && self.restart == ServiceRestart::OnFailure, + // launchd reads any `KeepAlive` as "run at load" too, so a + // disabled agent is written without one: it neither starts at + // login nor is restarted until it is enabled again + keep_alive: self.enabled && self.start() && self.restart == ServiceRestart::Always, + keep_alive_on_failure: self.enabled + && self.start() + && self.restart == ServiceRestart::OnFailure, environment: self.environment.clone(), working_directory: self.working_directory.clone(), kickstart: self.start(), @@ -803,6 +808,19 @@ mod tests { assert!(!agent.kickstart); assert!(!agent.keep_alive); assert!(!agent.keep_alive_on_failure); + + // a disabled agent carries no KeepAlive either: launchd would read + // it as RunAtLoad and start the agent at login + let mut config = user_config("agent"); + config.enabled = false; + let agent = request(config).launchd_request().unwrap(); + assert!(!agent.run_at_load); + assert!(agent.kickstart); + assert!(!agent.keep_alive); + assert!(!agent.keep_alive_on_failure); + let plist = String::from_utf8(launchd::render_plist(&agent).unwrap()).unwrap(); + assert!(!plist.contains("KeepAlive")); + assert!(!plist.contains("RunAtLoad")); } #[test] From daca62a0b24a2f6373a8e195ffa9202697f83fb0 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:20:13 +0000 Subject: [PATCH 14/15] fix(bootstrap): the built-in watcher keeps the mise path found on PATH, not the file behind it Durability is judged by where the binary really is, but a Homebrew or package-manager symlink is what survives an upgrade; the versioned file behind it does not. Co-Authored-By: Claude Fable 5.1 --- src/system/user_services.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 716b827d9ae..7cf6ca85d6d 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -287,10 +287,13 @@ pub(crate) fn requests_from_config(config: &Config) -> Result Option { - let current = std::fs::canonicalize(&*crate::env::MISE_BIN).ok(); - if let Some(current) = current.filter(|path| is_durable(path)) { + let current = crate::env::MISE_BIN.clone(); + if durable_behind(¤t) { return Some(current); } // every `mise` on PATH, not only the first: a staged binary earlier on @@ -301,8 +304,12 @@ pub(crate) fn durable_mise_executable() -> Option { .iter() .flat_map(|dir| names.iter().map(move |name| dir.join(name))) .filter(|candidate| candidate.is_file() && crate::file::is_executable(candidate)) - .filter_map(|candidate| std::fs::canonicalize(candidate).ok()) - .find(|path| is_durable(path)) + .find(|candidate| durable_behind(candidate)) +} + +/// Whether the file a path leads to (through any links) is durable. +fn durable_behind(path: &Path) -> bool { + std::fs::canonicalize(path).is_ok_and(|real| is_durable(&real)) } fn is_durable(path: &Path) -> bool { From 471472f8e3281e23b71ceb4245cbae1b6449d2f3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:09:04 +0000 Subject: [PATCH 15/15] fix(bootstrap): a service path must be durable itself, not only what it points at Co-Authored-By: Claude Fable 5.1 --- src/system/user_services.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/system/user_services.rs b/src/system/user_services.rs index 7cf6ca85d6d..f4f4eff8c13 100644 --- a/src/system/user_services.rs +++ b/src/system/user_services.rs @@ -307,9 +307,13 @@ pub(crate) fn durable_mise_executable() -> Option { .find(|candidate| durable_behind(candidate)) } -/// Whether the file a path leads to (through any links) is durable. +/// Whether a path may be embedded in a service definition: absolute and +/// durable itself (a link inside a staging directory is not, whatever it +/// points at), and leading (through any links) to a durable file. fn durable_behind(path: &Path) -> bool { - std::fs::canonicalize(path).is_ok_and(|real| is_durable(&real)) + path.is_absolute() + && is_durable(path) + && std::fs::canonicalize(path).is_ok_and(|real| is_durable(&real)) } fn is_durable(path: &Path) -> bool {