From 6c34266c067c794dc4623132124c8b8b9dfda682 Mon Sep 17 00:00:00 2001 From: intergrav Date: Mon, 29 Jun 2026 12:20:49 -0400 Subject: [PATCH] feat: cli interface and also lowercase restray binaries --- cmd/restray/cli.go | 314 ++++++++++++++++++++++++++++++++ cmd/restray/main.go | 4 + cmd/restray/operations.go | 42 +++-- cmd/restray/update.go | 4 +- flake.nix | 8 +- go.mod | 2 +- justfile | 16 +- packaging/linux/restray.desktop | 2 +- packaging/windows/installer.nsi | 4 +- 9 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 cmd/restray/cli.go diff --git a/cmd/restray/cli.go b/cmd/restray/cli.go new file mode 100644 index 0000000..1b1edf9 --- /dev/null +++ b/cmd/restray/cli.go @@ -0,0 +1,314 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "runtime" + "strings" + "sync/atomic" + "syscall" +) + +func cliUsage() { + fmt.Fprintf(os.Stderr, `Usage: restray [command] + +Commands: + (no command) Launch GUI + schedule [-p name] Run full schedule with hooks + op|operation backup [-p name] Run backup + op prune [-p name] Prune old snapshots + op check [-p name] Verify repository integrity + op unlock [-p name] Remove stale locks + op shell [-p name] Open shell with restic environment + op mount [-p name] Mount repository (Ctrl+C to unmount) + op pre-hook [-p name] Run pre-hook command + op post-hook [-p name] Run post-hook command + +Flags: + -p name Profile name (default: first profile) +`) +} + +func resolveProfile(cfg Config, name string) (int, Profile, error) { + if name == "" { + return 0, cfg.Profile[0], nil + } + for i, p := range cfg.Profile { + if p.displayName() == name { + return i, p, nil + } + } + var names []string + for _, p := range cfg.Profile { + names = append(names, p.displayName()) + } + return 0, Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", ")) +} + +func cliParseFlags(args []string) (profileName string, remaining []string) { + fs := flag.NewFlagSet("restray", flag.ContinueOnError) + fs.StringVar(&profileName, "p", "", "profile name") + fs.Parse(args) + return profileName, fs.Args() +} + +func cliRunRestic(prof Profile, args ...string) int { + p, _ := findRestic() + if p == "" { + fmt.Fprintln(os.Stderr, "error: restic not found") + return 1 + } + cmd := resticCmd(prof, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if code := exitCode(err); code > 0 { + return code + } + return 1 + } + return 0 +} + +func cliRunHook(hook string, prof Profile, extraEnv ...string) int { + if hook == "" { + fmt.Fprintln(os.Stderr, "error: no hook configured") + return 1 + } + cmd := hookCmd(hook, prof, extraEnv...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if code := exitCode(err); code > 0 { + return code + } + return 1 + } + return 0 +} + +func cliBackup(prof Profile, scheduled bool) int { + args := []string{"backup"} + if retryLockEnabled(prof) { + args = append(args, "--retry-lock", prof.RetryLock) + } + args = append(args, prof.Backup.Args...) + if scheduled { + args = append(args, prof.Backup.ArgsScheduled...) + } + args = append(args, prof.Backup.Paths...) + return cliRunRestic(prof, args...) +} + +func cliPrune(prof Profile) int { + return cliRunRestic(prof, forgetArgs(prof)...) +} + +func cliCheck(prof Profile) int { + return cliRunRestic(prof, checkArgs(prof)...) +} + +func cliUnlock(prof Profile) int { + return cliRunRestic(prof, "unlock") +} + +func cliShell(prof Profile) int { + shell := os.Getenv("SHELL") + if shell == "" { + if runtime.GOOS == "windows" { + shell = "cmd" + } else { + shell = "/bin/sh" + } + } + env := envWithResticPath(resticEnv(prof)) + fmt.Fprintf(os.Stderr, "Restray: environment for profile %q loaded. Run \"restic\" for commands. Exit to return.\n", prof.displayName()) + args := []string{"-l"} + if runtime.GOOS == "windows" { + args = nil + } + cmd := exec.Command(shell, args...) + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if code := exitCode(err); code > 0 { + return code + } + return 1 + } + return 0 +} + +func cliMount(prof Profile) int { + if runtime.GOOS == "windows" { + fmt.Fprintln(os.Stderr, "error: mount is not supported on Windows") + return 1 + } + dir, err := os.MkdirTemp("", "restray-mount-") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + defer os.Remove(dir) + + args := append([]string{"mount", "--no-lock"}, prof.Mount.Args...) + args = append(args, dir) + fmt.Fprintf(os.Stderr, "Mounting at %s (press Ctrl+C to unmount)\n", dir) + return cliRunRestic(prof, args...) +} + +func cliSchedule(prof Profile) int { + p, _ := findRestic() + if p == "" { + fmt.Fprintln(os.Stderr, "error: restic not found") + return 1 + } + + var interrupted atomic.Bool + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Reset(os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + interrupted.Store(true) + }() + + var ops []string + if prof.Schedule.BackupEnabled() { + ops = append(ops, "backup") + } + if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { + ops = append(ops, "prune") + } + if prof.Schedule.Check { + ops = append(ops, "check") + } + + hookEnv := []string{ + "RESTRAY_OPERATIONS=" + strings.Join(ops, ","), + "RESTRAY_SCHEDULED=true", + } + + if prof.PreHook != "" { + fmt.Fprintln(os.Stderr, "Running pre-hook...") + if code := cliRunHook(prof.PreHook, prof, hookEnv...); code != 0 { + return 1 + } + } + + failed := false + + if !interrupted.Load() && prof.Schedule.BackupEnabled() { + fmt.Fprintln(os.Stderr, "Running backup...") + if code := cliBackup(prof, true); code != 0 { + failed = true + } + } + + if !interrupted.Load() && prof.Schedule.Prune && len(prof.Prune.Args) > 0 { + fmt.Fprintln(os.Stderr, "Running prune...") + if code := cliPrune(prof); code != 0 { + failed = true + } + } + + if !interrupted.Load() && prof.Schedule.Check { + fmt.Fprintln(os.Stderr, "Running check...") + if code := cliCheck(prof); code != 0 { + failed = true + } + } + + if prof.PostHook != "" { + signal.Reset(os.Interrupt, syscall.SIGTERM) + fmt.Fprintln(os.Stderr, "Running post-hook...") + postEnv := hookEnv + if failed || interrupted.Load() { + errMsg := "operation failed" + if interrupted.Load() { + errMsg = "interrupted by user" + } + postEnv = append(postEnv, "RESTRAY_ERROR="+errMsg) + } + if code := cliRunHook(prof.PostHook, prof, postEnv...); code != 0 { + return 1 + } + } + + if failed || interrupted.Load() { + return 1 + } + return 0 +} + +func cliResolve(args []string) (Profile, error) { + name, _ := cliParseFlags(args) + cfg := loadConfig() + _, prof, err := resolveProfile(cfg, name) + return prof, err +} + +func runCLI(args []string) int { + if len(args) == 0 { + cliUsage() + return 1 + } + + switch args[0] { + case "schedule": + prof, err := cliResolve(args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + return cliSchedule(prof) + + case "op", "operation": + if len(args) < 2 { + cliUsage() + return 1 + } + op := args[1] + prof, err := cliResolve(args[2:]) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + switch op { + case "backup": + return cliBackup(prof, false) + case "prune": + return cliPrune(prof) + case "check": + return cliCheck(prof) + case "unlock": + return cliUnlock(prof) + case "shell": + return cliShell(prof) + case "mount": + return cliMount(prof) + case "pre-hook": + return cliRunHook(prof.PreHook, prof) + case "post-hook": + return cliRunHook(prof.PostHook, prof) + default: + fmt.Fprintf(os.Stderr, "unknown operation: %s\n", op) + cliUsage() + return 1 + } + + case "-h", "--help", "help": + cliUsage() + return 0 + + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0]) + cliUsage() + return 1 + } +} diff --git a/cmd/restray/main.go b/cmd/restray/main.go index 1c1eea2..a521205 100644 --- a/cmd/restray/main.go +++ b/cmd/restray/main.go @@ -11,6 +11,10 @@ import ( const version = "0.8.0" func main() { + if len(os.Args) > 1 { + os.Exit(runCLI(os.Args[1:])) + } + os.MkdirAll(configDir(), 0700) lock, err := os.OpenFile(lockPath(), os.O_CREATE|os.O_RDWR, 0600) diff --git a/cmd/restray/operations.go b/cmd/restray/operations.go index aa180ca..d896d7f 100644 --- a/cmd/restray/operations.go +++ b/cmd/restray/operations.go @@ -271,35 +271,37 @@ func runBackup(idx int, mStatus prefixedMenuItem, prof Profile, onDone func()) { } } -func runHook(hook string, prof Profile, extraEnv ...string) error { - if hook == "" { - return nil - } - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/c", hook) - } else { - cmd = exec.Command("sh", "-c", hook) - } - vars, err := parseEnvFile(prof.EnvFile) - if err != nil { - vars = map[string]string{} - } - cmd.Env = os.Environ() - for k, v := range vars { - cmd.Env = append(cmd.Env, k+"="+v) - } +func envWithResticPath(env []string) []string { if p, _ := findRestic(); p != "" { dir := filepath.Dir(p) - for i, e := range cmd.Env { + for i, e := range env { if strings.HasPrefix(e, "PATH=") { - cmd.Env[i] = "PATH=" + dir + string(os.PathListSeparator) + e[5:] + env[i] = "PATH=" + dir + string(os.PathListSeparator) + e[5:] break } } } + return env +} + +func hookCmd(hook string, prof Profile, extraEnv ...string) *exec.Cmd { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", hook) + } else { + cmd = exec.Command("sh", "-c", hook) + } + cmd.Env = envWithResticPath(resticEnv(prof)) cmd.Env = append(cmd.Env, extraEnv...) cmd.Env = append(cmd.Env, "RESTRAY_PROFILE="+prof.displayName()) + return cmd +} + +func runHook(hook string, prof Profile, extraEnv ...string) error { + if hook == "" { + return nil + } + cmd := hookCmd(hook, prof, extraEnv...) log.Printf("[%s] running hook: %s", prof.displayName(), hook) out, err := cmd.CombinedOutput() if err != nil { diff --git a/cmd/restray/update.go b/cmd/restray/update.go index 850065b..478d850 100644 --- a/cmd/restray/update.go +++ b/cmd/restray/update.go @@ -92,9 +92,9 @@ func artifactURL(ver string) string { var name string switch runtime.GOOS { case "windows": - name = fmt.Sprintf("Restray-%s-windows-%s-setup.exe", ver, runtime.GOARCH) + name = fmt.Sprintf("restray-%s-windows-%s-setup.exe", ver, runtime.GOARCH) case "darwin": - name = fmt.Sprintf("Restray-%s-darwin-%s.zip", ver, runtime.GOARCH) + name = fmt.Sprintf("restray-%s-darwin-%s.zip", ver, runtime.GOARCH) default: return "" } diff --git a/flake.nix b/flake.nix index 5f050bf..d468ade 100644 --- a/flake.nix +++ b/flake.nix @@ -36,24 +36,24 @@ if isDarwin then '' mkdir -p $out/Applications/Restray.app/Contents/{MacOS,Resources} - mv $out/bin/Restray $out/Applications/Restray.app/Contents/MacOS/Restray + mv $out/bin/restray $out/Applications/Restray.app/Contents/MacOS/Restray cp packaging/darwin/Info.plist $out/Applications/Restray.app/Contents/Info.plist cp packaging/darwin/restray.icns $out/Applications/Restray.app/Contents/Resources/restray.icns mkdir -p $out/bin - ln -s $out/Applications/Restray.app/Contents/MacOS/Restray $out/bin/Restray + ln -s $out/Applications/Restray.app/Contents/MacOS/Restray $out/bin/restray '' else '' install -Dm644 packaging/linux/restray.desktop $out/share/applications/restray.desktop install -Dm644 packaging/linux/restray.png $out/share/icons/hicolor/256x256/apps/restray.png substituteInPlace $out/share/applications/restray.desktop \ - --replace-fail "Exec=Restray" "Exec=$out/bin/Restray" + --replace-fail "Exec=restray" "Exec=$out/bin/restray" ''; meta = { description = "Restic backup scheduler system tray app"; license = pkgs.lib.licenses.gpl3Only; platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin; - mainProgram = "Restray"; + mainProgram = "restray"; }; }; diff --git a/go.mod b/go.mod index 0966ae7..4ba5796 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module Restray +module restray go 1.26.3 diff --git a/justfile b/justfile index 763be3f..5bc92d5 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,4 @@ -app := "Restray" +app := "restray" version := `grep 'const version' cmd/restray/main.go | cut -d'"' -f2` bin := "bin" dist := "dist" @@ -106,13 +106,13 @@ package target="" arch="": esac _package-darwin arch: - rm -rf {{dist}}/{{app}}.app - mkdir -p {{dist}}/{{app}}.app/Contents/MacOS {{dist}}/{{app}}.app/Contents/Resources - cp {{bin}}/{{app}}-{{version}}-darwin-{{arch}} {{dist}}/{{app}}.app/Contents/MacOS/{{app}} - cp packaging/darwin/Info.plist {{dist}}/{{app}}.app/Contents/Info.plist - cp packaging/darwin/restray.icns {{dist}}/{{app}}.app/Contents/Resources/restray.icns - cd {{dist}} && zip -r {{app}}-{{version}}-darwin-{{arch}}.zip {{app}}.app - rm -rf {{dist}}/{{app}}.app + rm -rf {{dist}}/Restray.app + mkdir -p {{dist}}/Restray.app/Contents/MacOS {{dist}}/Restray.app/Contents/Resources + cp {{bin}}/{{app}}-{{version}}-darwin-{{arch}} {{dist}}/Restray.app/Contents/MacOS/Restray + cp packaging/darwin/Info.plist {{dist}}/Restray.app/Contents/Info.plist + cp packaging/darwin/restray.icns {{dist}}/Restray.app/Contents/Resources/restray.icns + cd {{dist}} && zip -r {{app}}-{{version}}-darwin-{{arch}}.zip Restray.app + rm -rf {{dist}}/Restray.app _package-windows arch: mkdir -p {{dist}} diff --git a/packaging/linux/restray.desktop b/packaging/linux/restray.desktop index 4ed1def..6ce9235 100644 --- a/packaging/linux/restray.desktop +++ b/packaging/linux/restray.desktop @@ -2,6 +2,6 @@ Type=Application Name=Restray Comment=Restic Backup Scheduler -Exec=Restray +Exec=restray Icon=restray Categories=Utility;Archiving; diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index d8292f5..4bb4d2a 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -3,7 +3,7 @@ !define VERSION "0.8.0" Name "Restray" -OutFile "..\..\dist\Restray-${VERSION}-windows-${ARCH}-setup.exe" +OutFile "..\..\dist\restray-${VERSION}-windows-${ARCH}-setup.exe" InstallDir "$LOCALAPPDATA\Restray" RequestExecutionLevel user @@ -23,7 +23,7 @@ VIAddVersionKey "LegalCopyright" "Copyright © 2026 devins.page" Section "Install" SetOutPath "$INSTDIR" - File "..\..\bin\Restray.exe" + File "/oname=Restray.exe" "..\..\bin\restray.exe" CreateDirectory "$SMPROGRAMS\Restray" CreateShortcut "$SMPROGRAMS\Restray\Restray.lnk" "$INSTDIR\Restray.exe" -- 2.51.2