Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
56f562d05b chore(deps): update module github.com/spf13/cobra to v1.10.2
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
Go CI Pipeline / ci (pull_request) Has been cancelled
2025-12-14 14:57:17 +00:00
14 changed files with 715 additions and 887 deletions

372
app.go
View file

@ -1,7 +1,6 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
@ -9,172 +8,149 @@ import (
"strings"
"time"
sshPkg "golang.org/x/crypto/ssh"
"workctl/internal/config"
"workctl/internal/ssh"
"workctl/internal/store"
"github.com/charmbracelet/huh"
"golang.org/x/crypto/ssh"
)
type Flags struct {
ShowWeek bool
ShowMonth bool
ShowExport bool
ExportName string
StartInBackground bool
WithoutTimew bool
}
type App struct {
cfg config.Config
store *store.Store
flags Flags
cfg Config
flags Flags
timeStore *TimeStore
}
func NewApp() (*App, error) {
cfg, err := config.Load()
cfg, err := loadConfig()
if err != nil {
return nil, fmt.Errorf("error loading config: %w", err)
}
st, err := store.NewStore()
ts, err := NewTimeStore(cfg)
if err != nil {
return nil, fmt.Errorf("error initializing time store: %w", err)
}
return &App{
cfg: cfg,
store: st,
flags: Flags{},
cfg: cfg,
timeStore: ts,
}, nil
}
func (a *App) Close() error {
if a.store != nil {
return a.store.Close()
if a.timeStore != nil {
return a.timeStore.Close()
}
return nil
}
func (a *App) Execute(ctx context.Context) error {
if len(os.Args) > 1 {
return a.setupCommands().ExecuteContext(ctx)
}
return a.makeChoice(ctx)
}
func (a *App) StartTracking(ctx context.Context, tag string) error {
if err := a.store.StartTracking(ctx, tag); err != nil {
return err
}
if !a.flags.WithoutTimew {
_ = a.runCommand("timew", "start", tag)
}
return nil
}
func (a *App) StopTracking(ctx context.Context) error {
if err := a.store.StopTracking(ctx); err != nil {
return err
}
if !a.flags.WithoutTimew {
_ = a.runCommand("timew", "stop")
}
return nil
}
func (a *App) connect(ctx context.Context) error {
if err := a.StartTracking(ctx, store.TagWork); err != nil {
slog.Warn("Failed to start time tracking", "error", err)
func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { // Rückgabetyp geändert
if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil {
slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err))
}
a.wakeWorkstation()
sshCon, err := ssh.NewConnection(a.cfg.SSHUser, a.cfg.SSHHost, a.cfg.SSHPort, a.getSSHAuth())
sshCon, err := a.newSSHConnection()
if err != nil {
return fmt.Errorf("failed to establish primary SSH connection: %w", err)
return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err)
}
defer sshCon.Close()
slog.Info("SSH connection established. Setting up tunnels...")
tunnelCtx, cancelTunnels := context.WithCancel(ctx)
defer cancelTunnels()
sshForwarder := ssh.NewForwarder(sshCon.Client, config.PortLocalSSH, config.PortRemoteSSH, a.cfg.WorkstationIP)
rdpForwarder := ssh.NewForwarder(sshCon.Client, config.PortLocalRDP, config.PortRemoteRDP, a.cfg.WorkstationIP)
sshReady := make(chan struct{})
rdpReady := make(chan struct{})
sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP)
go func() {
if err := sshForwarder.Start(tunnelCtx, sshReady); err != nil {
slog.Error("SSH forwarder stopped", "error", err)
}
}()
go func() {
if err := rdpForwarder.Start(tunnelCtx, rdpReady); err != nil {
slog.Error("RDP forwarder stopped", "error", err)
slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)")
if err := sshForwarder.forward(); err != nil {
slog.Error(fmt.Sprintf("SSH forwarder failed: %v", err))
}
slog.Info("SSH forwarder stopped.")
}()
slog.Info("Waiting for tunnels to initialize...")
rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP)
go func() {
slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)")
if err := rdpForwarder.forward(); err != nil {
// slog.Error(fmt.Sprintf("RDP forwarder failed: %v", err)
slog.Error(fmt.Sprintf("ERROR: RDP forwarder failed: %v", err))
}
slog.Info("RDP forwarder stopped.")
}()
readyCtx, cancelReady := context.WithTimeout(ctx, 5*time.Second)
defer cancelReady()
time.Sleep(500 * time.Millisecond)
select {
case <-sshReady:
slog.Debug("SSH Tunnel ready")
case <-readyCtx.Done():
return fmt.Errorf("timeout waiting for SSH tunnel readiness")
}
select {
case <-rdpReady:
slog.Debug("RDP Tunnel ready")
case <-readyCtx.Done():
return fmt.Errorf("timeout waiting for RDP tunnel readiness")
}
slog.Info("All tunnels established and listening.")
if a.flags.StartInBackground {
fmt.Println("\nINFO: Tunnels are active in background.")
fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
fmt.Println("INFO: Press Ctrl+C to stop.")
<-ctx.Done()
slog.Info("Context cancelled, shutting down tunnels...")
} else {
fmt.Println("Automatically connecting to workstation via SSH tunnel...")
a.connectToWorkstation()
fmt.Println("Workstation SSH session finished.")
}
if err := a.StopTracking(context.Background()); err != nil {
slog.Warn("Failed to stop time tracking", "error", err)
} else {
slog.Info("Time tracking stopped.")
}
return nil
return sshCon, nil
}
func (a *App) runCommand(name string, args ...string) error {
slog.Info("Executing command", "cmd", name, "args", args)
slog.Info(fmt.Sprintf("Executing command: %s %s", name, strings.Join(args, " ")))
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
slog.Error("Command failed", "cmd", name, "error", err)
return err
err := cmd.Run()
if err != nil {
slog.Error(fmt.Sprintf("Command failed: %s %s -> %v", name, strings.Join(args, " "), err))
return fmt.Errorf("command execution failed: %w", err)
}
slog.Info(fmt.Sprintf("Command finished successfully: %s", name))
return nil
}
func (a *App) makeChoice(ctx context.Context) error {
func (a *App) wakeWorkstation() {
slog.Info("Attempting to wake workstation...")
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"",
a.cfg.JumpUser,
a.cfg.JumpHost,
a.cfg.WorkstationMac)
outerSSHCmd := []string{
"-tt",
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
innerSSHCmd,
}
if err := a.runCommand("ssh", outerSSHCmd...); err != nil {
slog.Warn("Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.")
} else {
slog.Info("Wake-on-LAN command executed.")
}
}
func (a *App) connectToJump() {
slog.Info("Connecting to Jump Host with Port Forwarding...")
sshArgs := []string{
"-tt",
"-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost),
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
}
if err := a.runCommand("ssh", sshArgs...); err != nil {
}
}
func (a *App) connectToWorkstation() {
slog.Info("Connecting to Workstation via local tunnel (localhost:2048)...")
sshArgs := []string{
"-tt",
"-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost),
"-p", "2048",
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
}
if err := a.runCommand("ssh", sshArgs...); err != nil {
}
}
func (a *App) startRDPConnection() {
slog.Info("Starting RDP connection to localhost:6000...")
rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350 +clipboard /dynamic-resolution",
a.cfg.RDPUser,
a.cfg.SSHPassword,
)
if err := a.runCommand("bash", "-c", rdpCommand); err != nil {
}
}
func (a *App) makeChoice() {
var choice string
form := huh.NewForm(
@ -190,47 +166,65 @@ func (a *App) makeChoice(ctx context.Context) error {
huh.NewOption("Show Week Summary", "show week summary"),
huh.NewOption("Show Month Summary", "show month summary"),
huh.NewOption("Export Yearly Timetable", "export"),
huh.NewOption("Connect to Jump Host (Tunnel)", "connect to jump"),
huh.NewOption("Connect to Workstation (Tunnel)", "connect to workstation"),
huh.NewOption("Start RDP Connection", "start rdp connection"),
huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"),
huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"),
huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"),
huh.NewOption("Wake Workstation", "wake workstation"),
huh.NewOption("Kill Active Tunnels", "kill tunnels"),
huh.NewOption("Config: Set Secrets", "set secrets"),
huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"),
huh.NewOption("Exit", "exit"),
).
Value(&choice),
),
)
if err := form.Run(); err != nil {
return nil
err := form.Run()
if err != nil {
if err == huh.ErrUserAborted {
fmt.Println("Operation cancelled.")
return
}
slog.Error(fmt.Sprintf("Form execution failed: %v", err))
return
}
switch choice {
case "start work":
return a.connect(ctx)
a.connect(withoutTimew)
case "stop work":
if err := a.StopTracking(ctx); err != nil {
slog.Error("Failed to stop time tracking", "error", err)
if err := a.timeStore.StopTracking(withoutTimew); err != nil {
slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err))
}
if err := a.killForwardings(); err != nil {
slog.Warn(fmt.Sprintf("Could not kill all forwardings: %v", err))
}
_ = a.killForwardings()
case "start break":
if err := a.StartTracking(ctx, store.TagBreak); err != nil {
slog.Error("Failed to start break", "error", err)
if err := a.timeStore.StartTracking(TagBreak, withoutTimew); err != nil {
slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err))
}
case "stop break":
if err := a.StartTracking(ctx, store.TagWork); err != nil {
slog.Error("Failed to stop break", "error", err)
if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil {
slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err))
}
case "show day summary":
_ = a.store.ShowSummary(ctx, "today")
if err := a.timeStore.ShowSummary("today"); err != nil {
slog.Error(fmt.Sprintf("Failed to show day summary: %v", err))
}
case "show week summary":
_ = a.store.ShowSummary(ctx, "week")
if err := a.timeStore.ShowSummary("week"); err != nil {
slog.Error(fmt.Sprintf("ERROR: Failed to show week summary: %v", err))
}
case "show month summary":
_ = a.store.ShowSummary(ctx, "month")
if err := a.timeStore.ShowSummary("month"); err != nil {
slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
}
case "export":
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
_ = a.store.ExportSummary(ctx, filename)
if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" {
filename = a.flags.ExportName
}
if err := a.timeStore.ExportSummary(filename); err != nil {
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
}
case "connect to jump":
a.connectToJump()
case "connect to workstation":
@ -240,90 +234,86 @@ func (a *App) makeChoice(ctx context.Context) error {
case "wake workstation":
a.wakeWorkstation()
case "kill tunnels":
_ = a.killForwardings()
case "set secrets":
fmt.Println("Please run 'workctl config set-secrets' directly from CLI.")
if err := a.killForwardings(); err != nil {
slog.Error(fmt.Sprintf("Failed to kill forwardings: %v", err))
} else {
slog.Info("Attempted to kill processes on ports 2048 and 6000.")
}
case "exit":
return nil
fmt.Println("Exiting.")
return
default:
slog.Warn(fmt.Sprintf("Unhandled choice '%s'", choice))
}
if choice != "exit" && choice != "start work" {
if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" {
fmt.Println("\nPress Enter to continue...")
fmt.Scanln()
return a.makeChoice(ctx)
a.makeChoice()
}
return nil
}
func (a *App) getSSHAuth() sshPkg.AuthMethod {
func (a *App) getSSHAuth() ssh.AuthMethod {
keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
slog.Error("Unable to read private key", "path", keyPath, "error", err)
slog.Error(fmt.Sprintf("Unable to read private key '%s': %v", keyPath, err))
return nil
}
key, err := sshPkg.ParsePrivateKey(keyBytes)
var key ssh.Signer
key, err = ssh.ParsePrivateKey(keyBytes)
if err != nil {
if _, ok := err.(*sshPkg.PassphraseMissingError); ok {
slog.Info("Key requires passphrase, trying RDP password from config/keyring")
key, err = sshPkg.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
if _, ok := err.(*ssh.PassphraseMissingError); ok {
slog.Info(fmt.Sprintf("Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath))
key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
if err != nil {
slog.Error("Failed to parse key with passphrase", "error", err)
slog.Error(fmt.Sprintf("Unable to parse private key '%s' with passphrase: %v", keyPath, err))
return nil
}
} else {
slog.Error("Failed to parse private key", "error", err)
slog.Error(fmt.Sprintf("Unable to parse private key '%s': %v", keyPath, err))
return nil
}
}
return sshPkg.PublicKeys(key)
slog.Info(fmt.Sprintf("Successfully loaded private key '%s'", keyPath))
return ssh.PublicKeys(key)
}
func (a *App) wakeWorkstation() {
slog.Info("Attempting to wake workstation...")
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Packet sent' && exit\"",
a.cfg.JumpUser, a.cfg.JumpHost, a.cfg.WorkstationMac)
args := []string{
"-tt",
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
innerSSHCmd,
func (a *App) newSSHConnection() (*SSHConnection, error) {
authMethod := a.getSSHAuth()
if authMethod == nil {
return nil, fmt.Errorf("SSH authentication method could not be obtained")
}
_ = a.runCommand("ssh", args...)
}
func (a *App) connectToJump() {
args := []string{
"-tt",
"-L", fmt.Sprintf("%s:%s:%s", config.PortLocalSSH, a.cfg.WorkstationHost, config.PortRemoteSSH),
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
sshConfig := &ssh.ClientConfig{
User: a.cfg.SSHUser,
Auth: []ssh.AuthMethod{authMethod},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second, // Etwas längerer Timeout
}
_ = a.runCommand("ssh", args...)
}
func (a *App) connectToWorkstation() {
args := []string{
"-tt",
"-L", fmt.Sprintf("%s:%s:%s", config.PortLocalRDP, a.cfg.WorkstationHost, config.PortRemoteRDP),
"-p", config.PortLocalSSH,
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
}
_ = a.runCommand("ssh", args...)
}
target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort)
slog.Info(fmt.Sprintf("Dialing SSH to %s...", target))
func (a *App) startRDPConnection() {
args := []string{
fmt.Sprintf("/u:%s", a.cfg.RDPUser),
fmt.Sprintf("/p:%s", a.cfg.RDPPassword),
fmt.Sprintf("/v:127.0.0.1:%s", config.PortLocalRDP),
"/size:3000x1350",
"+clipboard",
"/dynamic-resolution",
client, err := ssh.Dial("tcp", target, sshConfig)
if err != nil {
return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err)
}
_ = a.runCommand("xfreerdp", args...)
slog.Info(fmt.Sprintf("SSH connection to %s successful.", target))
session, err := client.NewSession()
if err != nil {
client.Close() // Client schließen, wenn Session fehlschlägt
return nil, fmt.Errorf("failed to create SSH session: %w", err)
}
session.Close()
return &SSHConnection{
client: client,
}, nil
}
func (a *App) killForwardings() error {

160
cmd.go
View file

@ -4,16 +4,16 @@ import (
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"workctl/internal/config"
"workctl/internal/store"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
var withoutTimew bool
func (a *App) setupCommands() *cobra.Command {
rootCmd := &cobra.Command{
Use: "workctl",
@ -30,84 +30,75 @@ and other utilities.`,
rootCmd.AddCommand(a.connectCommands())
rootCmd.AddCommand(a.wakeCommand())
rootCmd.AddCommand(a.importTimewarriorCommand())
rootCmd.AddCommand(a.configCommand())
rootCmd.CompletionOptions.DisableDefaultCmd = true
return rootCmd
}
func (a *App) configCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage configuration and secrets",
}
cmd.AddCommand(&cobra.Command{
Use: "set-secrets",
Short: "Interactively set passwords in the system keyring",
Long: "Prompts for SSH and RDP passwords and stores them securely in the operating system's keychain/keyring.",
RunE: func(cmd *cobra.Command, args []string) error {
var sshPw, rdpPw string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("SSH Password").
Description("Leave empty to keep existing").
EchoMode(huh.EchoModePassword).
Value(&sshPw),
huh.NewInput().
Title("RDP Password").
Description("Leave empty to keep existing").
EchoMode(huh.EchoModePassword).
Value(&rdpPw),
),
)
if err := form.Run(); err != nil {
return err
}
if sshPw != "" {
if err := config.SetSecret(config.KeySSHPassword(), sshPw); err != nil {
return fmt.Errorf("failed to save SSH password: %w", err)
}
fmt.Println("✓ SSH password saved to keyring.")
}
if rdpPw != "" {
if err := config.SetSecret(config.KeyRDPPassword(), rdpPw); err != nil {
return fmt.Errorf("failed to save RDP password: %w", err)
}
fmt.Println("✓ RDP password saved to keyring.")
}
if sshPw == "" && rdpPw == "" {
fmt.Println("No changes made.")
}
return nil
},
})
return cmd
}
func (a *App) startCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "start",
Short: "Start work: Track time, WOL, setup tunnels, optionally connect or run in background",
Long: `Starts time tracking, attempts WOL, sets up SSH tunnels.
Default behavior: Immediately starts an interactive SSH session to the workstation via the tunnel.
Use --background (-b) to keep tunnels running in the background without auto-connecting.`,
Default behavior: Immediately starts an interactive SSH session to the workstation via the tunnel. The command blocks until this session ends.
Use --background (-b) to keep tunnels running in the background without auto-connecting. Press Ctrl+C to stop background tunnels.`,
RunE: func(cmd *cobra.Command, args []string) error {
return a.connect(cmd.Context())
fmt.Println("Starting workday procedures...")
sshCon, err := a.connect(withoutTimew)
if err != nil {
fmt.Printf("ERROR: Failed to start connections: %v\n", err)
return fmt.Errorf("connection setup failed: %w", err)
}
defer func() {
slog.Info("Closing SSH connection to jump host (defer)...")
if err := sshCon.Close(); err != nil {
slog.Warn(fmt.Sprintf("Error closing SSH connection in defer: %v", err))
} else {
slog.Info("SSH connection closed via defer.")
}
}()
if a.flags.StartInBackground {
fmt.Println("\nINFO: Tunnels are active in background.")
fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
fmt.Println("INFO: Press Ctrl+C to stop tunnels.")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...")
slog.Info("Received signal, cleanup via defer sshCon.Close() will run.")
if err := a.timeStore.StopTracking(withoutTimew); err != nil {
slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else {
slog.Info("Time tracking stopped.")
}
fmt.Println("INFO: Background shutdown complete.")
} else {
fmt.Println("Automatically connecting to workstation via SSH tunnel...")
a.connectToWorkstation()
fmt.Println("Workstation SSH session finished.")
slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.")
if err := a.timeStore.StopTracking(withoutTimew); err != nil {
slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else {
slog.Info("Time tracking stopped.")
}
}
return nil
},
}
cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately")
cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
return cmd
}
@ -119,7 +110,7 @@ func (a *App) stopCommand() *cobra.Command {
Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping workday procedures...")
if err := a.StopTracking(cmd.Context()); err != nil {
if err := a.timeStore.StopTracking(withoutTimew); err != nil {
slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else {
fmt.Println("Time tracking stopped.")
@ -134,7 +125,7 @@ func (a *App) stopCommand() *cobra.Command {
fmt.Println("Workday stop procedures finished.")
},
}
cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
return cmd
}
@ -151,7 +142,7 @@ it will mark the *current day* with that tag instead of starting an interval tim
This also stops any currently running timer.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tag := store.TagWork
tag := TagWork
if len(args) > 0 {
tag = args[0]
}
@ -166,14 +157,14 @@ This also stops any currently running timer.`,
case "uni", "urlaub", "feiertag", "krank", "free":
today := time.Now()
fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02"))
if err := a.store.LogFullDay(cmd.Context(), tagLower, today); err != nil {
if err := a.timeStore.LogFullDay(tagLower, today); err != nil {
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
}
return nil
default:
fmt.Printf("Attempting to start tracking interval '%s'...\n", tag)
if err := a.StartTracking(cmd.Context(), tag); err != nil {
if err := a.timeStore.StartTracking(tag, withoutTimew); err != nil {
slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err))
return fmt.Errorf("could not start tracking '%s': %w", tag, err)
}
@ -187,7 +178,7 @@ This also stops any currently running timer.`,
Short: "Start tracking 'break'",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting break...")
if err := a.StartTracking(cmd.Context(), store.TagBreak); err != nil {
if err := a.timeStore.StartTracking(TagBreak, withoutTimew); err != nil {
slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err))
return fmt.Errorf("could not start break: %w", err)
}
@ -221,23 +212,23 @@ Export: Use the --export flag or the 'export' subcommand.`,
slog.Info(fmt.Sprintf("No export name specified, using default: %s", filename))
}
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
if err := a.timeStore.ExportSummary(filename); err != nil {
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
fmt.Printf("Error: Could not export to '%s'.\n", filename)
}
} else if a.flags.ShowWeek {
fmt.Println("Showing weekly summary...")
if err := a.store.ShowSummary(cmd.Context(), "week"); err != nil {
if err := a.timeStore.ShowSummary("week"); err != nil {
slog.Error(fmt.Sprintf("Failed to show week summary: %v", err))
}
} else if a.flags.ShowMonth {
fmt.Println("Showing monthly summary...")
if err := a.store.ShowSummary(cmd.Context(), "month"); err != nil {
if err := a.timeStore.ShowSummary("month"); err != nil {
slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
}
} else {
fmt.Printf("Showing summary for period: %s...\n", period)
if err := a.store.ShowSummary(cmd.Context(), period); err != nil {
if err := a.timeStore.ShowSummary(period); err != nil {
slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err))
}
}
@ -261,7 +252,7 @@ Export: Use the --export flag or the 'export' subcommand.`,
filename = args[0]
}
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
if err := a.timeStore.ExportSummary(filename); err != nil {
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
fmt.Printf("Error: Could not export to '%s'.\n", filename)
}
@ -293,6 +284,7 @@ func (a *App) connectCommands() *cobra.Command {
cmd.AddCommand(&cobra.Command{
Use: "jump",
Short: "Connect to Jump Host (with tunnel to workstation)",
Long: "Establishes an SSH connection to the Jump Host and forwards local port 2048 to the workstation's SSH port (22). This command blocks.",
Run: func(cmd *cobra.Command, args []string) {
a.connectToJump()
},
@ -301,6 +293,7 @@ func (a *App) connectCommands() *cobra.Command {
cmd.AddCommand(&cobra.Command{
Use: "workstation",
Short: "Connect to Workstation via SSH tunnel",
Long: "Establishes an SSH connection to the Workstation via the local tunnel on port 2048. Also sets up RDP tunnel on local port 6000. Requires the 'jump' tunnel to be active. This command blocks.",
Run: func(cmd *cobra.Command, args []string) {
a.connectToWorkstation()
},
@ -309,6 +302,7 @@ func (a *App) connectCommands() *cobra.Command {
cmd.AddCommand(&cobra.Command{
Use: "rdp",
Short: "Start RDP session via tunnel",
Long: "Starts an RDP client (xfreerdp) connecting to localhost:6000. Requires an active tunnel forwarding this port to the workstation's RDP port (3389). This command blocks.",
Run: func(cmd *cobra.Command, args []string) {
a.startRDPConnection()
},
@ -321,7 +315,11 @@ func (a *App) importTimewarriorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "import-timew [filepath]",
Short: "Import time entries from 'timewarrior summary' output file",
Args: cobra.ExactArgs(1),
Long: `Parses the output of 'timewarrior summary :year' (or similar) stored in a text file
and inserts the individual time intervals into the workctl SQLite database.
It expects the standard timewarrior summary format.
Example: workctl import-timew /path/to/timew-summary.txt`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
filepath := args[0]
fmt.Printf("Attempting to import timewarrior data from: %s\n", filepath)
@ -348,7 +346,7 @@ func (a *App) runImport(filepath string) (int, error) {
content := string(contentBytes)
lines := strings.Split(content, "\n")
tx, err := a.store.DB().Begin()
tx, err := a.timeStore.db.Begin()
if err != nil {
return 0, fmt.Errorf("could not begin database transaction: %w", err)
}
@ -443,9 +441,9 @@ func (a *App) runImport(filepath string) (int, error) {
dbTag := strings.ToLower(tag)
switch dbTag {
case "work":
dbTag = store.TagWork
dbTag = TagWork
case "break":
dbTag = store.TagBreak
dbTag = TagBreak
}
_, err = stmt.Exec(dbTag, startTime, endTime)

View file

@ -1,4 +1,4 @@
package config
package main
import (
"fmt"
@ -7,18 +7,6 @@ import (
"path/filepath"
"github.com/spf13/viper"
"github.com/zalando/go-keyring"
)
const (
serviceName = "workctl"
keySSHPassword = "ssh-password"
keyRDPPassword = "rdp-password"
PortLocalSSH = "2048"
PortLocalRDP = "6000"
PortRemoteSSH = "22"
PortRemoteRDP = "3389"
)
type Config struct {
@ -34,9 +22,18 @@ type Config struct {
RDPPassword string `mapstructure:"RDP_PASSWORD"`
WorkstationIP string `mapstructure:"WORKSTATION_IP"`
SSHPort int `mapstructure:"SSH_PORT"`
// DatabasePath string `mapstructure:"DATABASE_PATH"`
}
func Load() (Config, error) {
type Flags struct {
ShowWeek bool
ShowMonth bool
ShowExport bool
ExportName string
StartInBackground bool
}
func loadConfig() (Config, error) {
var cfg Config
configPath, err := os.UserConfigDir()
if err != nil {
@ -58,7 +55,7 @@ func Load() (Config, error) {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err)
}
slog.Debug(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile))
slog.Info(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile))
}
if err := viper.UnmarshalKey("default", &cfg); err != nil {
@ -71,32 +68,5 @@ func Load() (Config, error) {
cfg.SSHPort = 22
}
if cfg.SSHPassword == "" {
if secret, err := GetSecret(keySSHPassword); err == nil {
cfg.SSHPassword = secret
slog.Debug("Loaded SSH password from keyring.")
}
}
if cfg.RDPPassword == "" {
if secret, err := GetSecret(keyRDPPassword); err == nil {
cfg.RDPPassword = secret
slog.Debug("Loaded RDP password from keyring.")
}
}
return cfg, nil
}
func GetSecret(key string) (string, error) {
return keyring.Get(serviceName, key)
}
func SetSecret(key, value string) error {
if value == "" {
return fmt.Errorf("secret cannot be empty")
}
return keyring.Set(serviceName, key, value)
}
func KeySSHPassword() string { return keySSHPassword }
func KeyRDPPassword() string { return keyRDPPassword }

View file

@ -1,7 +1,6 @@
package store
package main
import (
"context"
"fmt"
"log/slog"
"sort"
@ -30,63 +29,6 @@ type ExcelEntry struct {
Tag string
}
func (s *Store) ExportSummary(ctx context.Context, filename string) error {
slog.Info(fmt.Sprintf("Starting export to '%s'...", filename))
currentYear := time.Now().Year()
location := time.Local
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
yearEnd := yearStart.AddDate(1, 0, 0)
slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")))
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE start_time < ?
AND (end_time IS NULL OR end_time > ?)
ORDER BY start_time ASC;`
rows, err := s.db.QueryContext(ctx, query, yearEnd, yearStart)
if err != nil {
return fmt.Errorf("failed to query entries for year export: %w", err)
}
defer rows.Close()
var entries []TimeEntry
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return fmt.Errorf("failed to scan entry row: %w", err)
}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error during export row iteration: %w", err)
}
slog.Info(fmt.Sprintf("Found %d potentially relevant time entries.", len(entries)))
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
if err != nil {
return fmt.Errorf("failed to aggregate entries for export: %w", err)
}
excelEntries := convertDailyToExcelEntries(dailySummaries)
if len(excelEntries) == 0 {
slog.Warn("No daily summaries generated for the export period.")
fmt.Println("No data available to generate the export for the specified period.")
return nil
}
if err := writeExcelSheet(excelEntries, filename); err != nil {
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
}
slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename))
fmt.Printf("Successfully exported timetable to %s\n", filename)
return nil
}
func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
dailyMap := make(map[string]*DailySummary)
location := yearStart.Location()
@ -113,13 +55,14 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
for _, entry := range entries {
if entry.StartTime.IsZero() {
slog.Warn("Skipping entry with zero start time", "ID", entry.ID)
slog.Warn(fmt.Sprintf("Skipping entry with zero start time (ID: %d)", entry.ID))
continue
}
startTime := entry.StartTime.In(location)
endTime := entry.EndTime.Time.In(location)
if !entry.EndTime.Valid {
validEndTime := entry.EndTime.Valid
if !validEndTime {
endTime = now
}
@ -156,6 +99,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary, exists := dailyMap[dayStr]
if !exists {
slog.Warn(fmt.Sprintf("Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID))
loopTime = dayEnd
continue
}
@ -188,6 +132,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
summary.WorkEnd = entryEndTimeOnThisDayStr
}
if summary.Tag == "" || summary.Tag == "free" {
summary.Tag = TagWork
}
@ -195,6 +140,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
case TagBreak:
summary.BreakDuration += segmentDuration
default:
slog.Info(fmt.Sprintf("Encountered unknown tag '%s' during interval processing for entry ID %d on %s. Counting duration as 'work'.", entry.Tag, entry.ID, dayStr))
summary.WorkDuration += segmentDuration
if summary.WorkStart == "" || timeStr < summary.WorkStart {
summary.WorkStart = timeStr
@ -211,6 +157,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary.Tag = TagWork
}
}
loopTime = dayEnd
}
}
@ -283,6 +230,23 @@ func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []Excel
return excelEntries
}
func formatDuration(d time.Duration) string {
if d < 0 {
d = -d
sign := "-"
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
}
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
func getSollExcelTime(dayOfWeek string) any {
var sollString string
switch dayOfWeek {
@ -306,7 +270,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
slog.Error("Failed to close excel file handle", "error", err)
slog.Error(fmt.Sprintf("Failed to close excel file handle: %v", err))
}
}()
@ -324,7 +288,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
sheetName = "Sheet1"
index, _ = f.GetSheetIndex(sheetName)
if index == -1 {
return fmt.Errorf("could not create sheet '%s': %w", sheetName, err)
return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err)
}
} else {
index = existingIndex
@ -363,10 +327,6 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "N4", "Total")
f.SetCellValue(sheetName, "O4", "")
toExcelTime := func(t time.Time) float64 {
return float64(t.Hour())/24.0 + float64(t.Minute())/(24.0*60.0) + float64(t.Second())/(24.0*60.0*60.0)
}
timeStyleCode := "hh:mm"
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
dateStyleCode := "dd.mm.yyyy"
@ -410,8 +370,8 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
if entry.WorkStart != "" && entry.WorkEnd != "" {
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
startExcelTime := toExcelTime(startTime)
endExcelTime := toExcelTime(endTime)
startExcelTime := float64(startTime.Hour())/24.0 + float64(startTime.Minute())/(24.0*60.0) + float64(startTime.Second())/(24.0*60.0*60.0)
endExcelTime := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0) + float64(endTime.Second())/(24.0*60.0*60.0)
if endExcelTime < startExcelTime {
endExcelTime += 1.0
}
@ -422,10 +382,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle)
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle) // Saldo-Style für Dauer
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
breakExcelTime := toExcelTime(breakDur)
breakExcelTime := float64(breakDur.Hour())/24.0 + float64(breakDur.Minute())/(24.0*60.0) + float64(breakDur.Second())/(24.0*60.0*60.0)
thirtyMinBreak := float64(30) / (24 * 60)
if breakExcelTime < thirtyMinBreak {
breakExcelTime = thirtyMinBreak

108
forwarder.go Normal file
View file

@ -0,0 +1,108 @@
package main
import (
"fmt"
"io"
"log/slog"
"net"
"sync"
"golang.org/x/crypto/ssh"
)
type PortForwarder struct {
sshCon *ssh.Client
localPort string
remotePort string
remoteHost string
logMutex sync.Mutex
}
func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortForwarder {
return &PortForwarder{
sshCon: sshCon,
localPort: localPort,
remotePort: remotePort,
remoteHost: remoteHost,
}
}
func (pf *PortForwarder) forward() error {
localAddr := "127.0.0.1:" + pf.localPort
remoteAddr := net.JoinHostPort(pf.remoteHost, pf.remotePort)
pf.logf("INFO", "Starting port forwarder: local -> remote (via SSH)", "Local Address", localAddr, "Remote Address", remoteAddr)
listener, err := net.Listen("tcp", localAddr)
if err != nil {
pf.logf("ERROR", "Failed to open local listener on %s: %v", localAddr, err)
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
}
defer listener.Close()
pf.logf("INFO", "Listener active", "Local Address", localAddr)
for {
localConn, err := listener.Accept()
if err != nil {
if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" {
pf.logf("INFO", "Listener closed, stopping forwarder.", "Local Address", localAddr)
return nil
}
pf.logf("ERROR", "Failed to accept incoming connection:", "Local Address", localAddr, "Error", err)
continue
}
pf.logf("INFO", "Accepted connection:", "Remote Address", localConn.RemoteAddr(), "Local Address", localAddr)
go pf.handleConnection(localConn, remoteAddr)
}
}
func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) {
defer localConn.Close()
pf.logf("INFO", "Dialing remote host via SSH tunnel", "Local Address", remoteAddr, "Remote Address", localConn.RemoteAddr())
remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr)
if err != nil {
pf.logf("ERROR", "Failed to dial remote host via SSH:", "Remote Address", remoteAddr, "Error:", err)
return
}
defer remoteConn.Close()
pf.logf("INFO", "Connection established. Starting data copy.", "Remote Address", remoteAddr)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
defer localConn.Close()
bytesCopied, err := io.Copy(localConn, remoteConn)
if err != nil {
}
pf.logf("INFO", "Finished copying remote->local", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr())
}()
go func() {
defer wg.Done()
defer remoteConn.Close()
bytesCopied, err := io.Copy(remoteConn, localConn)
if err != nil {
}
pf.logf("INFO", "Finished copying local->remote", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr())
}()
wg.Wait()
pf.logf("INFO", "Closing forwarded connection", "Remote Address", localConn.RemoteAddr())
}
func (pf *PortForwarder) logf(level, format string, v ...any) {
pf.logMutex.Lock()
defer pf.logMutex.Unlock()
switch level {
case "INFO":
slog.Info(format, v...)
case "WARN":
slog.Warn(format, v...)
case "ERROR":
slog.Error(format, v...)
}
}

83
go.mod
View file

@ -3,73 +3,62 @@ module workctl
go 1.24.2
require (
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/huh v0.6.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.46.0
golang.org/x/text v0.33.0
modernc.org/sqlite v1.43.0
github.com/spf13/viper v1.20.1
github.com/xuri/excelize/v2 v2.9.1
golang.org/x/crypto v0.38.0
golang.org/x/text v0.25.0
modernc.org/sqlite v1.37.0
)
require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.0 // indirect
github.com/charmbracelet/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.5 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/tiendc/go-deepcopy v1.6.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
github.com/xuri/nfp v0.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/memory v1.9.1 // indirect
)

95
go.sum
View file

@ -1,5 +1,3 @@
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@ -8,49 +6,21 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/exp/strings v0.0.0-20260109001716-2fbdffcb221f h1:c0cKImYFPrOEEzMsYss56Q7Q69HD7H4ss3Yu9Mw9vqQ=
github.com/charmbracelet/x/exp/strings v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -62,14 +32,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@ -84,16 +48,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@ -102,24 +62,16 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/mscfb v1.0.5 h1:OoQkDV2Bf2bIoSacCfJhSwm7BJN05fYFkwFUpxExtdY=
github.com/richardlehane/mscfb v1.0.5/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
@ -131,32 +83,22 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@ -165,64 +107,37 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -232,32 +147,24 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

18
helpers.go Normal file
View file

@ -0,0 +1,18 @@
package main
import (
"log/slog"
"os"
"os/exec"
)
func runCommand(name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()
if err != nil {
slog.Error("Command execution error", "command", name, "args", args, "error", err)
}
}

View file

@ -1,55 +0,0 @@
package ssh
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
type Connection struct {
Client *ssh.Client
}
func NewConnection(user, host string, port int, auth ssh.AuthMethod) (*Connection, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home dir: %w", err)
}
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
hkCallback, err := knownhosts.New(knownHostsPath)
if err != nil {
slog.Warn("Could not load known_hosts, ensure you connected manually once.", "path", knownHostsPath)
return nil, fmt.Errorf("known_hosts error: %w", err)
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{auth},
HostKeyCallback: hkCallback,
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", host, port)
slog.Debug("Dialing SSH", "target", addr)
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return nil, fmt.Errorf("ssh dial failed: %w", err)
}
slog.Debug("SSH connection established", "target", addr)
return &Connection{Client: client}, nil
}
func (c *Connection) Close() error {
if c.Client != nil {
return c.Client.Close()
}
return nil
}

View file

@ -1,107 +0,0 @@
package ssh
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
type Forwarder struct {
sshClient *ssh.Client
localPort string
remotePort string
remoteHost string
}
func NewForwarder(client *ssh.Client, localPort, remotePort, remoteHost string) *Forwarder {
return &Forwarder{
sshClient: client,
localPort: localPort,
remotePort: remotePort,
remoteHost: remoteHost,
}
}
func (f *Forwarder) Start(ctx context.Context, ready chan<- struct{}) error {
localAddr := "127.0.0.1:" + f.localPort
remoteAddr := net.JoinHostPort(f.remoteHost, f.remotePort)
listener, err := net.Listen("tcp", localAddr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
}
if ready != nil {
close(ready)
}
go func() {
<-ctx.Done()
listener.Close()
}()
slog.Info("Port forwarder active", "local", localAddr, "remote", remoteAddr)
for {
localConn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
}
slog.Error("Accept failed", "error", err)
time.Sleep(100 * time.Millisecond)
continue
}
go f.handleConnection(ctx, localConn, remoteAddr)
}
}
func (f *Forwarder) handleConnection(ctx context.Context, localConn net.Conn, remoteAddr string) {
defer localConn.Close()
_, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
remoteConn, err := f.sshClient.Dial("tcp", remoteAddr)
if err != nil {
slog.Error("Failed to dial remote via SSH", "target", remoteAddr, "error", err)
return
}
defer remoteConn.Close()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(localConn, remoteConn)
// localConn.SetWriteDeadline(time.Now())
localConn.Close()
}()
go func() {
defer wg.Done()
_, _ = io.Copy(remoteConn, localConn)
remoteConn.Close()
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}
}

31
main.go
View file

@ -1,50 +1,43 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"
)
func main() {
configDir, err := os.UserConfigDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting config dir: %v\n", err)
os.Exit(1)
slog.Error("Cant get user config dir")
panic(err)
}
logFile := filepath.Join(configDir, "work", "workctl.log")
_ = os.MkdirAll(filepath.Dir(logFile), 0750)
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
file, err := os.OpenFile(filepath.Join(configDir, "work", "workctl.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err)
os.Exit(1)
panic(err)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, nil))
slog.SetDefault(logger)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
app, err := NewApp()
if err != nil {
slog.Error("Unable to setup application", "error", err)
fmt.Fprintf(os.Stderr, "Error setting up application: %v\n", err)
slog.Error("Unable to setup application", "Error", err)
os.Exit(1)
}
defer func() {
if err := app.Close(); err != nil {
slog.Error("Failed to close application resources", "error", err)
slog.Error("Failed to close application resources", "Error", err)
}
}()
if err := app.Execute(ctx); err != nil {
os.Exit(1)
if len(os.Args) > 1 {
if err := app.setupCommands().Execute(); err != nil {
os.Exit(1)
}
} else {
app.makeChoice()
}
}

View file

@ -1,28 +0,0 @@
package main
import (
"fmt"
"github.com/zalando/go-keyring"
)
const (
serviceName = "workctl"
keySSHPassword = "ssh-password"
keyRDPPassword = "rdp-password"
)
func getSecret(key string) (string, error) {
val, err := keyring.Get(serviceName, key)
if err != nil {
return "", err
}
return val, nil
}
func setSecret(key, value string) error {
if value == "" {
return fmt.Errorf("secret cannot be empty")
}
return keyring.Set(serviceName, key, value)
}

19
ssh.go Normal file
View file

@ -0,0 +1,19 @@
package main
import (
"log/slog"
"golang.org/x/crypto/ssh"
)
type SSHConnection struct {
client *ssh.Client
}
func (s *SSHConnection) Close() error {
if s.client != nil {
slog.Debug("Closing SSH client connection.")
return s.client.Close()
}
return nil
}

View file

@ -1,7 +1,6 @@
package store
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
@ -27,18 +26,18 @@ type TimeEntry struct {
EndTime sql.NullTime
}
type Store struct {
type TimeStore struct {
db *sql.DB
dbPath string
}
func NewStore() (*Store, error) {
dbPath, err := ensureDatabasePath()
func NewTimeStore(cfg Config) (*TimeStore, error) {
dbPath, err := ensureDatabasePath(cfg)
if err != nil {
return nil, fmt.Errorf("could not determine database path: %w", err)
}
slog.Debug("Using database at:", "path", dbPath)
slog.Info("Using database at:", "Database Path", dbPath)
db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath))
if err != nil {
@ -50,97 +49,105 @@ func NewStore() (*Store, error) {
return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err)
}
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("migration failed: %w", err)
}
return &Store{db: db, dbPath: dbPath}, nil
}
func migrate(db *sql.DB) error {
createTableSQL := `
CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT NOT NULL CHECK(tag <> ''),
tag TEXT NOT NULL CHECK(tag <> ''), -- Stelle sicher, dass Tag nicht leer ist
start_time DATETIME NOT NULL,
end_time DATETIME NULL,
CHECK (end_time IS NULL OR end_time >= start_time)
-- Optional: Stelle sicher, dass nur ein Eintrag NULL end_time haben kann (falls DB unterstützt)
-- UNIQUE (end_time) WHERE end_time IS NULL -- SQLite unterstützt dies nicht direkt
CHECK (end_time IS NULL OR end_time >= start_time) -- Endzeit muss nach Startzeit liegen
);`
if _, err := db.Exec(createTableSQL); err != nil {
return fmt.Errorf("failed to create table 'time_entries': %w", err)
if _, err = db.Exec(createTableSQL); err != nil {
db.Close()
return nil, fmt.Errorf("failed to create table 'time_entries': %w", err)
}
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);`
if _, err := db.Exec(createIndexSQL); err != nil {
slog.Warn("Failed to create index on start_time:", "error", err)
if _, err = db.Exec(createIndexSQL); err != nil {
slog.Warn("Failed to create index on start_time:", "Error:", err)
}
return nil
return &TimeStore{db: db, dbPath: dbPath}, nil
}
func ensureDatabasePath() (string, error) {
func ensureDatabasePath(_ Config) (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("could not get user config dir: %w", err)
}
workConfigDir := filepath.Join(configDir, "work")
dbPath := filepath.Join(workConfigDir, "worktime.sqlite")
if err := os.MkdirAll(workConfigDir, 0o750); err != nil {
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
}
return filepath.Join(workConfigDir, "worktime.sqlite"), nil
return dbPath, nil
}
func (s *Store) Close() error {
if s.db != nil {
slog.Debug("Closing database connection", "path", s.dbPath)
return s.db.Close()
func (ts *TimeStore) Close() error {
if ts.db != nil {
slog.Info("Closing database connection", "Database Path", ts.dbPath)
return ts.db.Close()
}
return nil
}
func (s *Store) stopCurrentEntry(ctx context.Context, now time.Time) (bool, error) {
func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) {
query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;`
result, err := s.db.ExecContext(ctx, query, now)
result, err := ts.db.Exec(query, now)
if err != nil {
return false, fmt.Errorf("failed to execute stop current entry query: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("failed to get affected rows: %w", err)
return false, fmt.Errorf("failed to get affected rows after stopping entry: %w", err)
}
if rowsAffected > 1 {
slog.Warn(fmt.Sprintf("Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected))
}
return rowsAffected > 0, nil
}
func (s *Store) StartTracking(ctx context.Context, tag string) error {
func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error {
if tag == "" {
return fmt.Errorf("cannot start tracking with an empty tag")
}
now := time.Now()
stopped, err := s.stopCurrentEntry(ctx, now)
stopped, err := ts.stopCurrentEntry(now)
if err != nil {
return err
}
if stopped {
slog.Info("Stopped previous time entry.")
}
if !withoutTimew {
runCommand("timew", "start", "work")
}
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
_, err = s.db.ExecContext(ctx, query, tag, now)
_, err = ts.db.Exec(query, tag, now)
if err != nil {
return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err)
}
slog.Info(fmt.Sprintf("Started tracking: %s", tag))
slog.Info(fmt.Sprintf("Started tracking: %s at %s", tag, now.Format(time.RFC3339)))
return nil
}
func (s *Store) StopTracking(ctx context.Context) error {
func (ts *TimeStore) StopTracking(withoutTimew bool) error {
now := time.Now()
stopped, err := s.stopCurrentEntry(ctx, now)
stopped, err := ts.stopCurrentEntry(now)
if err != nil {
return err
}
if !withoutTimew {
runCommand("timew", "stop", "work")
}
if stopped {
slog.Info(fmt.Sprintf("Stopped tracking at %s", now.Format(time.RFC3339)))
} else {
@ -149,42 +156,7 @@ func (s *Store) StopTracking(ctx context.Context) error {
return nil
}
func (s *Store) LogFullDay(ctx context.Context, tag string, date time.Time) error {
if tag == "" {
return fmt.Errorf("cannot log full day with an empty tag")
}
tag = strings.ToLower(tag)
location := date.Location()
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour)
_, err := s.stopCurrentEntry(ctx, dayStart)
if err != nil {
slog.Warn("Failed to stop current entry before logging full day", "error", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
if _, err := tx.ExecContext(ctx, query, tag, dayStart, dayEnd); err != nil {
return fmt.Errorf("failed to insert full-day entry: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
titleCaser := cases.Title(language.English)
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Date='%s'", tag, dayStart.Format("2006-01-02")))
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStart.Format("2006-01-02"))
return nil
}
func (s *Store) GetEntriesInRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) {
if start.IsZero() || end.IsZero() || end.Before(start) {
return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end)
}
@ -195,7 +167,7 @@ func (s *Store) GetEntriesInRange(ctx context.Context, start, end time.Time) ([]
WHERE start_time >= ? AND start_time < ?
ORDER BY start_time ASC;`
rows, err := s.db.QueryContext(ctx, query, start, end)
rows, err := ts.db.Query(query, start, end)
if err != nil {
return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err)
}
@ -217,8 +189,8 @@ func (s *Store) GetEntriesInRange(ctx context.Context, start, end time.Time) ([]
return entries, nil
}
func (s *Store) CalculateSummary(ctx context.Context, period string) (map[string]time.Duration, error) {
start, end := GetTimeRangeFromPeriod(period)
func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) {
start, end := getTimeRangeFromPeriod(period)
if start.IsZero() {
return nil, fmt.Errorf("invalid period string: '%s'", period)
}
@ -226,13 +198,13 @@ func (s *Store) CalculateSummary(ctx context.Context, period string) (map[string
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE (end_time IS NULL OR end_time > ?)
AND start_time < ?
WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums
AND start_time < ? -- Beginnt vor dem Ende des Zeitraums
ORDER BY start_time ASC;`
rows, err := s.db.QueryContext(ctx, query, start, end)
rows, err := ts.db.Query(query, start, end)
if err != nil {
return nil, fmt.Errorf("failed to query entries: %w", err)
return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err)
}
defer rows.Close()
@ -242,38 +214,86 @@ func (s *Store) CalculateSummary(ctx context.Context, period string) (map[string
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return nil, fmt.Errorf("failed to scan entry: %w", err)
return nil, fmt.Errorf("failed to scan overlapping entry row: %w", err)
}
effStart := entry.StartTime
if effStart.Before(start) {
effStart = start
}
effEnd := now
if entry.EndTime.Valid {
effEnd = entry.EndTime.Time
}
if effEnd.After(end) {
effEnd = end
effectiveStart := entry.StartTime
if effectiveStart.Before(start) {
effectiveStart = start
}
if effEnd.After(effStart) {
summary[entry.Tag] += effEnd.Sub(effStart)
effectiveEnd := entry.EndTime.Time
if !entry.EndTime.Valid {
effectiveEnd = now
}
if effectiveEnd.After(end) {
effectiveEnd = end
}
if effectiveEnd.After(effectiveStart) {
duration := effectiveEnd.Sub(effectiveStart)
summary[entry.Tag] += duration
}
}
return summary, rows.Err()
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error during overlapping row iteration: %w", err)
}
return summary, nil
}
func (s *Store) ShowSummary(ctx context.Context, period string) error {
summary, err := s.CalculateSummary(ctx, period)
func getTimeRangeFromPeriod(period string) (time.Time, time.Time) {
now := time.Now()
year, month, day := now.Date()
loc := now.Location()
normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
switch normalizedPeriod {
case "week":
weekday := now.Weekday()
daysToMonday := time.Duration(weekday - time.Monday)
if weekday == time.Sunday {
daysToMonday = 6
}
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
end := start.Add(7 * 24 * time.Hour)
return start, end
case "month":
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end
case "year":
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
end := start.AddDate(1, 0, 0)
return start, end
case "day", "today":
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
default:
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
}
slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
return time.Time{}, time.Time{}
}
}
func (ts *TimeStore) ShowSummary(period string) error {
summary, err := ts.CalculateSummary(period)
if err != nil {
return err
return fmt.Errorf("error calculating summary for '%s': %w", period, err)
}
start, _ := GetTimeRangeFromPeriod(period)
start, _ := getTimeRangeFromPeriod(period)
titlePeriod := period
if !start.IsZero() {
_, end := GetTimeRangeFromPeriod(period)
_, end := getTimeRangeFromPeriod(period)
if period == ":day" || period == "today" {
titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02"))
} else if period == ":week" {
@ -314,63 +334,109 @@ func (s *Store) ShowSummary(ctx context.Context, period string) error {
return nil
}
func formatDuration(d time.Duration) string {
if d < 0 {
d = -d
sign := "-"
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
func (ts *TimeStore) ExportSummary(filename string) error {
slog.Info(fmt.Sprintf("Starting export to '%s'...", filename))
currentYear := time.Now().Year()
location := time.Local
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
yearEnd := yearStart.AddDate(1, 0, 0)
slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")))
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE start_time < ? -- Beginnt vor Anfang des nächsten Jahres
AND (end_time IS NULL OR end_time > ?) -- Endet nach Anfang des Jahres
ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, yearEnd, yearStart)
if err != nil {
return fmt.Errorf("failed to query entries for year export [%v, %v): %w", yearStart, yearEnd, err)
}
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
defer rows.Close()
func GetTimeRangeFromPeriod(period string) (time.Time, time.Time) {
now := time.Now()
year, month, day := now.Date()
loc := now.Location()
normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
switch normalizedPeriod {
case "week":
weekday := now.Weekday()
daysToMonday := time.Duration(weekday - time.Monday)
if weekday == time.Sunday {
daysToMonday = 6
var entries []TimeEntry
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return fmt.Errorf("failed to scan entry row (ID: %d) for export: %w", entry.ID, err)
}
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
end := start.Add(7 * 24 * time.Hour)
return start, end
case "month":
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end
case "year":
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
end := start.AddDate(1, 0, 0)
return start, end
case "day", "today":
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
default:
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
}
slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
return time.Time{}, time.Time{}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error during export row iteration: %w", err)
}
slog.Info(fmt.Sprintf("Found %d potentially relevant time entries for year %d.", len(entries), currentYear))
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
if err != nil {
return fmt.Errorf("failed to aggregate entries for export: %w", err)
}
excelEntries := convertDailyToExcelEntries(dailySummaries)
if len(excelEntries) == 0 {
slog.Warn("No daily summaries generated for the export period.")
fmt.Println("No data available to generate the export for the specified period.")
return nil
}
slog.Info(fmt.Sprintf("Generated %d daily entries for the Excel export.", len(excelEntries)))
if err := writeExcelSheet(excelEntries, filename); err != nil {
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
}
slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename))
fmt.Printf("Successfully exported timetable to %s\n", filename)
return nil
}
func (s *Store) DB() *sql.DB {
return s.db
func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
if tag == "" {
return fmt.Errorf("cannot log full day with an empty tag")
}
tag = strings.ToLower(tag)
location := date.Location()
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour)
dayStr := dayStart.Format("2006-01-02")
slog.Info(fmt.Sprintf("Attempting to log '%s' for the full day %s", tag, dayStr))
stopped, err := ts.stopCurrentEntry(dayStart)
if err != nil {
slog.Warn(fmt.Sprintf("Failed to stop current entry before logging full day '%s': %v", tag, err))
} else if stopped {
slog.Info(fmt.Sprintf("Stopped active timer before logging '%s' for %s.", tag, dayStr))
}
tx, err := ts.db.Begin()
if err != nil {
return fmt.Errorf("could not begin transaction to log full day: %w", err)
}
defer tx.Rollback()
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
stmt, err := tx.Prepare(query)
if err != nil {
return fmt.Errorf("could not prepare statement to log full day: %w", err)
}
defer stmt.Close()
_, err = stmt.Exec(tag, dayStart, dayEnd)
if err != nil {
return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction for full-day entry: %w", err)
}
titleCaser := cases.Title(language.English)
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)))
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr)
return nil
}