mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(test): isolate cmd/multica suite home resolution on Windows os.UserHomeDir reads USERPROFILE, not HOME, so every test that redirected only HOME kept resolving ~/.multica against the real home: mkProfiles stopped isolating at all, and a full-suite run wrote SaveCLIConfig fixtures over a real default-profile config.json. Redirect HOME and USERPROFILE process-wide in TestMain, and make mkProfiles redirect both so per-test isolation holds on Windows too. On unix nothing changes: USERPROFILE is unused there. Co-authored-by: multica-agent <[email protected]> * feat(cli): register daemon boot autostart on every platform multica daemon start now registers the profile's daemon to come back at login/boot so a reboot no longer takes the runtime offline: an HKCU Run key on Windows, a launchd LaunchAgent on macOS, and a systemd user unit (or XDG autostart fallback without systemd) on Linux. New 'daemon autostart enable|disable|status' manages the registration; --no-autostart opts a single start out, and daemons spawned by a manager (MULTICA_LAUNCHED_BY, i.e. the Desktop app's own start-at-login pref) are never registered. The registered command runs 'daemon start --foreground' per profile and snapshots the shell's PATH so login sessions keep finding agent CLIs. EnsureHiddenConsole now hides a console that exists only for the daemon itself — a Run-key launch would otherwise park a visible console window for the daemon's whole lifetime. Co-authored-by: multica-agent <[email protected]> * refactor(cli): make boot autostart registration opt-in per review Registration now happens only through 'multica daemon autostart enable': daemon start (and setup / restart / --foreground) merely print the enable hint when nothing is registered and silently refresh an entry Multica owns — marked by a comment line in a unit/.desktop file or a ManagedBy key in a plist (the Run value name doubles as the marker on Windows). Refresh skips anything without the marker and skips entirely under an external systemd unit (INVOCATION_ID with a cgroup that is not ours), so a hand-written unit like the one in #7091 is never overwritten. --no-autostart is gone (there is nothing to opt out of), loginctl enable-linger is no longer run automatically (enable prints the command instead), and 'autostart enable' now spells out that only PATH carries into the login session while shell-exported variables do not. Also fixes the binary-update restart handoff under our own systemd unit: spawning a successor and exiting 0 lets systemd's cgroup cleanup kill it while Restart=on-failure never fires, leaving the runtime offline after the first auto-update. The daemon now exits with daemonSystemdHandoffExitStatus (42) instead; the generated unit carries RestartForceExitStatus for it, and linux tests cover the unit-ownership detection behind both the handoff and the refresh gate. Review items also addressed: gofmt applied to cmd_daemon_test.go and proc_windows.go, the Desktop wording corrected to its app-start daemon preference (it has no OS login item), and CLI_AND_DAEMON.md plus the daemon start / autostart help text follow the opt-in flow. Co-authored-by: multica-agent <[email protected]> * fix(test): redirect USERPROFILE alongside HOME in cmd/multica tests Every t.Setenv("HOME", ...) in this package now goes through redirectTestHome, which sets both home environment variables. Production resolves ~/.multica via os.UserHomeDir — USERPROFILE on Windows — so a HOME-only redirect split the fixture's write path from the code's read path: on Windows the fixture landed where the code never looked (or, before TestMain's scratch home, in the real ~/.multica). CI on ubuntu is unaffected; the Windows host suite stops depending on whatever happens to be in the real profile directory. Co-authored-by: multica-agent <[email protected]> * fix(cli): record autostart with the stable brew path, shared with the restart target The exit-42 systemd handoff restarts whatever ExecStart recorded, and autostartSpecFor recorded os.Executable() verbatim — on brew installs (Homebrew on Linux too, reached through install.sh) that is the versioned keg path, which `brew upgrade` deletes when it cleans the old keg: systemd then fails ExecStart with 203/EXEC and gives up after StartLimitBurst, leaving the runtime offline until someone starts it by hand; a LaunchAgent would point at a missing file at the next login. Fix per review: both consumers now resolve through one shared helper, cli.StableExecutablePath, with identical detection order — the path's own known-Cellar shape first, then `brew --prefix` — so the two cannot drift. The daemon's restartTargetBinary keeps its seam-based per-process cache but delegates the mapping; autostartSpecFor runs cli.StableSelfExecutable before recording. cli.IsBrewInstall shares the same detection core. Tests pin the keg -> <prefix>/bin/multica mapping at the helper level for all three known prefixes, and end to end through autostartSpecFor feeding a brew keg path, as requested in the review. Co-authored-by: multica-agent <[email protected]> * fix(cli): key autostart ownership on file presence, not enablement Review round 3: on Linux Enabled only means "linked in default.target.wants", so the enable/disable guard (Enabled && !Managed) missed a hand-written unit at our path that was unlinked — or linked under another target such as graphical-session.target — and disable deleted it; separately, the XDG sweeper deleted an unmarked multica-daemon.desktop outright from enable, disable, and the silent daemon start refresh. - autostartState gains Present ("a file/value exists at our path"); refuseUnmanagedAutostart and the status note key off Present, while the enabled/disabled verdict still keys off Enabled. - platformRemoveAutostart re-checks the marker on the unit BEFORE systemctl disable (refusing must not unlink the user's unit as a side effect) and refuses to delete an unmarked file; removeXdgAutostartFile removes only marked files, which covers all three of its call sites (enable's stale sweep, the daemon start refresh, disable). - sync no longer hints enable at a foreign unlinked file, and never heals an owned-but-unlinked entry — refresh must not re-enable what the user manually disabled — it hints instead. - linux tests cover unlinked / other-target-linked / default-linked unmarked units on the read side, the removal backstop, a managed unit coexisting with an unmarked .desktop, and the mark-checked XDG sweeper; shared guard/status/sync tests move to the presence contract. Co-authored-by: multica-agent <[email protected]> * fix(cli): keep the running service's restart policy when disabling autostart Review round 4 (P2): on Linux, `autostart disable` deleted the managed unit file after unlinking it. The running daemon kept going, but systemd lost the unit — LoadState=not-found, Restart=no — so the next binary-update handoff (exit 42) exited into a service that could no longer restart it and the runtime stayed offline until a manual start. Disable now removes the enablement only: unlink every wants entry (systemctl --user disable, with a glob fallback for a missing user bus) and KEEP the marked unit file, so the supervised process retains its restart policy while nothing starts at the next login. Re-enabling re-links the same file. A second disable is a no-op again (change is keyed off links actually present, in any target). The macOS equivalent is fixed in the same spirit: the plist is deleted WITHOUT launchctl bootout, which used to terminate the daemon launchd was supervising — disable means "don't start at the next login", not "kill this session". enable/disable now read the state once and share it with the ownership guard; enable's wording keys off the pre-state, because re-linking a kept unit often changes no file content and "already enabled" would describe the state the user just left. The daemon-start hint now fires only when nothing is registered at all — never at a disabled-or-foreign file the user chose or owns — and the refresh still never re-links. Regression coverage for running service → disable → handoff: TestDisableKeepsRunningServiceRecoverable asserts the unit file survives with RestartForceExitStatus intact, no wants link remains, the handoff still detects our own unit (exit-42 path), a subsequent sync does not re-enable, and a second disable reports no change. Docs note the keep-the-file semantics on both Linux and macOS. Co-authored-by: multica-agent <[email protected]> * test(cli): update the coexistence test to the keep-the-unit disable contract CI caught the round-4 test asserting the pre-round-5 behavior: it wrote a marked unit WITHOUT a wants link and expected platformRemoveAutostart to delete it. Disable now keeps the marked unit (unlinking its enablement) so a running service retains its restart policy — so the test links the unit (giving disable real work, changed=true), asserts the unit survives unlinked, and keeps its original point: the hand-written .desktop beside it is never touched. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: ZhangRui <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Bohan-J <[email protected]>
94 lines
4.1 KiB
Go
94 lines
4.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// StableExecutablePath maps a self-executable path to the path that should be
|
|
// recorded wherever a multica invocation has to outlive a brew upgrade — the
|
|
// daemon's binary-update restart target and a boot-autostart entry's
|
|
// ExecStart/ProgramArguments.
|
|
//
|
|
// The two consumers must never disagree: `os.Executable()` reports the
|
|
// versioned keg path on brew installs (`/proc/self/exe` on Linux, realpath on
|
|
// macOS), and `brew upgrade` deletes that path when it cleans the old keg.
|
|
// A unit recorded with the keg path survives neither the restart it would
|
|
// trigger (systemd fails ExecStart with 203/EXEC and gives up after
|
|
// StartLimitBurst) nor the next login (a LaunchAgent pointing at a deleted
|
|
// file), while the spawn-based restart handoff has always resolved through
|
|
// the same brew facts — which is exactly why this mapping lives in one
|
|
// function both callers use.
|
|
//
|
|
// brewInstall and brewPrefix are the caller's detection facts:
|
|
//
|
|
// brewInstall=true, prefix!="" -> <prefix>/bin/multica (stable brew symlink)
|
|
// brewInstall=true, prefix=="" -> path unchanged; the caller decides how to
|
|
// report a brew install whose prefix it
|
|
// could not resolve
|
|
// brewInstall=false -> path with symlinks resolved, or path
|
|
// unchanged when resolution fails
|
|
//
|
|
// Detection itself stays with the caller because the daemon caches it per
|
|
// process behind its seams (see Daemon.restartTargetBinary) while one-shot
|
|
// callers use StableSelfExecutable. Both detect in the SAME order — the
|
|
// path's own shape (a known Cellar) first, then `brew --prefix` — so a keg
|
|
// path always resolves to ITS prefix and the two can never drift.
|
|
func StableExecutablePath(path, brewPrefix string, brewInstall bool) string {
|
|
if brewInstall {
|
|
if brewPrefix != "" {
|
|
// filepath.Join, as the daemon has always done: brew only exists
|
|
// on unix where the separators coincide anyway, and matching the
|
|
// historical mapping keeps every existing expectation intact.
|
|
return filepath.Join(brewPrefix, "bin", "multica")
|
|
}
|
|
return path
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
return resolved
|
|
}
|
|
return path
|
|
}
|
|
|
|
// StableSelfExecutable is the one-stop form of StableExecutablePath for
|
|
// callers that hold a self-executable path and no brew-fact cache of their
|
|
// own (boot-autostart registration). Detection order mirrors the daemon's —
|
|
// MatchKnownBrewPrefix first, GetBrewPrefix as fallback — so both consumers
|
|
// resolve the same path for the same install.
|
|
func StableSelfExecutable(path string) string {
|
|
resolved := evalSymlinkOrSelf(path)
|
|
// The path's own shape is the stronger evidence: a known Cellar path
|
|
// resolves to THAT Cellar's prefix even if `brew --prefix` reports a
|
|
// different root, so fixtures and moved installs stay per-path correct.
|
|
if prefix := MatchKnownBrewPrefix(resolved); prefix != "" {
|
|
return StableExecutablePath(path, prefix, true)
|
|
}
|
|
if prefix := GetBrewPrefix(); prefix != "" && strings.HasPrefix(resolved, prefix) {
|
|
return StableExecutablePath(path, prefix, true)
|
|
}
|
|
return StableExecutablePath(path, "", false)
|
|
}
|
|
|
|
// brewInstallForPath reports whether path belongs to a Homebrew install,
|
|
// using the same checks and order as StableSelfExecutable but answering a
|
|
// yes/no question — IsBrewInstall uses it against the running binary, and
|
|
// StableSelfExecutable's fallback containment check mirrors it.
|
|
func brewInstallForPath(path string) bool {
|
|
resolved := evalSymlinkOrSelf(path)
|
|
if MatchKnownBrewPrefix(resolved) != "" {
|
|
return true
|
|
}
|
|
prefix := GetBrewPrefix()
|
|
return prefix != "" && strings.HasPrefix(resolved, prefix)
|
|
}
|
|
|
|
// evalSymlinkOrSelf resolves symlinks when it can and returns the input
|
|
// unchanged otherwise — a path that does not exist (a stale keg, a fixture)
|
|
// must still be shape-matchable against the known prefixes.
|
|
func evalSymlinkOrSelf(path string) string {
|
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
|
return resolved
|
|
}
|
|
return path
|
|
}
|