Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ jobs:
done
echo "Release $TAG not found after 5 minutes — proceeding, but the next upload step will fail if the draft release is missing."

- name: Copy LICENSE into CLI module
run: cp LICENSE cli/LICENSE

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
Expand Down
2 changes: 2 additions & 0 deletions cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copied by CI for GoReleaser archive inclusion (lives at repo root)
LICENSE
2 changes: 1 addition & 1 deletion cli/.goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ archives:
- zip
name_template: "synthorg_{{ .Os }}_{{ .Arch }}"
files:
- src: ../LICENSE
- src: LICENSE
dst: .
info:
mtime: "{{ .CommitDate }}"
Expand Down
12 changes: 12 additions & 0 deletions cli/cmd/procattr_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !windows

package cmd

import "syscall"

// windowsDetachedProcAttr is a no-op stub for non-Windows platforms.
// The caller (scheduleWindowsCleanup) is only reachable on Windows,
// but the function must exist for compilation.
func windowsDetachedProcAttr() *syscall.SysProcAttr {
return nil
}
11 changes: 11 additions & 0 deletions cli/cmd/procattr_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cmd

import "syscall"

// windowsDetachedProcAttr returns SysProcAttr that detaches the child
// process so it survives after the parent exits.
func windowsDetachedProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
}
212 changes: 182 additions & 30 deletions cli/cmd/uninstall.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package cmd

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/Aureliolo/synthorg/cli/internal/completion"
"github.com/Aureliolo/synthorg/cli/internal/config"
"github.com/Aureliolo/synthorg/cli/internal/docker"
"github.com/Aureliolo/synthorg/cli/internal/verify"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -53,6 +56,10 @@ func runUninstall(cmd *cobra.Command, _ []string) error {
if err := stopAndRemoveVolumes(cmd, info, safeDir); err != nil {
return err
}
// Offer to remove SynthOrg container images.
if err := confirmAndRemoveImages(cmd, info); err != nil {
return err
}
}

// Remove data directory.
Expand All @@ -72,7 +79,7 @@ func runUninstall(cmd *cobra.Command, _ []string) error {
}

// Optionally remove CLI binary.
if err := confirmAndRemoveBinary(cmd); err != nil {
if err := confirmAndRemoveBinary(cmd, safeDir); err != nil {
return err
}

Expand Down Expand Up @@ -113,6 +120,77 @@ func stopAndRemoveVolumes(cmd *cobra.Command, info docker.Info, dataDir string)
return nil
}

// confirmAndRemoveImages offers to remove SynthOrg container images from
// the local Docker cache. Lists matching images first so the user sees
// what will be removed.
func confirmAndRemoveImages(cmd *cobra.Command, info docker.Info) error {
ctx := cmd.Context()
out := cmd.OutOrStdout()

// List SynthOrg images using the Docker CLI directly (not docker-compose,
// which doesn't support --filter/--format for image listing).
imageRef := verify.RegistryHost + "/" + verify.ImageRepoPrefix
listOut, err := docker.RunCmd(ctx, info.DockerPath, "images",
"--filter", "reference="+imageRef+"*",
"--format", "{{.Repository}}:{{.Tag}} ({{.Size}})",
)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not list images: %v\n", err)
return nil
}

images := strings.TrimSpace(listOut)
if images == "" {
_, _ = fmt.Fprintln(out, "No SynthOrg images found locally.")
return nil
}

_, _ = fmt.Fprintln(out, "SynthOrg images found locally:")
for _, line := range strings.Split(images, "\n") {
_, _ = fmt.Fprintf(out, " %s\n", line)
}

var removeImages bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Remove these container images?").
Value(&removeImages),
),
)
if err := form.Run(); err != nil {
return err
}
if !removeImages {
return nil
}

// Get image IDs for removal (more reliable than names for rmi).
idsOut, err := docker.RunCmd(ctx, info.DockerPath, "images",
"--filter", "reference="+imageRef+"*",
"--format", "{{.ID}}",
)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not get image IDs: %v\n", err)
return nil
}

