diff --git a/Makefile b/Makefile index ebbe69e..0832223 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ EMBED_CONFIG := config.json EMBED_MODELS := models.json .PHONY: all help build build-check build-summary config embed-ready check run once dry-run no-mutate \ - install uninstall print-service migrate-config \ + install uninstall purge print-service migrate-config \ test coverage vet fmt fmt-check lint staticcheck vulcheck ci tidy clean \ ssh-add @@ -93,7 +93,7 @@ no-mutate: build install: build sudo $(BINARY) --install --config $(CONFIG) -## uninstall: stop, disable, and remove everything `make install` created (requires root) +## uninstall: stop, disable, and remove the service; leaves data in place (requires root) # Deliberately does not depend on `build`: uninstalling must work even without # a config.json/models.json in place, since removing a broken install is # exactly when those might be missing or invalid. If a binary from a previous @@ -107,6 +107,14 @@ uninstall: fi sudo $(BINARY) --uninstall +## purge: destructive — stop, disable, remove the service, and delete its workspace/logs/state data (requires root) +purge: + @if [ ! -x "$(BINARY)" ]; then \ + echo "no existing $(BINARY); building one (requires $(EMBED_CONFIG) and $(EMBED_MODELS) at the repo root, since go:embed compiles them in)"; \ + go build -ldflags="-w -s" -o $(BINARY) ./cmd; \ + fi + sudo $(BINARY) --uninstall --purge + ## print-service: print the embedded systemd unit without installing anything print-service: build $(BINARY) --print-service diff --git a/README.md b/README.md index 6d639d5..d865857 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,8 @@ All flags on the built binary (`bin/coding-agent-loop`, or via `make run` / `mak | `--no-server` | off | do not start the control API | | `--check` | off | run start-up checks (binaries, auth, config) and exit | | `--install` | off | install + enable + start the systemd unit; **must run as root** | -| `--uninstall` | off | stop, disable, and remove everything `--install` created; **must run as root** | +| `--uninstall` | off | stop, disable, and remove the systemd unit and `/opt/coding-agent-loop`; **leaves all data in place**; **must run as root** | +| `--purge` | off | only with `--uninstall`: also delete the configured workspace/logs/state paths and the dedicated service account's home | | `--print-service` | off | print the embedded systemd unit to stdout and exit; no privileges needed | | `--migrate-config` | off | rewrite `--config` to the current `config.json` schema, in place; see [Migrating config.json](#migrating-configjson) | @@ -863,31 +864,43 @@ sudo systemctl restart coding-agent-loop curl localhost:8787/status # gate/run state (from the host, not the service user) ``` -To remove everything `--install` created: +To remove the service `--install` created, leaving all data in place: ```sh sudo bin/coding-agent-loop --uninstall # or: make uninstall ``` -`--uninstall` (root required) reverses every step of `--install`: +To remove the service *and* its data: + +```sh +sudo bin/coding-agent-loop --uninstall --purge # or: make purge +``` + +`--uninstall` (root required) always does this much: 1. stops and disables `coding-agent-loop.service` (fine if it wasn't running); -2. reads `workspace.root`, `workspace.repos_root`, `workspace.logs_root`, `store.path`, and +2. removes `/etc/systemd/system/coding-agent-loop.service` and runs `systemctl daemon-reload`; +3. removes `/opt/coding-agent-loop` entirely (binary and config). + +Only with `--purge` does it also: + +4. read `workspace.root`, `workspace.repos_root`, `workspace.logs_root`, `store.path`, and `claude.usage_cache_path` from `/opt/coding-agent-loop/config.json` (the copy that actually drove the running service — falling back to whatever `--config` points at, default `config.json`, if that copy is already gone, then to the compiled defaults under `~/.agent-loop` if neither is - found) and removes exactly those directories/files, resolved against the account the service ran + found) and remove exactly those directories/files, resolved against the account the service ran as — **not** `claude.credentials_path`, which is Claude Code's own login and predates this app's install; -3. removes `/etc/systemd/system/coding-agent-loop.service` and runs `systemctl daemon-reload`; -4. removes `/opt/coding-agent-loop` entirely (binary and config); 5. if `--install` ever fell back to creating the dedicated `coding-agent-loop` system user (no - `$SUDO_USER` at install time), removes that account and its entire home (`userdel -r`) — safe - because that account and home exist solely for this service, and a superset of step 2 for + `$SUDO_USER` at install time), remove that account and its entire home (`userdel -r`) — safe + because that account and home exist solely for this service, and a superset of step 4 for anything under it. +Without `--purge`, the configured state paths and (if applicable) the dedicated account/home are +left untouched, and `--uninstall` logs each path it left behind. + If you moved `workspace.root`/`workspace.repos_root`/`workspace.logs_root`/`store.path`/ -`claude.usage_cache_path` to non-default locations, step 2 follows your config there too — it does +`claude.usage_cache_path` to non-default locations, step 4 follows your config there too — it does not assume `~/.agent-loop`. Run `--uninstall` the same way you ran `--install` (`sudo` from your own account, or with the same `--config`) so it resolves the same account and config `--install` used. diff --git a/cmd/agent.go b/cmd/agent.go index 66113be..5a8f084 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -41,6 +41,7 @@ type flags struct { checkOnly bool install bool uninstall bool + purge bool printUnit bool migrateConfig bool } @@ -61,7 +62,8 @@ Usage: coding-agent-loop --print-service print the embedded systemd unit coding-agent-loop --migrate-config bring config.json up to the current schema, in place sudo coding-agent-loop --install install, enable, and start the systemd unit - sudo coding-agent-loop --uninstall stop, disable, and remove everything --install created + sudo coding-agent-loop --uninstall stop, disable, and remove the service only + sudo coding-agent-loop --uninstall --purge also delete workspace/logs/state data See README.md for configuration (config.json, models.json) and the control API. @@ -82,11 +84,17 @@ func main() { flag.BoolVar(&f.noServer, "no-server", false, "do not start the control API") flag.BoolVar(&f.checkOnly, "check", false, "run start-up checks and exit") flag.BoolVar(&f.install, "install", false, "install the systemd unit (embedded in this binary), enable it, and start it; must run as root") - flag.BoolVar(&f.uninstall, "uninstall", false, "stop, disable, and remove the systemd unit, /opt/coding-agent-loop, and any ~/.agent-loop or dedicated service user --install created; must run as root") + flag.BoolVar(&f.uninstall, "uninstall", false, "stop, disable, and remove the systemd unit and /opt/coding-agent-loop; leaves data in place unless --purge is also given; must run as root") + flag.BoolVar(&f.purge, "purge", false, "with --uninstall: also delete the service's data — workspace.root, workspace.repos_root, workspace.logs_root, store.path, claude.usage_cache_path, and the dedicated service account's home. Without it, --uninstall removes only the service and leaves all data in place") flag.BoolVar(&f.printUnit, "print-service", false, "print the systemd unit --install would write and exit") flag.BoolVar(&f.migrateConfig, "migrate-config", false, "rewrite -config to the current config.json schema: keep every value already set, add new fields at their default, drop and report fields the schema no longer has; the original is saved as -config.bak first. Combine with -dry-run to preview on stdout instead of writing anything") flag.Parse() + if err := validateFlags(f); err != nil { + fmt.Fprintf(os.Stderr, "coding-agent-loop: %v\n", err) + os.Exit(1) + } + if f.printUnit { unit, err := install.PreviewUnit() if err != nil { @@ -103,6 +111,15 @@ func main() { } } +// validateFlags rejects flag combinations that parse individually but make +// no sense together. +func validateFlags(f flags) error { + if f.purge && !f.uninstall { + return fmt.Errorf("--purge only applies to --uninstall (try: sudo coding-agent-loop --uninstall --purge)") + } + return nil +} + func run(f flags) error { log := newLogger(f.logLevel) @@ -115,6 +132,7 @@ func run(f flags) error { if f.uninstall { return install.Uninstall(install.UninstallOptions{ ConfigPath: f.configPath, + Purge: f.purge, Log: func(format string, args ...any) { log.Info(format, args...) }, }) } diff --git a/internal/install/install.go b/internal/install/install.go index 7c8c422..61eea5a 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -277,18 +277,27 @@ type UninstallOptions struct { // ConfigPath is consulted for workspace/store paths only when // installedConfigPath does not exist. Optional. ConfigPath string - Log func(format string, args ...any) + // Purge, when true, also deletes the service's data: the configured + // state paths (workspace.root, workspace.repos_root, workspace.logs_root, + // store.path, claude.usage_cache_path) and, if Run ever created it, the + // dedicated service account and its home. When false, --uninstall removes + // only the service and leaves all of that data in place. + Purge bool + Log func(format string, args ...any) } -// Uninstall reverses Run: stops and disables the unit, removes the unit file -// and /opt/coding-agent-loop, and removes exactly the state directories and -// files the operator's config.json told the service to use — workspace.root, -// workspace.repos_root, workspace.logs_root, store.path, and -// claude.usage_cache_path — resolved against the account the service ran as, -// the same way Run resolves it. It never touches claude.credentials_path: -// that file is Claude Code's own login, not something this app created, and -// other tools may depend on it surviving. It must run as root, since it -// touches /etc, /opt, and the service account's files. +// Uninstall reverses the service-installing part of Run unconditionally: +// stops and disables the unit, removes the unit file, and removes +// /opt/coding-agent-loop. When opts.Purge is set, it additionally removes the +// state directories and files the operator's config.json told the service to +// use — workspace.root, workspace.repos_root, workspace.logs_root, +// store.path, and claude.usage_cache_path — resolved against the account the +// service ran as, the same way Run resolves it, and (if Run ever created it) +// the dedicated service account and its home. It never touches +// claude.credentials_path under either flag: that file is Claude Code's own +// login, not something this app created, and other tools may depend on it +// surviving. It must run as root, since it touches /etc, /opt, and the +// service account's files. func Uninstall(opts UninstallOptions) error { if os.Geteuid() != 0 { return fmt.Errorf("--uninstall must run as root (try: sudo %s --uninstall)", os.Args[0]) @@ -309,7 +318,7 @@ func Uninstall(opts UninstallOptions) error { if err != nil { log("could not resolve the service account; skipping its state directories", "error", err.Error()) } else { - removeConfiguredStatePaths(t.home, opts.ConfigPath, log) + applyStatePaths(t.home, opts.ConfigPath, opts.Purge, log) } if _, err := os.Stat(unitPath); err == nil { @@ -331,11 +340,16 @@ func Uninstall(opts UninstallOptions) error { // The dedicated fallback user, if Run ever created one: its entire home // exists solely for this service, so the account and home go together. - // This is a superset of removeConfiguredStatePaths above when state paths - // live under that home (the common case), and also mops up anything else - // under it. + // This is a superset of applyStatePaths above when state paths live under + // that home (the common case), and also mops up anything else under it. + // Only removed when purging: userdel without -r would orphan the home to + // a dangling uid, and a later --install would recreate the account with a + // possibly different uid, leaving any retained data unreadable. if _, err := user.Lookup(dedicatedUser); err == nil { - if _, lookErr := exec.LookPath("userdel"); lookErr != nil { + if !opts.Purge { + log("leaving service account and home in place, re-run with --uninstall --purge to remove them", + "user", dedicatedUser, "home", dedicatedHome) + } else if _, lookErr := exec.LookPath("userdel"); lookErr != nil { log("userdel not available; remove the service user and its home manually", "user", dedicatedUser, "home", dedicatedHome) } else { @@ -426,15 +440,51 @@ func expandHome(p, home string) string { return p } -// removeConfiguredStatePaths removes exactly the directories/file the -// service's own config told it to use, resolved against home. -func removeConfiguredStatePaths(home, fallbackConfigPath string, log func(string, ...any)) { +// resolvedStatePaths returns the directories and files the service was +// configured to write, resolved against home. +func resolvedStatePaths(home, fallbackConfigPath string, log func(string, ...any)) (dirs, files []string) { paths := loadStatePaths(fallbackConfigPath, log) - removeDir(expandHome(paths.Workspace.Root, home), log) - removeDir(expandHome(paths.Workspace.ReposRoot, home), log) - removeDir(expandHome(paths.Workspace.LogsRoot, home), log) - removeFile(expandHome(paths.Store.Path, home), log) - removeFile(expandHome(paths.Claude.UsageCachePath, home), log) + dirs = []string{ + expandHome(paths.Workspace.Root, home), + expandHome(paths.Workspace.ReposRoot, home), + expandHome(paths.Workspace.LogsRoot, home), + } + files = []string{ + expandHome(paths.Store.Path, home), + expandHome(paths.Claude.UsageCachePath, home), + } + return dirs, files +} + +// applyStatePaths removes the configured state directories/files when purge +// is set. Otherwise it leaves them in place and logs exactly what was +// retained and how to remove it later. +func applyStatePaths(home, fallbackConfigPath string, purge bool, log func(string, ...any)) { + dirs, files := resolvedStatePaths(home, fallbackConfigPath, log) + if !purge { + for _, d := range dirs { + logRetainedPath(d, log) + } + for _, f := range files { + logRetainedPath(f, log) + } + return + } + for _, d := range dirs { + removeDir(d, log) + } + for _, f := range files { + removeFile(f, log) + } +} + +// logRetainedPath logs a state path left in place by a non-purge uninstall, +// if it actually exists. +func logRetainedPath(path string, log func(string, ...any)) { + if _, err := os.Stat(path); err != nil { + return + } + log("data retained, re-run with --uninstall --purge to delete this data", "path", path) } func removeDir(dir string, log func(string, ...any)) { diff --git a/internal/install/install_test.go b/internal/install/install_test.go index b65dcf7..78a8933 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -293,6 +293,97 @@ func TestLoadStatePathsFallsBackToDefaultsWhenNeitherConfigExists(t *testing.T) } } +func TestApplyStatePathsRemovesWhenPurging(t *testing.T) { + home := t.TempDir() + work := filepath.Join(home, ".agent-loop", "work") + logs := filepath.Join(home, ".agent-loop", "logs") + state := filepath.Join(home, ".agent-loop", "state.db") + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(logs, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(state, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + installed := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(installed, []byte(`{"workspace":{"root":"`+work+`","logs_root":"`+logs+`"},"store":{"path":"`+state+`"}}`), 0o644); err != nil { + t.Fatal(err) + } + restore := setInstalledConfigPathForTest(installed) + defer restore() + + applyStatePaths(home, "", true, func(string, ...any) {}) + + for _, p := range []string{work, logs, state} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed after purge, stat err = %v", p, err) + } + } +} + +func TestApplyStatePathsKeepsDataWhenNotPurging(t *testing.T) { + home := t.TempDir() + work := filepath.Join(home, ".agent-loop", "work") + logs := filepath.Join(home, ".agent-loop", "logs") + state := filepath.Join(home, ".agent-loop", "state.db") + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(logs, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(state, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + installed := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(installed, []byte(`{"workspace":{"root":"`+work+`","logs_root":"`+logs+`"},"store":{"path":"`+state+`"}}`), 0o644); err != nil { + t.Fatal(err) + } + restore := setInstalledConfigPathForTest(installed) + defer restore() + + logged := false + applyStatePaths(home, "", false, func(string, ...any) { logged = true }) + + for _, p := range []string{work, logs, state} { + if _, err := os.Stat(p); err != nil { + t.Errorf("expected %s to survive a non-purge uninstall, stat err = %v", p, err) + } + } + if !logged { + t.Error("expected applyStatePaths to log the retained paths") + } +} + +func TestApplyStatePathsHonoursConfiguredPaths(t *testing.T) { + dataRoot := t.TempDir() + customWork := filepath.Join(dataRoot, "custom-work") + customState := filepath.Join(dataRoot, "custom-state.db") + if err := os.MkdirAll(customWork, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(customState, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + installed := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(installed, []byte(`{"workspace":{"root":"`+customWork+`"},"store":{"path":"`+customState+`"}}`), 0o644); err != nil { + t.Fatal(err) + } + restore := setInstalledConfigPathForTest(installed) + defer restore() + + applyStatePaths(t.TempDir(), "", true, func(string, ...any) {}) + + if _, err := os.Stat(customWork); !os.IsNotExist(err) { + t.Errorf("expected configured workspace root %s to be removed, stat err = %v", customWork, err) + } + if _, err := os.Stat(customState); !os.IsNotExist(err) { + t.Errorf("expected configured store path %s to be removed, stat err = %v", customState, err) + } +} + // setInstalledConfigPathForTest overrides the package-level installedConfigPath // for the duration of a test and returns a func to restore it. func setInstalledConfigPathForTest(path string) func() {