diff --git a/install-linux.sh b/install-linux.sh deleted file mode 100755 index 470ce2b..0000000 --- a/install-linux.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SERVICE_NAME="luxtools-client" -INSTALL_DIR="${HOME}/.local/share/${SERVICE_NAME}" -BIN_PATH="${INSTALL_DIR}/${SERVICE_NAME}" -CONFIG_DIR="${HOME}/.config/${SERVICE_NAME}" -ENV_FILE="${CONFIG_DIR}/${SERVICE_NAME}.env" -UNIT_DIR="${HOME}/.config/systemd/user" -UNIT_FILE="${UNIT_DIR}/${SERVICE_NAME}.service" - -DEFAULT_LISTEN="127.0.0.1:8765" - -usage() { - cat <]... - -Installs/updates ${SERVICE_NAME} as a systemd *user* service (runs under your current user). -- Re-running updates the installed binary and restarts the service. -- Config is stored in ${ENV_FILE} (created on first install). - -Options: - --listen host:port Listen address (default: ${DEFAULT_LISTEN}) - --allow PATH Allowed path prefix (repeatable). If none, any path is allowed. -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SRC_BIN="${SCRIPT_DIR}/${SERVICE_NAME}" -if [[ ! -f "$SRC_BIN" ]]; then - echo "Missing binary next to script: ${SRC_BIN}" >&2 - echo "Build it first (e.g. 'go build -o ${SERVICE_NAME} .') and re-run." >&2 - exit 1 -fi - -LISTEN="${DEFAULT_LISTEN}" -ALLOW_ARGS="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --listen) - LISTEN="${2:-}" - shift 2 - ;; - --allow) - p="${2:-}" - if [[ -n "$p" ]]; then - # Note: this is inserted into a shell command in the systemd unit; we escape quotes. - p_escaped=${p//\"/\\\"} - ALLOW_ARGS+=" -allow \"${p_escaped}\"" - fi - shift 2 - ;; - *) - echo "Unknown arg: $1" >&2 - usage - exit 2 - ;; - esac -done - -mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$UNIT_DIR" - -# Copy a fresh binary into a temp location, then atomically replace. -TMP_BIN="$(mktemp -p /tmp ${SERVICE_NAME}.XXXXXX)" -trap 'rm -f "$TMP_BIN"' EXIT - -cp "$SRC_BIN" "$TMP_BIN" -chmod 0755 "$TMP_BIN" || true - -if [[ -f "$ENV_FILE" ]]; then - # Preserve existing config. - # shellcheck disable=SC1090 - source "$ENV_FILE" || true -fi - -cat >"$ENV_FILE" <"$UNIT_FILE" <<'EOF' -[Unit] -Description=luxtools-client (local folder opener helper) - -[Service] -Type=simple -EnvironmentFile=%h/.config/luxtools-client/luxtools-client.env -ExecStart=/bin/sh -lc '%h/.local/share/luxtools-client/luxtools-client -listen "$LISTEN" $ALLOW_ARGS' -Restart=on-failure -RestartSec=1 - -NoNewPrivileges=true -PrivateTmp=true - -[Install] -WantedBy=default.target -EOF - -systemctl --user daemon-reload -systemctl --user enable "$SERVICE_NAME" >/dev/null -systemctl --user restart "$SERVICE_NAME" - -echo -echo "Installed/updated ${SERVICE_NAME}." -echo "- Binary: ${BIN_PATH}" -echo "- Unit: ${UNIT_FILE}" -echo "- Config: ${ENV_FILE}" - -echo -echo "View logs with: journalctl --user -u ${SERVICE_NAME} -f" -echo "If you want it to start at boot without logging in, enable lingering:" -echo " loginctl enable-linger $(whoami)" diff --git a/install-windows-task.bat b/install-windows-task.bat deleted file mode 100644 index 1ba1ff4..0000000 --- a/install-windows-task.bat +++ /dev/null @@ -1,7 +0,0 @@ -@echo off -setlocal - -set "SCRIPT_DIR=%~dp0" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%install-windows-task.ps1" %* -set EXITCODE=%ERRORLEVEL% -exit /b %EXITCODE% diff --git a/install-windows-task.ps1 b/install-windows-task.ps1 deleted file mode 100644 index f078639..0000000 --- a/install-windows-task.ps1 +++ /dev/null @@ -1,157 +0,0 @@ -[CmdletBinding()] -param( - [string]$Listen = "127.0.0.1:8765", - [string[]]$Allow = @() -) - -$ErrorActionPreference = 'Stop' - -$ServiceName = "luxtools-client" -$TaskName = $ServiceName - -function Write-Usage { - @" -Usage: - install-windows-task.bat [--listen host:port] [--allow ]... - -Installs/updates $ServiceName as a Windows Scheduled Task (per-user, runs at logon). -- Re-running updates the installed binary and restarts the task. -- Config is stored in: %LOCALAPPDATA%\$ServiceName\config.json - -Options: - --listen host:port Listen address (default: 127.0.0.1:8765) - --allow PATH Allowed path prefix (repeatable). If none, any path is allowed. -"@ | Write-Host -} - -function Quote-Arg([string]$s) { - if ($null -eq $s) { return '""' } - # Simple CreateProcess-compatible quoting: wrap in quotes if whitespace or quotes exist. - if ($s -match '[\s\"]') { - $escaped = $s -replace '"', '\\"' - return '"' + $escaped + '"' - } - return $s -} - -function Stop-ExistingInstance([string]$exePath) { - try { - if (Get-Command Stop-ScheduledTask -ErrorAction SilentlyContinue) { - Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null - } else { - schtasks.exe /End /TN $TaskName 2>$null | Out-Null - } - } catch { - # best-effort - } - - try { - $procs = Get-Process -Name $ServiceName -ErrorAction SilentlyContinue - foreach ($p in $procs) { - try { - if ($p.Path -and (Split-Path -Path $p.Path -Resolve) -ieq (Split-Path -Path $exePath -Resolve)) { - Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue - } - } catch { - # best-effort - } - } - } catch { - # best-effort - } -} - -# Parse args in a bash-like style to match the README expectation. -$rawArgs = @($args) -for ($i = 0; $i -lt $rawArgs.Count; $i++) { - switch ($rawArgs[$i]) { - '-h' { Write-Usage; exit 0 } - '--help' { Write-Usage; exit 0 } - '--listen' { - if ($i + 1 -ge $rawArgs.Count) { throw "--listen requires a value" } - $Listen = $rawArgs[$i + 1] - $i++ - continue - } - '--allow' { - if ($i + 1 -ge $rawArgs.Count) { throw "--allow requires a value" } - $p = $rawArgs[$i + 1] - if ($p -and $p.Trim().Length -gt 0) { $Allow += $p } - $i++ - continue - } - default { - throw "Unknown arg: $($rawArgs[$i])" - } - } -} - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$srcExe = Join-Path $scriptDir "$ServiceName.exe" -if (-not (Test-Path -LiteralPath $srcExe)) { - Write-Error "Missing binary next to script: $srcExe`nBuild it first (e.g. 'go build -o $ServiceName.exe .') and re-run." -} - -$installDir = Join-Path $env:LOCALAPPDATA $ServiceName -$exePath = Join-Path $installDir "$ServiceName.exe" -$configPath = Join-Path $installDir "config.json" - -New-Item -ItemType Directory -Force -Path $installDir | Out-Null - -# Persist config. -$config = [ordered]@{ - Listen = $Listen - Allow = @($Allow) -} -($config | ConvertTo-Json -Depth 4) | Set-Content -LiteralPath $configPath -Encoding UTF8 - -# Update behavior: stop existing instance, then replace binary. -Stop-ExistingInstance -exePath $exePath - -$tmpExe = Join-Path $installDir ("$ServiceName.tmp.{0}.exe" -f ([Guid]::NewGuid().ToString('N'))) -Copy-Item -LiteralPath $srcExe -Destination $tmpExe -Force - -try { - Move-Item -LiteralPath $tmpExe -Destination $exePath -Force -} catch { - # If replace failed, try removing and retry once. - Remove-Item -LiteralPath $exePath -Force -ErrorAction SilentlyContinue - Move-Item -LiteralPath $tmpExe -Destination $exePath -Force -} - -# Build the argument string for the scheduled task. -$argList = @('-listen', $Listen) -foreach ($p in $Allow) { - if ($p -and $p.Trim().Length -gt 0) { - $argList += @('-allow', $p) - } -} - -$argString = ($argList | ForEach-Object { Quote-Arg $_ }) -join ' ' - -# Register/update the scheduled task (per-user, interactive at logon). -$principalUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name - -$action = New-ScheduledTaskAction -Execute $exePath -Argument $argString -$trigger = New-ScheduledTaskTrigger -AtLogOn -User $principalUser -$principal = New-ScheduledTaskPrincipal -UserId $principalUser -LogonType Interactive -RunLevel Limited -$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries - -$task = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null - -# Start now (best-effort). -try { - Start-ScheduledTask -TaskName $TaskName | Out-Null -} catch { - # best-effort -} - -Write-Host "" -Write-Host "Installed/updated $ServiceName (Scheduled Task)." -Write-Host "- Binary: $exePath" -Write-Host "- Task: $TaskName" -Write-Host "- Config: $configPath" -Write-Host "" -Write-Host "To view task status: schtasks /Query /TN $TaskName /V" -Write-Host "To run it manually: schtasks /Run /TN $TaskName" diff --git a/internal/installer/helpers.go b/internal/installer/helpers.go new file mode 100644 index 0000000..ea71c4b --- /dev/null +++ b/internal/installer/helpers.go @@ -0,0 +1,40 @@ +package installer + +import ( + "io" + "os" + "path/filepath" +) + +func copySelfAtomic(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + _ = os.Remove(tmp) + return copyErr + } + if closeErr != nil { + _ = os.Remove(tmp) + return closeErr + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go new file mode 100644 index 0000000..83def77 --- /dev/null +++ b/internal/installer/installer.go @@ -0,0 +1,25 @@ +package installer + +import "runtime" + +const ServiceName = "luxtools-client" + +type InstallOptions struct { + Listen string + Allow []string + DryRun bool +} + +type UninstallOptions struct { + KeepConfig bool + DryRun bool +} + +func Supported() bool { + switch runtime.GOOS { + case "linux", "windows": + return true + default: + return false + } +} diff --git a/internal/installer/installer_linux.go b/internal/installer/installer_linux.go new file mode 100644 index 0000000..481dbab --- /dev/null +++ b/internal/installer/installer_linux.go @@ -0,0 +1,148 @@ +//go:build linux + +package installer + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func Install(opts InstallOptions) error { + if opts.Listen == "" { + opts.Listen = "127.0.0.1:8765" + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + installDir := filepath.Join(home, ".local", "share", ServiceName) + binPath := filepath.Join(installDir, ServiceName) + configDir := filepath.Join(home, ".config", ServiceName) + envFile := filepath.Join(configDir, ServiceName+".env") + unitDir := filepath.Join(home, ".config", "systemd", "user") + unitFile := filepath.Join(unitDir, ServiceName+".service") + + allowArgs := buildLinuxAllowArgs(opts.Allow) + + if opts.DryRun { + return nil + } + + if err := os.MkdirAll(installDir, 0o755); err != nil { + return err + } + if err := os.MkdirAll(configDir, 0o755); err != nil { + return err + } + if err := os.MkdirAll(unitDir, 0o755); err != nil { + return err + } + + exePath, err := os.Executable() + if err != nil { + return err + } + if err := copySelfAtomic(exePath, binPath, 0o755); err != nil { + return err + } + + env := fmt.Sprintf("# %s environment\nLISTEN=\"%s\"\nALLOW_ARGS=\"%s\"\n", ServiceName, escapeDoubleQuotes(opts.Listen), escapeDoubleQuotes(allowArgs)) + if err := os.WriteFile(envFile, []byte(env), 0o600); err != nil { + return err + } + + unit := `[Unit] +Description=luxtools-client (local folder opener helper) + +[Service] +Type=simple +EnvironmentFile=%h/.config/luxtools-client/luxtools-client.env +ExecStart=/bin/sh -lc '%h/.local/share/luxtools-client/luxtools-client -listen "$LISTEN" $ALLOW_ARGS' +Restart=on-failure +RestartSec=1 + +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=default.target +` + if err := os.WriteFile(unitFile, []byte(unit), 0o644); err != nil { + return err + } + + if err := runCmd("systemctl", "--user", "daemon-reload"); err != nil { + return fmt.Errorf("systemctl --user daemon-reload failed: %w", err) + } + _ = runCmd("systemctl", "--user", "enable", ServiceName) + if err := runCmd("systemctl", "--user", "restart", ServiceName); err != nil { + return fmt.Errorf("systemctl --user restart failed: %w", err) + } + return nil +} + +func Uninstall(opts UninstallOptions) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + installDir := filepath.Join(home, ".local", "share", ServiceName) + configDir := filepath.Join(home, ".config", ServiceName) + unitFile := filepath.Join(home, ".config", "systemd", "user", ServiceName+".service") + + if opts.DryRun { + return nil + } + + _ = runCmd("systemctl", "--user", "stop", ServiceName) + _ = runCmd("systemctl", "--user", "disable", ServiceName) + _ = runCmd("systemctl", "--user", "reset-failed", ServiceName) + _ = os.Remove(unitFile) + _ = runCmd("systemctl", "--user", "daemon-reload") + _ = os.RemoveAll(installDir) + if !opts.KeepConfig { + _ = os.RemoveAll(configDir) + } + return nil +} + +func buildLinuxAllowArgs(allowed []string) string { + var b strings.Builder + for _, p := range allowed { + p = strings.TrimSpace(p) + if p == "" { + continue + } + pEsc := strings.ReplaceAll(p, "\"", "\\\"") + b.WriteString(" -allow \"") + b.WriteString(pEsc) + b.WriteString("\"") + } + return b.String() +} + +func escapeDoubleQuotes(s string) string { + return strings.ReplaceAll(s, "\"", "\\\"") +} + +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Stdout = io.Discard + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return errors.New(msg) + } + return err + } + return nil +} diff --git a/internal/installer/installer_windows.go b/internal/installer/installer_windows.go new file mode 100644 index 0000000..de761c6 --- /dev/null +++ b/internal/installer/installer_windows.go @@ -0,0 +1,193 @@ +//go:build windows + +package installer + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type windowsConfig struct { + Listen string `json:"Listen"` + Allow []string `json:"Allow"` +} + +func Install(opts InstallOptions) error { + if opts.Listen == "" { + opts.Listen = "127.0.0.1:8765" + } + + installDir, err := windowsInstallDir() + if err != nil { + return err + } + exePath := filepath.Join(installDir, ServiceName+".exe") + configPath := filepath.Join(installDir, "config.json") + + cfg := windowsConfig{Listen: opts.Listen, Allow: opts.Allow} + cfgBytes, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + if opts.DryRun { + return nil + } + + if err := os.MkdirAll(installDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(configPath, cfgBytes, 0o644); err != nil { + return err + } + + self, err := os.Executable() + if err != nil { + return err + } + if err := copySelfAtomic(self, exePath, 0o755); err != nil { + return err + } + + if err := registerWindowsScheduledTask(exePath, opts.Listen, opts.Allow); err != nil { + return err + } + return nil +} + +func Uninstall(opts UninstallOptions) error { + installDir, err := windowsInstallDir() + if err != nil { + return err + } + exePath := filepath.Join(installDir, ServiceName+".exe") + configPath := filepath.Join(installDir, "config.json") + + if opts.DryRun { + return nil + } + + _ = unregisterWindowsScheduledTask() + + if opts.KeepConfig { + _ = os.Remove(exePath) + // keep directory + config.json + return nil + } + + _ = os.Remove(configPath) + _ = os.Remove(exePath) + _ = os.RemoveAll(installDir) + return nil +} + +func windowsInstallDir() (string, error) { + if v := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); v != "" { + return filepath.Join(v, ServiceName), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + // Fallback for older environments. + return filepath.Join(home, "AppData", "Local", ServiceName), nil +} + +func registerWindowsScheduledTask(exePath, listen string, allow []string) error { + // Use PowerShell scheduled task cmdlets to ensure per-user + interactive logon. + allowJSON, err := json.Marshal(allow) + if err != nil { + return err + } + + ps := ` +param( + [string]$ExePath, + [string]$Listen, + [string]$AllowJson +) +$ErrorActionPreference = 'Stop' +$ServiceName = 'luxtools-client' +$TaskName = $ServiceName + +function Quote-Arg([string]$s) { + if ($null -eq $s) { return '""' } + if ($s -match '[\s\"]') { + $escaped = $s -replace '"', '\\"' + return '"' + $escaped + '"' + } + return $s +} + +# Stop existing +try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null } catch {} +try { schtasks.exe /End /TN $TaskName 2>$null | Out-Null } catch {} + +# Build args +$Allow = @() +if ($AllowJson -and $AllowJson.Trim().Length -gt 0) { + $Allow = (ConvertFrom-Json -InputObject $AllowJson) +} +$argList = @('-listen', $Listen) +foreach ($p in $Allow) { + if ($p -and $p.ToString().Trim().Length -gt 0) { + $argList += @('-allow', $p) + } +} +$argString = ($argList | ForEach-Object { Quote-Arg $_ }) -join ' ' + +$principalUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$action = New-ScheduledTaskAction -Execute $ExePath -Argument $argString +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $principalUser +$principal = New-ScheduledTaskPrincipal -UserId $principalUser -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +$task = New-ScheduledTask -Action $action -Trigger $trigger -Principal $principal -Settings $settings +Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null +try { Start-ScheduledTask -TaskName $TaskName | Out-Null } catch {} +` + + cmd := exec.Command("powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-Command", ps, + exePath, + listen, + string(allowJSON), + ) + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + return err + } + return errors.New(msg) + } + return nil +} + +func unregisterWindowsScheduledTask() error { + ps := ` +$ErrorActionPreference = 'SilentlyContinue' +$TaskName = 'luxtools-client' +try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null } catch {} +try { schtasks.exe /End /TN $TaskName 2>$null | Out-Null } catch {} +try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } catch {} +try { schtasks.exe /Delete /TN $TaskName /F 2>$null | Out-Null } catch {} +` + cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps) + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg != "" { + return fmt.Errorf("%s", msg) + } + return err + } + return nil +} diff --git a/main.go b/main.go index 94979e0..5b726f3 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "io" "log" "net" "net/http" @@ -14,6 +15,7 @@ import ( "strings" "time" + "luxtools-client/internal/installer" "luxtools-client/internal/openfolder" ) @@ -42,6 +44,21 @@ func main() { infoLog := log.New(os.Stdout, "", log.LstdFlags) errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags) + if len(os.Args) > 1 { + switch os.Args[1] { + case "install": + if err := runInstall(os.Args[2:], infoLog, errLog); err != nil { + errLog.Fatal(err) + } + return + case "uninstall": + if err := runUninstall(os.Args[2:], infoLog, errLog); err != nil { + errLog.Fatal(err) + } + return + } + } + listen := flag.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback") var allowed allowList flag.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed") @@ -131,6 +148,60 @@ func main() { errLog.Fatal(srv.ListenAndServe()) } +func runInstall(args []string, infoLog, errLog *log.Logger) error { + fs := flag.NewFlagSet("install", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + listen := fs.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback") + dryRun := fs.Bool("dry-run", false, "print/validate only; do not write files or register services") + var allowed allowList + fs.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("install: %w", err) + } + + if !installer.Supported() { + return fmt.Errorf("install: unsupported OS: %s", runtime.GOOS) + } + if !isLoopbackListenAddr(*listen) { + return fmt.Errorf("install: refusing non-loopback listen address: %s", *listen) + } + + if err := installer.Install(installer.InstallOptions{Listen: *listen, Allow: []string(allowed), DryRun: *dryRun}); err != nil { + return err + } + if *dryRun { + infoLog.Printf("install dry-run OK") + return nil + } + infoLog.Printf("installed %s", installer.ServiceName) + return nil +} + +func runUninstall(args []string, infoLog, errLog *log.Logger) error { + fs := flag.NewFlagSet("uninstall", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + keepConfig := fs.Bool("keep-config", false, "keep config on disk") + dryRun := fs.Bool("dry-run", false, "print/validate only; do not remove files or unregister services") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("uninstall: %w", err) + } + + if !installer.Supported() { + return fmt.Errorf("uninstall: unsupported OS: %s", runtime.GOOS) + } + if err := installer.Uninstall(installer.UninstallOptions{KeepConfig: *keepConfig, DryRun: *dryRun}); err != nil { + return err + } + if *dryRun { + infoLog.Printf("uninstall dry-run OK") + return nil + } + infoLog.Printf("uninstalled %s", installer.ServiceName) + return nil +} + func isLoopbackListenAddr(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { diff --git a/uninstall-linux.sh b/uninstall-linux.sh deleted file mode 100755 index f547950..0000000 --- a/uninstall-linux.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SERVICE_NAME="luxtools-client" -INSTALL_DIR="${HOME}/.local/share/${SERVICE_NAME}" -CONFIG_DIR="${HOME}/.config/${SERVICE_NAME}" -UNIT_FILE="${HOME}/.config/systemd/user/${SERVICE_NAME}.service" - -usage() { - cat </dev/null 2>&1 || true -systemctl --user disable "$SERVICE_NAME" >/dev/null 2>&1 || true -systemctl --user reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true - -# Also stop/disable any leftover system-wide service from older installs. -if systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE_NAME}\.service"; then - if [[ ${EUID} -eq 0 ]]; then - systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true - systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true - systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true - elif command -v sudo >/dev/null 2>&1; then - sudo systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true - sudo systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true - sudo systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true - else - echo "Note: a system service '${SERVICE_NAME}.service' exists; run with sudo to stop it." >&2 - fi -fi - -rm -f "$UNIT_FILE" -systemctl --user daemon-reload || true - -rm -rf "$INSTALL_DIR" - -if [[ $KEEP_CONFIG -eq 0 ]]; then - rm -rf "$CONFIG_DIR" -fi - -echo "Uninstalled ${SERVICE_NAME}." -if [[ $KEEP_CONFIG -eq 1 ]]; then - echo "Kept config directory: ${CONFIG_DIR}" -fi diff --git a/uninstall-windows-task.bat b/uninstall-windows-task.bat deleted file mode 100644 index f0a1379..0000000 --- a/uninstall-windows-task.bat +++ /dev/null @@ -1,7 +0,0 @@ -@echo off -setlocal - -set "SCRIPT_DIR=%~dp0" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%uninstall-windows-task.ps1" %* -set EXITCODE=%ERRORLEVEL% -exit /b %EXITCODE% diff --git a/uninstall-windows-task.ps1 b/uninstall-windows-task.ps1 deleted file mode 100644 index 8b90718..0000000 --- a/uninstall-windows-task.ps1 +++ /dev/null @@ -1,74 +0,0 @@ -[CmdletBinding()] -param( - [Alias('keep-config')] - [switch]$KeepConfig -) - -$ErrorActionPreference = 'Stop' - -$ServiceName = "luxtools-client" -$TaskName = $ServiceName - -function Write-Usage { - @" -Usage: - uninstall-windows-task.bat [--keep-config] - -Uninstalls $ServiceName Scheduled Task and removes installed files. - -Options: - --keep-config Keep %LOCALAPPDATA%\$ServiceName\config.json on disk. -"@ | Write-Host -} - -# Allow -h/--help as positional args (PowerShell doesn't treat these as built-in). -foreach ($a in @($args)) { - if ($a -eq '-h' -or $a -eq '--help') { - Write-Usage - exit 0 - } -} - -$installDir = Join-Path $env:LOCALAPPDATA $ServiceName -$exePath = Join-Path $installDir "$ServiceName.exe" -$configPath = Join-Path $installDir "config.json" - -# Stop task and any running instance (best-effort). -try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null } catch {} -try { schtasks.exe /End /TN $TaskName 2>$null | Out-Null } catch {} - -try { - $procs = Get-Process -Name $ServiceName -ErrorAction SilentlyContinue - foreach ($p in $procs) { - try { - if ($p.Path -and (Split-Path -Path $p.Path -Resolve) -ieq (Split-Path -Path $exePath -Resolve)) { - Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue - } - } catch {} - } -} catch {} - -# Remove task (best-effort). -try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } catch {} -try { schtasks.exe /Delete /TN $TaskName /F 2>$null | Out-Null } catch {} - -# Remove files. -if (Test-Path -LiteralPath $installDir) { - if ($KeepConfig) { - # Remove everything except config.json - Get-ChildItem -LiteralPath $installDir -Force | ForEach-Object { - if ($_.FullName -ieq $configPath) { return } - try { - Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue - } catch {} - } - # If directory became empty except config, keep it. - } else { - try { Remove-Item -LiteralPath $installDir -Recurse -Force -ErrorAction SilentlyContinue } catch {} - } -} - -Write-Host "Uninstalled $ServiceName." -if ($KeepConfig) { - Write-Host "Kept config file: $configPath" -}