From fb7e269bf3fd18a5de37a68b7bbc9935abe27984 Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 21:19:54 -0500 Subject: [PATCH 1/8] feat(autostart): add LaunchDaemon support for headless macOS servers Introduces `limactl daemon install/uninstall` to register Lima instances as system LaunchDaemons, enabling VM startup at boot without requiring a user session or auto-login. LaunchAgents (the existing `start-at-login` mechanism) require a GUI login session and do not start on headless macOS servers. A LaunchDaemon with a `UserName` key runs as a specified user at system boot, before any login, solving this gap. Changes: - pkg/autostart/launchd: add DaemonTemplate, GetDaemonPlistPath, DaemonServiceNameFrom; restore RequestStop to its original form - pkg/autostart/managers: add extraTemplateVars to TemplateFileBasedManager to support templates requiring additional variables (e.g. UserName) - pkg/autostart/managers_darwin: add DaemonManager(userName string) for rendering daemon plists and tracking registration state - pkg/autostart/managers_{linux,others}: add DaemonManager stubs returning notSupportedManager - cmd/limactl: add `daemon` subcommand with `install` and `uninstall`; runs as normal user; privileged operations (writing to /Library/LaunchDaemons/ and launchctl system domain) are performed via sudo internally so limactl itself never runs as root - tests: add TestGetDaemonPlistPath, TestDaemonServiceNameFrom, and a daemon plist render test verifying UserName substitution Developed with AI assistance. Signed-off-by: Robert Esker --- cmd/limactl/daemon.go | 47 +++++++ cmd/limactl/daemon_darwin.go | 118 ++++++++++++++++++ cmd/limactl/daemon_others.go | 20 +++ cmd/limactl/main.go | 1 + pkg/autostart/autostart_test.go | 42 +++++++ .../launchd/io.lima-vm.daemon.INSTANCE.plist | 27 ++++ pkg/autostart/launchd/launchd.go | 13 ++ pkg/autostart/launchd/launchd_test.go | 48 +++++++ pkg/autostart/managers.go | 16 +-- pkg/autostart/managers_darwin.go | 13 ++ pkg/autostart/managers_linux.go | 5 + pkg/autostart/managers_others.go | 5 + 12 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 cmd/limactl/daemon.go create mode 100644 cmd/limactl/daemon_darwin.go create mode 100644 cmd/limactl/daemon_others.go create mode 100644 pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist diff --git a/cmd/limactl/daemon.go b/cmd/limactl/daemon.go new file mode 100644 index 00000000000..b1aca6d207d --- /dev/null +++ b/cmd/limactl/daemon.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func newDaemonCommand() *cobra.Command { + daemonCommand := &cobra.Command{ + Use: "daemon", + Short: "Manage Lima instances as system LaunchDaemons (macOS only, requires root)", + GroupID: advancedCommand, + } + daemonCommand.AddCommand(newDaemonInstallCommand(), newDaemonUninstallCommand()) + return daemonCommand +} + +func newDaemonInstallCommand() *cobra.Command { + installCommand := &cobra.Command{ + Use: "install INSTANCE", + Short: "Install a system LaunchDaemon for the instance (run with sudo)", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: daemonInstallAction, + ValidArgsFunction: daemonComplete, + } + installCommand.Flags().String( + "user", "", + "macOS username to run the daemon as (default: $USER)", + ) + return installCommand +} + +func newDaemonUninstallCommand() *cobra.Command { + return &cobra.Command{ + Use: "uninstall INSTANCE", + Short: "Uninstall the system LaunchDaemon for the instance (run with sudo)", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: daemonUninstallAction, + ValidArgsFunction: daemonComplete, + } +} + +func daemonComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/cmd/limactl/daemon_darwin.go b/cmd/limactl/daemon_darwin.go new file mode 100644 index 00000000000..8280efbab78 --- /dev/null +++ b/cmd/limactl/daemon_darwin.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/autostart/launchd" + "github.com/lima-vm/lima/v2/pkg/store" + "github.com/lima-vm/lima/v2/pkg/textutil" +) + +func daemonInstallAction(cmd *cobra.Command, args []string) error { + userName, err := cmd.Flags().GetString("user") + if err != nil { + return err + } + if userName == "" { + userName = os.Getenv("USER") + } + if userName == "" { + return errors.New("could not determine user; pass --user") + } + + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + selfExe, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine limactl path: %w", err) + } + + content, err := textutil.ExecuteTemplate(launchd.DaemonTemplate, map[string]string{ + "Binary": selfExe, + "Instance": inst.Name, + "WorkDir": inst.Dir, + "UserName": userName, + }) + if err != nil { + return fmt.Errorf("failed to render daemon plist: %w", err) + } + + tmp, err := os.CreateTemp("", "io.lima-vm.daemon.*.plist") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(content); err != nil { + return fmt.Errorf("failed to write temp plist: %w", err) + } + tmp.Close() + + destPath := launchd.GetDaemonPlistPath(inst.Name) + svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name) + + // Bootout first in case a previous install is still loaded (ignore error). + _ = runSudo(ctx, "launchctl", "bootout", svcTarget) + + if err := runSudo(ctx, "install", "-m", "644", tmp.Name(), destPath); err != nil { + return fmt.Errorf("failed to install plist to %s: %w", destPath, err) + } + if err := runSudo(ctx, "launchctl", "enable", svcTarget); err != nil { + return fmt.Errorf("failed to enable LaunchDaemon: %w", err) + } + if err := runSudo(ctx, "launchctl", "bootstrap", "system", destPath); err != nil { + return fmt.Errorf("failed to bootstrap LaunchDaemon: %w", err) + } + + logrus.Infof("LaunchDaemon installed for instance %q (runs as %q at boot)", inst.Name, userName) + logrus.Infof("Plist: %s", destPath) + return nil +} + +func daemonUninstallAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name) + destPath := launchd.GetDaemonPlistPath(inst.Name) + + _ = runSudo(ctx, "launchctl", "bootout", svcTarget) + _ = runSudo(ctx, "launchctl", "disable", svcTarget) + if err := runSudo(ctx, "rm", "-f", destPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", destPath, err) + } + logrus.Infof("LaunchDaemon uninstalled for instance %q", inst.Name) + return nil +} + +// runSudo executes a command under sudo, inheriting the terminal so password prompts work. +func runSudo(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "sudo", args...) //nolint:gosec // args are constructed internally, not from user input + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + logrus.Debugf("running: sudo %v", args) + return cmd.Run() +} diff --git a/cmd/limactl/daemon_others.go b/cmd/limactl/daemon_others.go new file mode 100644 index 00000000000..67280e0cf57 --- /dev/null +++ b/cmd/limactl/daemon_others.go @@ -0,0 +1,20 @@ +//go:build !darwin + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + + "github.com/spf13/cobra" +) + +func daemonInstallAction(_ *cobra.Command, _ []string) error { + return errors.New("daemon install is only supported on macOS") +} + +func daemonUninstallAction(_ *cobra.Command, _ []string) error { + return errors.New("daemon uninstall is only supported on macOS") +} diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 31f322f6930..07e37be48c6 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -206,6 +206,7 @@ func newApp() *cobra.Command { newRestartCommand(), newSudoersCommand(), newStartAtLoginCommand(), + newDaemonCommand(), newNetworkCommand(), newCloneCommand(), newRenameCommand(), diff --git a/pkg/autostart/autostart_test.go b/pkg/autostart/autostart_test.go index bc6c274d9c7..170e72095c9 100644 --- a/pkg/autostart/autostart_test.go +++ b/pkg/autostart/autostart_test.go @@ -22,6 +22,11 @@ var ( requestStart: launchd.RequestStart, requestStop: launchd.RequestStop, } + LaunchdDaemon = &TemplateFileBasedManager{ + filePath: launchd.GetDaemonPlistPath, + template: launchd.DaemonTemplate, + extraTemplateVars: map[string]string{"UserName": "alice"}, + } Systemd = &TemplateFileBasedManager{ filePath: systemd.GetUnitPath, template: systemd.Template, @@ -73,6 +78,43 @@ func TestRenderTemplate(t *testing.T) { Background +`, + GetExecutable: func() (string, error) { + return "/limactl", nil + }, + WorkDir: "/some/path", + }, + { + Manager: LaunchdDaemon, + Name: "render darwin launchd daemon plist", + InstanceName: "k3s", + Expected: ` + + + + Label + io.lima-vm.daemon.k3s + UserName + alice + ProgramArguments + + /limactl + start + k3s + --foreground + + RunAtLoad + + StandardErrorPath + launchd.stderr.log + StandardOutPath + launchd.stdout.log + WorkingDirectory + /some/path + ProcessType + Background + + `, GetExecutable: func() (string, error) { return "/limactl", nil diff --git a/pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist b/pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist new file mode 100644 index 00000000000..a846980869f --- /dev/null +++ b/pkg/autostart/launchd/io.lima-vm.daemon.INSTANCE.plist @@ -0,0 +1,27 @@ + + + + + Label + io.lima-vm.daemon.{{ .Instance }} + UserName + {{ .UserName }} + ProgramArguments + + {{ .Binary }} + start + {{ .Instance }} + --foreground + + RunAtLoad + + StandardErrorPath + launchd.stderr.log + StandardOutPath + launchd.stdout.log + WorkingDirectory + {{ .WorkDir }} + ProcessType + Background + + diff --git a/pkg/autostart/launchd/launchd.go b/pkg/autostart/launchd/launchd.go index 3ec52980ed2..afdce13d163 100644 --- a/pkg/autostart/launchd/launchd.go +++ b/pkg/autostart/launchd/launchd.go @@ -19,6 +19,9 @@ import ( //go:embed io.lima-vm.autostart.INSTANCE.plist var Template string +//go:embed io.lima-vm.daemon.INSTANCE.plist +var DaemonTemplate string + // GetPlistPath returns the path to the launchd plist file for the given instance name. func GetPlistPath(instName string) string { return fmt.Sprintf("%s/Library/LaunchAgents/%s.plist", os.Getenv("HOME"), ServiceNameFrom(instName)) @@ -95,3 +98,13 @@ func RequestStop(ctx context.Context, inst *limatype.Instance) (bool, error) { } return false, nil } + +// GetDaemonPlistPath returns the path to the system LaunchDaemon plist for the given instance. +func GetDaemonPlistPath(instName string) string { + return fmt.Sprintf("/Library/LaunchDaemons/%s.plist", DaemonServiceNameFrom(instName)) +} + +// DaemonServiceNameFrom returns the launchd daemon service name for the given instance name. +func DaemonServiceNameFrom(instName string) string { + return fmt.Sprintf("io.lima-vm.daemon.%s", instName) +} diff --git a/pkg/autostart/launchd/launchd_test.go b/pkg/autostart/launchd/launchd_test.go index b26943cb633..de20ef4a4e0 100644 --- a/pkg/autostart/launchd/launchd_test.go +++ b/pkg/autostart/launchd/launchd_test.go @@ -30,3 +30,51 @@ func TestGetPlistPath(t *testing.T) { }) } } + +func TestGetDaemonPlistPath(t *testing.T) { + tests := []struct { + Name string + InstanceName string + Expected string + }{ + { + Name: "k3s instance", + InstanceName: "k3s", + Expected: "/Library/LaunchDaemons/io.lima-vm.daemon.k3s.plist", + }, + { + Name: "default instance", + InstanceName: "default", + Expected: "/Library/LaunchDaemons/io.lima-vm.daemon.default.plist", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + assert.Equal(t, GetDaemonPlistPath(tt.InstanceName), tt.Expected) + }) + } +} + +func TestDaemonServiceNameFrom(t *testing.T) { + tests := []struct { + Name string + InstanceName string + Expected string + }{ + { + Name: "k3s instance", + InstanceName: "k3s", + Expected: "io.lima-vm.daemon.k3s", + }, + { + Name: "default instance", + InstanceName: "default", + Expected: "io.lima-vm.daemon.default", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + assert.Equal(t, DaemonServiceNameFrom(tt.InstanceName), tt.Expected) + }) + } +} diff --git a/pkg/autostart/managers.go b/pkg/autostart/managers.go index 87480dbb9b0..4d2475ef483 100644 --- a/pkg/autostart/managers.go +++ b/pkg/autostart/managers.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "path/filepath" "runtime" @@ -51,6 +52,7 @@ type TemplateFileBasedManager struct { enabler func(ctx context.Context, enable bool, instName string) error filePath func(instName string) string template string + extraTemplateVars map[string]string autoStartedIdentifier func() string requestStart func(ctx context.Context, inst *limatype.Instance) error requestStop func(ctx context.Context, inst *limatype.Instance) (bool, error) @@ -118,13 +120,13 @@ func (t *TemplateFileBasedManager) renderTemplate(instName, workDir string, getE if err != nil { return nil, err } - return textutil.ExecuteTemplate( - t.template, - map[string]string{ - "Binary": selfExeAbs, - "Instance": instName, - "WorkDir": workDir, - }) + data := map[string]string{ + "Binary": selfExeAbs, + "Instance": instName, + "WorkDir": workDir, + } + maps.Copy(data, t.extraTemplateVars) + return textutil.ExecuteTemplate(t.template, data) } func (t *TemplateFileBasedManager) AutoStartedIdentifier() string { diff --git a/pkg/autostart/managers_darwin.go b/pkg/autostart/managers_darwin.go index 8451c4ce8dc..ff998e69319 100644 --- a/pkg/autostart/managers_darwin.go +++ b/pkg/autostart/managers_darwin.go @@ -16,3 +16,16 @@ func Manager() autoStartManager { requestStop: launchd.RequestStop, } } + +// DaemonManager returns an autostart manager for rendering and tracking system LaunchDaemon plists. +// The userName is the macOS user the daemon will run as. +// Note: install/uninstall require privileged operations (writing to /Library/LaunchDaemons/ and +// interacting with the system launchctl domain) that are handled by the `limactl daemon` CLI +// command via sudo rather than by this manager directly. +func DaemonManager(userName string) autoStartManager { + return &TemplateFileBasedManager{ + filePath: launchd.GetDaemonPlistPath, + template: launchd.DaemonTemplate, + extraTemplateVars: map[string]string{"UserName": userName}, + } +} diff --git a/pkg/autostart/managers_linux.go b/pkg/autostart/managers_linux.go index bbe334331b2..0996d2c5c02 100644 --- a/pkg/autostart/managers_linux.go +++ b/pkg/autostart/managers_linux.go @@ -5,6 +5,11 @@ package autostart import "github.com/lima-vm/lima/v2/pkg/autostart/systemd" +// DaemonManager is not supported on Linux; use systemd user services instead. +func DaemonManager(_ string) autoStartManager { + return ¬SupportedManager{} +} + // Manager returns the autostart manager for Linux. func Manager() autoStartManager { if systemd.IsRunningSystemd() { diff --git a/pkg/autostart/managers_others.go b/pkg/autostart/managers_others.go index f8c864ab397..b35ecfb7619 100644 --- a/pkg/autostart/managers_others.go +++ b/pkg/autostart/managers_others.go @@ -9,3 +9,8 @@ package autostart func Manager() autoStartManager { return ¬SupportedManager{} } + +// DaemonManager is not supported on this OS. +func DaemonManager(_ string) autoStartManager { + return ¬SupportedManager{} +} From 0c205269bc70b139af6d01de18a950c09a27cf0b Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 22:22:28 -0500 Subject: [PATCH 2/8] fix(website): remove trailing whitespace in security docs Signed-off-by: Robert Esker --- website/content/en/docs/security/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/content/en/docs/security/_index.md b/website/content/en/docs/security/_index.md index eb5429bb7c7..74b8c7023bf 100644 --- a/website/content/en/docs/security/_index.md +++ b/website/content/en/docs/security/_index.md @@ -28,7 +28,7 @@ sudo softwareupdate --install "Name of the Update" Alternatively , you can set the [`upgradePackages`](https://github.com/lima-vm/lima/blob/a28905cb1bd332cc7178c30f4e42d4c6bf1b2a34/templates/default.yaml#L181) in your template to `true` for most Linux distributions (except `alpine-iso`, for example). -> ⚠️ Rapidly updating can reduce exposure to known CVEs, but it can also increase exposure to upstream supply chain compromises (for example, [the XZ backdoor](https://en.wikipedia.org/wiki/XZ_Utils_backdoor)). +> ⚠️ Rapidly updating can reduce exposure to known CVEs, but it can also increase exposure to upstream supply chain compromises (for example, [the XZ backdoor](https://en.wikipedia.org/wiki/XZ_Utils_backdoor)). ## Security model From 1a077caed12508182139f092bf72082b94816c5c Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 22:33:22 -0500 Subject: [PATCH 3/8] docs(usage): document start-at-login and daemon install commands Signed-off-by: Robert Esker --- website/content/en/docs/usage/_index.md | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/website/content/en/docs/usage/_index.md b/website/content/en/docs/usage/_index.md index 542ed37a118..fbda46d79a2 100644 --- a/website/content/en/docs/usage/_index.md +++ b/website/content/en/docs/usage/_index.md @@ -67,3 +67,39 @@ The guest home directory exists independently on the following path: ### Shell completion - To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`. - To enable zsh completion, see `limactl completion zsh --help` + +## Starting instances automatically + +### At user login (macOS and Linux) + +`limactl start-at-login` registers a Lima instance to start automatically when +the user logs in. On macOS this installs a LaunchAgent; on Linux a systemd user +service. + +```bash +# Register +limactl start-at-login default + +# Unregister +limactl start-at-login --enabled=false default +``` + +The instance starts in the background on next login and on subsequent logins. + +### At system boot, without a user session (macOS only) + +For headless macOS servers where no user session is expected, use +`limactl daemon install` instead. This installs a system LaunchDaemon that +starts the instance at boot, before any user logs in. + +```bash +# Install (prompts for sudo once) +limactl daemon install k3s + +# Uninstall +limactl daemon uninstall k3s +``` + +The daemon runs the instance as the current user (or pass `--user ` +to specify one). The plist is installed to +`/Library/LaunchDaemons/io.lima-vm.daemon..plist`. From 1bfde8be98962275fd1905e04341339a2918c3fb Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 22:45:18 -0500 Subject: [PATCH 4/8] refactor(autostart): replace daemon subcommand with limactl autostart Unifies LaunchAgent (login) and LaunchDaemon (boot) management under a single `limactl autostart enable/disable` command per maintainer feedback. - `limactl autostart enable --condition=login` (default): installs a LaunchAgent on macOS or a systemd user service on Linux, equivalent to the existing `start-at-login` command - `limactl autostart enable --condition=boot --user=$USER`: installs a system LaunchDaemon on macOS (macOS only, prompts for sudo) - `limactl autostart disable`: removes whichever registration is present `limactl start-at-login` is kept but marked deprecated in favor of `limactl autostart enable`. Adds a dedicated usage/autostart.md subpage for the documentation. Signed-off-by: Robert Esker --- cmd/limactl/autostart.go | 51 ++++++++++ .../{daemon_darwin.go => autostart_darwin.go} | 92 +++++++++++++------ cmd/limactl/autostart_others.go | 67 ++++++++++++++ cmd/limactl/daemon.go | 47 ---------- cmd/limactl/daemon_others.go | 20 ---- cmd/limactl/main.go | 2 +- cmd/limactl/start-at-login.go | 1 + website/content/en/docs/usage/_index.md | 36 -------- website/content/en/docs/usage/autostart.md | 41 +++++++++ 9 files changed, 227 insertions(+), 130 deletions(-) create mode 100644 cmd/limactl/autostart.go rename cmd/limactl/{daemon_darwin.go => autostart_darwin.go} (55%) create mode 100644 cmd/limactl/autostart_others.go delete mode 100644 cmd/limactl/daemon.go delete mode 100644 cmd/limactl/daemon_others.go create mode 100644 website/content/en/docs/usage/autostart.md diff --git a/cmd/limactl/autostart.go b/cmd/limactl/autostart.go new file mode 100644 index 00000000000..1983567c6da --- /dev/null +++ b/cmd/limactl/autostart.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func newAutostartCommand() *cobra.Command { + autostartCommand := &cobra.Command{ + Use: "autostart", + Short: "Manage automatic startup of Lima instances", + GroupID: advancedCommand, + } + autostartCommand.AddCommand(newAutostartEnableCommand(), newAutostartDisableCommand()) + return autostartCommand +} + +func newAutostartEnableCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable INSTANCE", + Short: "Register an instance to start automatically", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: autostartEnableAction, + ValidArgsFunction: autostartComplete, + } + cmd.Flags().String( + "condition", "login", + "When to start the instance: \"login\" (user session) or \"boot\" (system boot, macOS only)", + ) + cmd.Flags().String( + "user", "", + "macOS username to run the instance as when --condition=boot (default: $USER)", + ) + return cmd +} + +func newAutostartDisableCommand() *cobra.Command { + return &cobra.Command{ + Use: "disable INSTANCE", + Short: "Unregister an instance from automatic startup", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: autostartDisableAction, + ValidArgsFunction: autostartComplete, + } +} + +func autostartComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteInstanceNames(cmd) +} diff --git a/cmd/limactl/daemon_darwin.go b/cmd/limactl/autostart_darwin.go similarity index 55% rename from cmd/limactl/daemon_darwin.go rename to cmd/limactl/autostart_darwin.go index 8280efbab78..dc5cfdb9680 100644 --- a/cmd/limactl/daemon_darwin.go +++ b/cmd/limactl/autostart_darwin.go @@ -13,23 +13,52 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/lima-vm/lima/v2/pkg/autostart" "github.com/lima-vm/lima/v2/pkg/autostart/launchd" "github.com/lima-vm/lima/v2/pkg/store" "github.com/lima-vm/lima/v2/pkg/textutil" ) -func daemonInstallAction(cmd *cobra.Command, args []string) error { - userName, err := cmd.Flags().GetString("user") +func autostartEnableAction(cmd *cobra.Command, args []string) error { + condition, err := cmd.Flags().GetString("condition") if err != nil { return err } - if userName == "" { - userName = os.Getenv("USER") + + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err } - if userName == "" { - return errors.New("could not determine user; pass --user") + + switch condition { + case "login": + if err := autostart.RegisterToStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to register instance %#q to start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q registered to start at login", inst.Name) + case "boot": + userName, err := cmd.Flags().GetString("user") + if err != nil { + return err + } + if userName == "" { + userName = os.Getenv("USER") + } + if userName == "" { + return errors.New("could not determine user; pass --user") + } + return daemonInstall(ctx, inst.Name, inst.Dir, userName) + default: + return fmt.Errorf("unknown condition %q: must be \"login\" or \"boot\"", condition) } + return nil +} +func autostartDisableAction(cmd *cobra.Command, args []string) error { ctx := cmd.Context() inst, err := store.Inspect(ctx, args[0]) if err != nil { @@ -39,6 +68,28 @@ func daemonInstallAction(cmd *cobra.Command, args []string) error { return err } + // Check for a LaunchAgent (login) registration first. + if registered, err := autostart.IsRegistered(ctx, inst); err != nil { + return err + } else if registered { + if err := autostart.UnregisterFromStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to unregister instance %#q from start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q unregistered from start at login", inst.Name) + return nil + } + + // Check for a LaunchDaemon (boot) installation. + daemonPath := launchd.GetDaemonPlistPath(inst.Name) + if _, err := os.Stat(daemonPath); err == nil { + return daemonUninstall(ctx, inst.Name) + } + + logrus.Infof("Instance %#q is not registered for automatic startup", inst.Name) + return nil +} + +func daemonInstall(ctx context.Context, instName, workDir, userName string) error { selfExe, err := os.Executable() if err != nil { return fmt.Errorf("could not determine limactl path: %w", err) @@ -46,8 +97,8 @@ func daemonInstallAction(cmd *cobra.Command, args []string) error { content, err := textutil.ExecuteTemplate(launchd.DaemonTemplate, map[string]string{ "Binary": selfExe, - "Instance": inst.Name, - "WorkDir": inst.Dir, + "Instance": instName, + "WorkDir": workDir, "UserName": userName, }) if err != nil { @@ -64,12 +115,10 @@ func daemonInstallAction(cmd *cobra.Command, args []string) error { } tmp.Close() - destPath := launchd.GetDaemonPlistPath(inst.Name) - svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name) + destPath := launchd.GetDaemonPlistPath(instName) + svcTarget := "system/" + launchd.DaemonServiceNameFrom(instName) - // Bootout first in case a previous install is still loaded (ignore error). _ = runSudo(ctx, "launchctl", "bootout", svcTarget) - if err := runSudo(ctx, "install", "-m", "644", tmp.Name(), destPath); err != nil { return fmt.Errorf("failed to install plist to %s: %w", destPath, err) } @@ -80,30 +129,21 @@ func daemonInstallAction(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to bootstrap LaunchDaemon: %w", err) } - logrus.Infof("LaunchDaemon installed for instance %q (runs as %q at boot)", inst.Name, userName) + logrus.Infof("LaunchDaemon installed for instance %q (runs as %q at boot)", instName, userName) logrus.Infof("Plist: %s", destPath) return nil } -func daemonUninstallAction(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - inst, err := store.Inspect(ctx, args[0]) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("instance %q not found", args[0]) - } - return err - } - - svcTarget := "system/" + launchd.DaemonServiceNameFrom(inst.Name) - destPath := launchd.GetDaemonPlistPath(inst.Name) +func daemonUninstall(ctx context.Context, instName string) error { + svcTarget := "system/" + launchd.DaemonServiceNameFrom(instName) + destPath := launchd.GetDaemonPlistPath(instName) _ = runSudo(ctx, "launchctl", "bootout", svcTarget) _ = runSudo(ctx, "launchctl", "disable", svcTarget) if err := runSudo(ctx, "rm", "-f", destPath); err != nil { return fmt.Errorf("failed to remove %s: %w", destPath, err) } - logrus.Infof("LaunchDaemon uninstalled for instance %q", inst.Name) + logrus.Infof("LaunchDaemon uninstalled for instance %q", instName) return nil } diff --git a/cmd/limactl/autostart_others.go b/cmd/limactl/autostart_others.go new file mode 100644 index 00000000000..a888aa8930b --- /dev/null +++ b/cmd/limactl/autostart_others.go @@ -0,0 +1,67 @@ +//go:build !darwin + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/autostart" + "github.com/lima-vm/lima/v2/pkg/store" +) + +func autostartEnableAction(cmd *cobra.Command, args []string) error { + condition, err := cmd.Flags().GetString("condition") + if err != nil { + return err + } + if condition == "boot" { + return errors.New("--condition=boot is only supported on macOS") + } + + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + if err := autostart.RegisterToStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to register instance %#q to start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q registered to start at login", inst.Name) + return nil +} + +func autostartDisableAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + inst, err := store.Inspect(ctx, args[0]) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("instance %q not found", args[0]) + } + return err + } + + if registered, err := autostart.IsRegistered(ctx, inst); err != nil { + return err + } else if !registered { + logrus.Infof("Instance %#q is not registered for automatic startup", inst.Name) + return nil + } + + if err := autostart.UnregisterFromStartAtLogin(ctx, inst); err != nil { + return fmt.Errorf("failed to unregister instance %#q from start at login: %w", inst.Name, err) + } + logrus.Infof("Instance %#q unregistered from start at login", inst.Name) + return nil +} diff --git a/cmd/limactl/daemon.go b/cmd/limactl/daemon.go deleted file mode 100644 index b1aca6d207d..00000000000 --- a/cmd/limactl/daemon.go +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Copyright The Lima Authors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "github.com/spf13/cobra" -) - -func newDaemonCommand() *cobra.Command { - daemonCommand := &cobra.Command{ - Use: "daemon", - Short: "Manage Lima instances as system LaunchDaemons (macOS only, requires root)", - GroupID: advancedCommand, - } - daemonCommand.AddCommand(newDaemonInstallCommand(), newDaemonUninstallCommand()) - return daemonCommand -} - -func newDaemonInstallCommand() *cobra.Command { - installCommand := &cobra.Command{ - Use: "install INSTANCE", - Short: "Install a system LaunchDaemon for the instance (run with sudo)", - Args: WrapArgsError(cobra.ExactArgs(1)), - RunE: daemonInstallAction, - ValidArgsFunction: daemonComplete, - } - installCommand.Flags().String( - "user", "", - "macOS username to run the daemon as (default: $USER)", - ) - return installCommand -} - -func newDaemonUninstallCommand() *cobra.Command { - return &cobra.Command{ - Use: "uninstall INSTANCE", - Short: "Uninstall the system LaunchDaemon for the instance (run with sudo)", - Args: WrapArgsError(cobra.ExactArgs(1)), - RunE: daemonUninstallAction, - ValidArgsFunction: daemonComplete, - } -} - -func daemonComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return bashCompleteInstanceNames(cmd) -} diff --git a/cmd/limactl/daemon_others.go b/cmd/limactl/daemon_others.go deleted file mode 100644 index 67280e0cf57..00000000000 --- a/cmd/limactl/daemon_others.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build !darwin - -// SPDX-FileCopyrightText: Copyright The Lima Authors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "errors" - - "github.com/spf13/cobra" -) - -func daemonInstallAction(_ *cobra.Command, _ []string) error { - return errors.New("daemon install is only supported on macOS") -} - -func daemonUninstallAction(_ *cobra.Command, _ []string) error { - return errors.New("daemon uninstall is only supported on macOS") -} diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 07e37be48c6..9ed78b3eb85 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -205,8 +205,8 @@ func newApp() *cobra.Command { newTemplateCommand(), newRestartCommand(), newSudoersCommand(), + newAutostartCommand(), newStartAtLoginCommand(), - newDaemonCommand(), newNetworkCommand(), newCloneCommand(), newRenameCommand(), diff --git a/cmd/limactl/start-at-login.go b/cmd/limactl/start-at-login.go index b7ca2aaf0b2..2d68ddf33f5 100644 --- a/cmd/limactl/start-at-login.go +++ b/cmd/limactl/start-at-login.go @@ -11,6 +11,7 @@ func newStartAtLoginCommand() *cobra.Command { startAtLoginCommand := &cobra.Command{ Use: "start-at-login INSTANCE", Short: "Register/Unregister an autostart file for the instance", + Deprecated: "use \"limactl autostart enable\" or \"limactl autostart disable\" instead", Args: WrapArgsError(cobra.MaximumNArgs(1)), RunE: startAtLoginAction, ValidArgsFunction: startAtLoginComplete, diff --git a/website/content/en/docs/usage/_index.md b/website/content/en/docs/usage/_index.md index fbda46d79a2..542ed37a118 100644 --- a/website/content/en/docs/usage/_index.md +++ b/website/content/en/docs/usage/_index.md @@ -67,39 +67,3 @@ The guest home directory exists independently on the following path: ### Shell completion - To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`. - To enable zsh completion, see `limactl completion zsh --help` - -## Starting instances automatically - -### At user login (macOS and Linux) - -`limactl start-at-login` registers a Lima instance to start automatically when -the user logs in. On macOS this installs a LaunchAgent; on Linux a systemd user -service. - -```bash -# Register -limactl start-at-login default - -# Unregister -limactl start-at-login --enabled=false default -``` - -The instance starts in the background on next login and on subsequent logins. - -### At system boot, without a user session (macOS only) - -For headless macOS servers where no user session is expected, use -`limactl daemon install` instead. This installs a system LaunchDaemon that -starts the instance at boot, before any user logs in. - -```bash -# Install (prompts for sudo once) -limactl daemon install k3s - -# Uninstall -limactl daemon uninstall k3s -``` - -The daemon runs the instance as the current user (or pass `--user ` -to specify one). The plist is installed to -`/Library/LaunchDaemons/io.lima-vm.daemon..plist`. diff --git a/website/content/en/docs/usage/autostart.md b/website/content/en/docs/usage/autostart.md new file mode 100644 index 00000000000..6e5e10d1be5 --- /dev/null +++ b/website/content/en/docs/usage/autostart.md @@ -0,0 +1,41 @@ +--- +title: Automatic Startup +weight: 4 +--- + +## Starting instances automatically + +Use `limactl autostart enable` to register a Lima instance to start automatically. +Use `limactl autostart disable` to remove the registration. + +### At user login (macOS and Linux) + +```bash +# Register +limactl autostart enable default + +# Unregister +limactl autostart disable default +``` + +On macOS this installs a LaunchAgent in `~/Library/LaunchAgents/`. On Linux it +installs a systemd user service. The instance starts in the background on the +next login and on subsequent logins. + +### At system boot, without a user session (macOS only) + +For headless macOS servers where no user session is expected, use +`--condition=boot`. This installs a system LaunchDaemon that starts the instance +at boot, before any user logs in. + +```bash +# Register (prompts for sudo once) +limactl autostart enable --condition=boot k3s + +# Unregister +limactl autostart disable k3s +``` + +The `--user` flag specifies which macOS user the instance runs as (default: +`$USER`). The plist is installed to +`/Library/LaunchDaemons/io.lima-vm.daemon..plist`. From 6dcdb0f71de516939af5446d357b3b0c605e8e2d Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 23:00:11 -0500 Subject: [PATCH 5/8] fix(autostart): address review feedback - Call Flags() once in newAutostartEnableCommand via local var - Mark autostart docs as requiring Lima >= 2.2 Signed-off-by: Robert Esker --- cmd/limactl/autostart.go | 5 +++-- website/content/en/docs/usage/autostart.md | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/limactl/autostart.go b/cmd/limactl/autostart.go index 1983567c6da..1e3d5c7cf73 100644 --- a/cmd/limactl/autostart.go +++ b/cmd/limactl/autostart.go @@ -25,11 +25,12 @@ func newAutostartEnableCommand() *cobra.Command { RunE: autostartEnableAction, ValidArgsFunction: autostartComplete, } - cmd.Flags().String( + flags := cmd.Flags() + flags.String( "condition", "login", "When to start the instance: \"login\" (user session) or \"boot\" (system boot, macOS only)", ) - cmd.Flags().String( + flags.String( "user", "", "macOS username to run the instance as when --condition=boot (default: $USER)", ) diff --git a/website/content/en/docs/usage/autostart.md b/website/content/en/docs/usage/autostart.md index 6e5e10d1be5..3f74ca5829d 100644 --- a/website/content/en/docs/usage/autostart.md +++ b/website/content/en/docs/usage/autostart.md @@ -3,6 +3,9 @@ title: Automatic Startup weight: 4 --- +| ⚡ Requirement | Lima >= 2.2 | +|----------------|-------------| + ## Starting instances automatically Use `limactl autostart enable` to register a Lima instance to start automatically. From 3e3f8406280370ebf720c3c4daf14ab936e9bd65 Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 23:09:16 -0500 Subject: [PATCH 6/8] docs(autostart): address remaining review feedback - Shorten start-at-login deprecated message to "use limactl autostart instead" - Add Lima < 2.2 section to autostart.md documenting limactl start-at-login - Add limactl start-at-login to deprecated features page Signed-off-by: Robert Esker --- cmd/limactl/start-at-login.go | 2 +- website/content/en/docs/releases/deprecated.md | 1 + website/content/en/docs/usage/autostart.md | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/limactl/start-at-login.go b/cmd/limactl/start-at-login.go index 2d68ddf33f5..ba9fb013fb8 100644 --- a/cmd/limactl/start-at-login.go +++ b/cmd/limactl/start-at-login.go @@ -11,7 +11,7 @@ func newStartAtLoginCommand() *cobra.Command { startAtLoginCommand := &cobra.Command{ Use: "start-at-login INSTANCE", Short: "Register/Unregister an autostart file for the instance", - Deprecated: "use \"limactl autostart enable\" or \"limactl autostart disable\" instead", + Deprecated: "use \"limactl autostart\" instead", Args: WrapArgsError(cobra.MaximumNArgs(1)), RunE: startAtLoginAction, ValidArgsFunction: startAtLoginComplete, diff --git a/website/content/en/docs/releases/deprecated.md b/website/content/en/docs/releases/deprecated.md index 75719d42fa9..d7505238b5c 100644 --- a/website/content/en/docs/releases/deprecated.md +++ b/website/content/en/docs/releases/deprecated.md @@ -5,6 +5,7 @@ weight: 10 The following features are deprecated: +- `limactl start-at-login` command: deprecated in v2.2.0 (Use `limactl autostart` instead) - `limactl show-ssh` command: deprecated in v0.18.0 (Use `ssh -F ~/.lima/default/ssh.config lima-default` instead) - Ansible provisioning mode: deprecated in Lima v1.1.0 (Use `ansible-playbook playbook.yaml` after the start instead) - `limactl --yes` flag: deprecated in Lima v2.0.0 (Use `limactl (clone|rename|edit|shell) --start` instead) diff --git a/website/content/en/docs/usage/autostart.md b/website/content/en/docs/usage/autostart.md index 3f74ca5829d..10c6469390f 100644 --- a/website/content/en/docs/usage/autostart.md +++ b/website/content/en/docs/usage/autostart.md @@ -42,3 +42,15 @@ limactl autostart disable k3s The `--user` flag specifies which macOS user the instance runs as (default: `$USER`). The plist is installed to `/Library/LaunchDaemons/io.lima-vm.daemon..plist`. + +## Lima < 2.2 + +Use `limactl start-at-login` (equivalent to `limactl autostart enable --condition=login`): + +```bash +# Register +limactl start-at-login default + +# Unregister +limactl start-at-login --enabled=false default +``` From 6616e70739e792e406166ca99bd962d39992918f Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 23:12:01 -0500 Subject: [PATCH 7/8] docs(autostart): add introductory context paragraph Signed-off-by: Robert Esker --- website/content/en/docs/usage/autostart.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/content/en/docs/usage/autostart.md b/website/content/en/docs/usage/autostart.md index 10c6469390f..c29d5da06ac 100644 --- a/website/content/en/docs/usage/autostart.md +++ b/website/content/en/docs/usage/autostart.md @@ -6,6 +6,11 @@ weight: 4 | ⚡ Requirement | Lima >= 2.2 | |----------------|-------------| +Lima instances can be registered to start automatically using `limactl autostart`. +Two conditions are supported: `login` (start when the user logs in) and `boot` +(start at system boot, before any user session). This replaces the older +`limactl start-at-login` command, which is deprecated as of Lima v2.2. + ## Starting instances automatically Use `limactl autostart enable` to register a Lima instance to start automatically. From be5c6beb55adaaed589c20bef243f2587a8f1433 Mon Sep 17 00:00:00 2001 From: Robert Esker Date: Thu, 14 May 2026 23:28:46 -0500 Subject: [PATCH 8/8] docs(deprecated): append start-at-login entry with context Signed-off-by: Robert Esker --- website/content/en/docs/releases/deprecated.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/content/en/docs/releases/deprecated.md b/website/content/en/docs/releases/deprecated.md index d7505238b5c..b1d6cc462eb 100644 --- a/website/content/en/docs/releases/deprecated.md +++ b/website/content/en/docs/releases/deprecated.md @@ -5,13 +5,13 @@ weight: 10 The following features are deprecated: -- `limactl start-at-login` command: deprecated in v2.2.0 (Use `limactl autostart` instead) - `limactl show-ssh` command: deprecated in v0.18.0 (Use `ssh -F ~/.lima/default/ssh.config lima-default` instead) - Ansible provisioning mode: deprecated in Lima v1.1.0 (Use `ansible-playbook playbook.yaml` after the start instead) - `limactl --yes` flag: deprecated in Lima v2.0.0 (Use `limactl (clone|rename|edit|shell) --start` instead) - Environment variable `LIMA_SSH_OVER_VSOCK`: deprecated in Lima v2.0.2 (Use the YAML property `.ssh.overVsock`) - YAML property `cpuType`: deprecated in Lima v2.0.0 (Use `vmOpts.qemu.cpuType` instead) - YAML property `rosetta`: deprecated in Lima v2.0.0 (Use `vmOpts.vz.rosetta` instead) +- `limactl start-at-login` command: deprecated in Lima v2.2.0 (Use `limactl autostart` instead, which also adds support for starting instances at system boot via `--condition=boot`) ## Removed features - YAML property `network`: deprecated in [Lima v0.7.0](https://github.com/lima-vm/lima/commit/07e68230e70b21108d2db3ca5e0efd0e43842fbd)