add install commands

This commit is contained in:
2026-01-07 07:26:35 +01:00
parent 25cda1026b
commit 5763545f10
11 changed files with 477 additions and 431 deletions

View File

@@ -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 <<EOF
Usage: $0 [--listen host:port] [--allow <path>]...
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" <<EOF
# ${SERVICE_NAME} environment
LISTEN="${LISTEN}"
ALLOW_ARGS="${ALLOW_ARGS}"
EOF
chmod 0640 "$ENV_FILE"
# Best-effort tighten config perms.
chmod 0600 "$ENV_FILE" || true
install -m 0755 -D "$TMP_BIN" "$BIN_PATH"
cat >"$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)"

View File

@@ -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%

View File

@@ -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 <path>]...
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"

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

71
main.go
View File

@@ -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 {

View File

@@ -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 <<EOF
Usage: $0 [--keep-config]
Uninstalls ${SERVICE_NAME} systemd *user* service and removes installed files.
Options:
--keep-config Keep ${CONFIG_DIR} (config) on disk.
EOF
}
KEEP_CONFIG=0
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ "${1:-}" == "--keep-config" ]]; then
KEEP_CONFIG=1
fi
# Stop/disable user service if present (best-effort).
systemctl --user stop "$SERVICE_NAME" >/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

View File

@@ -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%

View File

@@ -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"
}