ids := strings.Fields(strings.TrimSpace(idsOut))
if len(ids) == 0 {
return nil
}

_, _ = fmt.Fprintln(out, "Removing SynthOrg images...")
rmiArgs := append([]string{"rmi", "--force"}, ids...)
if _, rmiErr := docker.RunCmd(ctx, info.DockerPath, rmiArgs...); rmiErr != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: some images could not be removed: %v\n", rmiErr)
} else {
_, _ = fmt.Fprintf(out, "Removed %d image(s).\n", len(ids))
}

return nil
}

func confirmAndRemoveData(cmd *cobra.Command, dataDir string) error {
var removeData bool
form := huh.NewForm(
Expand All @@ -138,7 +216,7 @@ func confirmAndRemoveData(cmd *cobra.Command, dataDir string) error {
// rejectUnsafeDir refuses to remove root, home, relative, UNC share roots, or drive roots.
func rejectUnsafeDir(dir string) error {
if dir == "" || dir == "." || !filepath.IsAbs(dir) {
return fmt.Errorf("refusing to remove %q must be an absolute path", dir)
return fmt.Errorf("refusing to remove %q -- must be an absolute path", dir)
}
home, homeErr := os.UserHomeDir()
isHomeDir := false
Expand All @@ -158,7 +236,7 @@ func rejectUnsafeDir(dir string) error {
(dir == vol || dir == vol+`\` || dir == vol+"/")
isDriveRoot := len(dir) == 3 && dir[1] == ':' && (dir[2] == '\\' || dir[2] == '/')
if dir == "/" || isHomeDir || isDriveRoot || isUNCRoot {
return fmt.Errorf("refusing to remove %q does not look like an app data directory", dir)
return fmt.Errorf("refusing to remove %q -- does not look like an app data directory", dir)
}
return nil
}
Expand All @@ -179,7 +257,7 @@ func removeDataDir(cmd *cobra.Command, dir string) error {
if err := removeAllExcept(dir, execPath); err != nil {
return fmt.Errorf("removing config directory: %w", err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed contents of %s (binary skipped still running)\n", dir)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed contents of %s (binary skipped -- still running)\n", dir)
} else {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("removing config directory: %w", err)
Expand All @@ -189,7 +267,10 @@ func removeDataDir(cmd *cobra.Command, dir string) error {
return nil
}

func confirmAndRemoveBinary(cmd *cobra.Command) error {
// confirmAndRemoveBinary asks to remove the CLI binary. On Windows, spawns
// a detached process that waits for the current process to exit, then
// deletes the binary and cleans up empty parent directories.
func confirmAndRemoveBinary(cmd *cobra.Command, dataDir string) error {
var removeBinary bool
form := huh.NewForm(
huh.NewGroup(
Expand All @@ -203,34 +284,105 @@ func confirmAndRemoveBinary(cmd *cobra.Command) error {
return err
}

if removeBinary {
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("finding executable: %w", err)
}
// Resolve symlinks so we remove the actual binary.
if resolved, err := filepath.EvalSymlinks(execPath); err == nil {
execPath = resolved
}
if runtime.GOOS == "windows" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Cannot delete running binary on Windows.")
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "To finish cleanup after exit, run:")
// Use PowerShell Remove-Item -LiteralPath which does not interpret
// wildcards or cmd.exe metacharacters (%, ^, &, |, <, >).
escaped := strings.ReplaceAll(execPath, "'", "''")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " powershell -Command \"Remove-Item -LiteralPath '%s'\"\n", escaped)
} else {
if err := os.Remove(execPath); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove binary: %v\n", err)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Manually remove: %s\n", execPath)
} else {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "CLI binary removed.")
}
}
if !removeBinary {
return nil
}

execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("finding executable: %w", err)
}
// Resolve symlinks so we remove the actual binary.
if resolved, err := filepath.EvalSymlinks(execPath); err == nil {
execPath = resolved
}

if runtime.GOOS != "windows" {
return removeUnixBinary(cmd, execPath)
}
return scheduleWindowsCleanup(cmd, execPath, dataDir)
}

func removeUnixBinary(cmd *cobra.Command, execPath string) error {
if err := os.Remove(execPath); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove binary: %v\n", err)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Manually remove: %s\n", execPath)
} else {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "CLI binary removed.")
}
return nil
}

// scheduleWindowsCleanup writes a temporary .bat file that waits for the
// current process to exit, then deletes the binary, empty parent dirs,
// and the .bat file itself. Uses a temp .bat instead of inline cmd /c
// because goto/labels don't work in single-line cmd /c commands.
func scheduleWindowsCleanup(cmd *cobra.Command, execPath, dataDir string) error {
out := cmd.OutOrStdout()
pid := os.Getpid()
binDir := filepath.Dir(execPath)

// Write cleanup script to a temp .bat file next to the binary
// (same filesystem, survives after this process exits).
batContent := fmt.Sprintf(
"@echo off\r\n"+
"for /L %%%%i in (1,1,30) do (\r\n"+
" tasklist /fi \"PID eq %d\" 2>nul | find \"%d\" >nul || goto :cleanup\r\n"+
" timeout /t 1 /nobreak >nul\r\n"+
")\r\n"+
"goto :done\r\n"+
":cleanup\r\n"+
"del /f /q \"%s\"\r\n"+
"rmdir \"%s\" 2>nul\r\n"+
"rmdir \"%s\" 2>nul\r\n"+
":done\r\n"+
"del /f /q \"%%~f0\"\r\n",
pid, pid,
execPath,
binDir,
dataDir,
)

batFile, err := os.CreateTemp(binDir, "synthorg-cleanup-*.bat")
if err != nil {
return fallbackManualCleanup(cmd, execPath, err)
}
batPath := batFile.Name()
if _, err := batFile.WriteString(batContent); err != nil {
_ = batFile.Close()
_ = os.Remove(batPath)
return fallbackManualCleanup(cmd, execPath, err)
}
if err := batFile.Close(); err != nil {
_ = os.Remove(batPath)
return fallbackManualCleanup(cmd, execPath, err)
}

// Spawn detached -- use context.Background so parent context
// cancellation doesn't kill the cleanup process.
c := exec.CommandContext(context.Background(), "cmd.exe", "/c", batPath) //nolint:noctx // intentionally detached
c.SysProcAttr = windowsDetachedProcAttr()
if err := c.Start(); err != nil {
_ = os.Remove(batPath)
return fallbackManualCleanup(cmd, execPath, err)
}

// Detach -- don't wait for the cleanup process.
_ = c.Process.Release()

_, _ = fmt.Fprintln(out, "CLI binary will be removed automatically after exit.")
return nil
}

func fallbackManualCleanup(cmd *cobra.Command, execPath string, cause error) error {
out := cmd.OutOrStdout()
_, _ = fmt.Fprintf(out, "Could not schedule automatic cleanup: %v\n", cause)
_, _ = fmt.Fprintln(out, "To finish cleanup after exit, run:")
escaped := strings.ReplaceAll(execPath, "'", "''")
_, _ = fmt.Fprintf(out, " powershell -Command \"Remove-Item -LiteralPath '%s'\"\n", escaped)
return nil
}

// isInsideDir reports whether child is inside (or equal to) parent.
// On Windows, the comparison is case-insensitive (NTFS is case-insensitive).
// Note: strings.ToLower is correct for ASCII paths; non-ASCII Unicode paths
Expand Down Expand Up @@ -276,7 +428,7 @@ func removeAllExcept(root, except string) error {
return err
}
cleanPath := filepath.Clean(path)
// Skip root itself we only remove contents, not the root directory.
// Skip root itself -- we only remove contents, not the root directory.
if cleanPath == root {
return nil
}
Expand Down
Loading