add install commands
This commit is contained in:
125
install-linux.sh
125
install-linux.sh
@@ -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)"
|
||||
@@ -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%
|
||||
@@ -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"
|
||||
40
internal/installer/helpers.go
Normal file
40
internal/installer/helpers.go
Normal 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
|
||||
}
|
||||
25
internal/installer/installer.go
Normal file
25
internal/installer/installer.go
Normal 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
|
||||
}
|
||||
}
|
||||
148
internal/installer/installer_linux.go
Normal file
148
internal/installer/installer_linux.go
Normal 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
|
||||
}
|
||||
193
internal/installer/installer_windows.go
Normal file
193
internal/installer/installer_windows.go
Normal 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
71
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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user