From f9945a268545bda2c2906221baee4c4aef5894ed Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 15:18:18 +0200 Subject: [PATCH 01/17] fix: fix wrong parameter in upload task --- .forgejo/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3184675..5f64d36 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -85,7 +85,7 @@ jobs: env: GITEA_TOKEN: ${{ secrets.GITEATOKEN }} with: - url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./artifacts/workctl-linux/workctl-linux asset_name: workctl-linux asset_content_type: application/octet-stream @@ -95,7 +95,7 @@ jobs: env: GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ with: - url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./artifacts/workctl-darwin/workctl-darwin asset_name: workctl-darwin asset_content_type: application/octet-stream @@ -105,7 +105,7 @@ jobs: env: GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ with: - url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./artifacts/workctl-windows/workctl-windows.exe asset_name: workctl-windows.exe asset_content_type: application/octet-stream From deb15af40ea2d5a5e3f17ceb5dc6be73425d625a Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 15:47:00 +0200 Subject: [PATCH 02/17] fix: try a different approuch --- .forgejo/workflows/release.yml | 207 +++++++++++++++++++-------------- 1 file changed, 117 insertions(+), 90 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 5f64d36..2cf585a 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -7,105 +7,132 @@ on: jobs: build: + name: GoReleaser build runs-on: ubuntu-22.04 - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - goos: [linux, darwin, windows] - exclude: - - os: ubuntu-latest - goos: darwin - - os: ubuntu-latest - goos: windows - - os: macos-latest - goos: linux - - os: macos-latest - goos: windows - - os: windows-latest - goos: linux - - os: windows-latest - goos: darwin steps: - - name: Check out repository - uses: actions/checkout@v4 + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + with: + fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' + - name: Set up Go 1.24 + uses: actions/setup-go@v2 + with: + go-version: 1.24 + id: go - - name: Get dependencies - run: go mod tidy + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@master + with: + version: latest + args: release --rm-dist + env: + GITEA_TOKEN: ${{ secrets.GITEATOKEN }} - - name: Build for ${{ matrix.goos }} - run: | - BUILD_NAME="workctl-${{ matrix.goos }}" - if [ "${{ matrix.goos }}" = "windows" ]; then - BUILD_NAME="${BUILD_NAME}.exe" - fi - GOOS=${{ matrix.goos }} GOARCH=amd64 go build -o ${BUILD_NAME} . - ls -la +# jobs: +# build: +# runs-on: ubuntu-22.04 +# strategy: +# matrix: +# os: [ubuntu-latest, macos-latest, windows-latest] +# goos: [linux, darwin, windows] +# exclude: +# - os: ubuntu-latest +# goos: darwin +# - os: ubuntu-latest +# goos: windows +# - os: macos-latest +# goos: linux +# - os: macos-latest +# goos: windows +# - os: windows-latest +# goos: linux +# - os: windows-latest +# goos: darwin + +# steps: +# - name: Check out repository +# uses: actions/checkout@v4 + +# - name: Set up Go +# uses: actions/setup-go@v5 +# with: +# go-version: '1.24' + +# - name: Get dependencies +# run: go mod tidy + +# - name: Build for ${{ matrix.goos }} +# run: | +# BUILD_NAME="workctl-${{ matrix.goos }}" +# if [ "${{ matrix.goos }}" = "windows" ]; then +# BUILD_NAME="${BUILD_NAME}.exe" +# fi +# GOOS=${{ matrix.goos }} GOARCH=amd64 go build -o ${BUILD_NAME} . +# ls -la - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: workctl-${{ matrix.goos }} - path: workctl-${{ matrix.goos }}${{ matrix.goos == 'windows' && '.exe' || '' }} - retention-days: 7 +# - name: Upload artifact +# uses: actions/upload-artifact@v3 +# with: +# name: workctl-${{ matrix.goos }} +# path: workctl-${{ matrix.goos }}${{ matrix.goos == 'windows' && '.exe' || '' }} +# retention-days: 7 - create_release: - needs: build - runs-on: ubuntu-22.04 - steps: - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - # Dies ist der Token, der Forgejo erlaubt, das Release zu erstellen - # Du musst einen Gitea/Forgejo API-Token als Repository Secret (z.B. GITEA_TOKEN) hinterlegen. - # Der Token benötigt die Berechtigung "write:releases" oder "repo". - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} - with: - url: https://git.patanix.de - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - body: | - Dies ist ein automatisches Release von ${{ github.ref }}. - draft: false - prerelease: false +# create_release: +# needs: build +# runs-on: ubuntu-22.04 +# steps: +# - name: Create Release +# id: create_release +# uses: actions/create-release@v1 +# env: +# # Dies ist der Token, der Forgejo erlaubt, das Release zu erstellen +# # Du musst einen Gitea/Forgejo API-Token als Repository Secret (z.B. GITEA_TOKEN) hinterlegen. +# # Der Token benötigt die Berechtigung "write:releases" oder "repo". +# GITEA_TOKEN: ${{ secrets.GITEATOKEN }} +# with: +# url: https://git.patanix.de +# tag_name: ${{ github.ref }} +# release_name: Release ${{ github.ref }} +# body: | +# Dies ist ein automatisches Release von ${{ github.ref }}. +# draft: false +# prerelease: false - - name: Download all workflow artifacts - uses: actions/download-artifact@v3 - with: - path: ./artifacts +# - name: Download all workflow artifacts +# uses: actions/download-artifact@v3 +# with: +# path: ./artifacts - - name: Upload Release Asset (Linux) - uses: actions/upload-release-asset@v1 - env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./artifacts/workctl-linux/workctl-linux - asset_name: workctl-linux - asset_content_type: application/octet-stream +# - name: Upload Release Asset (Linux) +# uses: actions/upload-release-asset@v4 +# env: +# GITEA_TOKEN: ${{ secrets.GITEATOKEN }} +# with: +# name: workctl-linux +# path: ./artifacts/workctl-linux/workctl-linux +# upload_url: ${{ steps.create_release.outputs.upload_url }} +# asset_path: ./artifacts/workctl-linux/workctl-linux +# asset_name: workctl-linux +# asset_content_type: application/octet-stream - - name: Upload Release Asset (macOS) - uses: actions/upload-release-asset@v1 - env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./artifacts/workctl-darwin/workctl-darwin - asset_name: workctl-darwin - asset_content_type: application/octet-stream +# - name: Upload Release Asset (macOS) +# uses: actions/upload-release-asset@v1 +# env: +# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ +# with: +# upload_url: ${{ steps.create_release.outputs.upload_url }} +# asset_path: ./artifacts/workctl-darwin/workctl-darwin +# asset_name: workctl-darwin +# asset_content_type: application/octet-stream - - name: Upload Release Asset (Windows) - uses: actions/upload-release-asset@v1 - env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./artifacts/workctl-windows/workctl-windows.exe - asset_name: workctl-windows.exe - asset_content_type: application/octet-stream +# - name: Upload Release Asset (Windows) +# uses: actions/upload-release-asset@v1 +# env: +# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ +# with: +# upload_url: ${{ steps.create_release.outputs.upload_url }} +# asset_path: ./artifacts/workctl-windows/workctl-windows.exe +# asset_name: workctl-windows.exe +# asset_content_type: application/octet-stream From ea906ca86218287762e768250727b91f2928cd10 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 15:50:22 +0200 Subject: [PATCH 03/17] fix: wrong field --- .forgejo/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 2cf585a..65ebfc1 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -26,7 +26,7 @@ jobs: uses: goreleaser/goreleaser-action@master with: version: latest - args: release --rm-dist + args: release --clean env: GITEA_TOKEN: ${{ secrets.GITEATOKEN }} From 9a67429b4581f05febbf2fe3be7c53ad725aa095 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 15:56:56 +0200 Subject: [PATCH 04/17] fix: and another try --- .forgejo/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 65ebfc1..3aed6bb 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -22,6 +22,9 @@ jobs: go-version: 1.24 id: go + - name: Unset GITHUB_TOKEN (if present) + run: unset GITHUB_TOKEN + - name: Run GoReleaser uses: goreleaser/goreleaser-action@master with: From bb75925d1f8fe0677a1d4f924ff3aba578e7bf6c Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 16:02:08 +0200 Subject: [PATCH 05/17] fix: next one --- .gitignore | 2 ++ .goreleaser.yaml | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .goreleaser.yaml diff --git a/.gitignore b/.gitignore index 3c58a5b..b0da51a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ work-config.toml +# Added by goreleaser init: +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7dc96a7 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,54 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + - go mod tidy + - go generate ./... + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin +env_files: + gitlab_token: ~/nope + github_token: ~/nope + +force_token: "gitea" + +archives: + - formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + formats: [zip] + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +release: + footer: >- + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). From ac943acae2f8dc8756ddedbe3a0e00b574cc9143 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 16:16:16 +0200 Subject: [PATCH 06/17] fix: next --- .goreleaser.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7dc96a7..53c43b4 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,6 +20,10 @@ builds: - linux - windows - darwin + goarch: + - amd64 + - arm64 + env_files: gitlab_token: ~/nope github_token: ~/nope From 23e3d4919f152fb1b4ece3ae10323cab2a51496a Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 16:22:38 +0200 Subject: [PATCH 07/17] fix: next --- .goreleaser.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 53c43b4..cb15f1a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -24,6 +24,10 @@ builds: - amd64 - arm64 +gitea_urls: + api: https://git.patanix.de/api/v1 + download: https://git.patanix.de + env_files: gitlab_token: ~/nope github_token: ~/nope From fcffccc1451178b3ae5672cc84caa376a15dc039 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 4 Jun 2025 20:08:49 +0200 Subject: [PATCH 08/17] ci: update goreleaser files --- .forgejo/workflows/release.yml | 109 +-------------------------------- .goreleaser.yaml | 9 +-- 2 files changed, 3 insertions(+), 115 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3aed6bb..8f51234 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -31,111 +31,4 @@ jobs: version: latest args: release --clean env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} - -# jobs: -# build: -# runs-on: ubuntu-22.04 -# strategy: -# matrix: -# os: [ubuntu-latest, macos-latest, windows-latest] -# goos: [linux, darwin, windows] -# exclude: -# - os: ubuntu-latest -# goos: darwin -# - os: ubuntu-latest -# goos: windows -# - os: macos-latest -# goos: linux -# - os: macos-latest -# goos: windows -# - os: windows-latest -# goos: linux -# - os: windows-latest -# goos: darwin - -# steps: -# - name: Check out repository -# uses: actions/checkout@v4 - -# - name: Set up Go -# uses: actions/setup-go@v5 -# with: -# go-version: '1.24' - -# - name: Get dependencies -# run: go mod tidy - -# - name: Build for ${{ matrix.goos }} -# run: | -# BUILD_NAME="workctl-${{ matrix.goos }}" -# if [ "${{ matrix.goos }}" = "windows" ]; then -# BUILD_NAME="${BUILD_NAME}.exe" -# fi -# GOOS=${{ matrix.goos }} GOARCH=amd64 go build -o ${BUILD_NAME} . -# ls -la - -# - name: Upload artifact -# uses: actions/upload-artifact@v3 -# with: -# name: workctl-${{ matrix.goos }} -# path: workctl-${{ matrix.goos }}${{ matrix.goos == 'windows' && '.exe' || '' }} -# retention-days: 7 - -# create_release: -# needs: build -# runs-on: ubuntu-22.04 -# steps: -# - name: Create Release -# id: create_release -# uses: actions/create-release@v1 -# env: -# # Dies ist der Token, der Forgejo erlaubt, das Release zu erstellen -# # Du musst einen Gitea/Forgejo API-Token als Repository Secret (z.B. GITEA_TOKEN) hinterlegen. -# # Der Token benötigt die Berechtigung "write:releases" oder "repo". -# GITEA_TOKEN: ${{ secrets.GITEATOKEN }} -# with: -# url: https://git.patanix.de -# tag_name: ${{ github.ref }} -# release_name: Release ${{ github.ref }} -# body: | -# Dies ist ein automatisches Release von ${{ github.ref }}. -# draft: false -# prerelease: false - -# - name: Download all workflow artifacts -# uses: actions/download-artifact@v3 -# with: -# path: ./artifacts - -# - name: Upload Release Asset (Linux) -# uses: actions/upload-release-asset@v4 -# env: -# GITEA_TOKEN: ${{ secrets.GITEATOKEN }} -# with: -# name: workctl-linux -# path: ./artifacts/workctl-linux/workctl-linux -# upload_url: ${{ steps.create_release.outputs.upload_url }} -# asset_path: ./artifacts/workctl-linux/workctl-linux -# asset_name: workctl-linux -# asset_content_type: application/octet-stream - -# - name: Upload Release Asset (macOS) -# uses: actions/upload-release-asset@v1 -# env: -# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ -# with: -# upload_url: ${{ steps.create_release.outputs.upload_url }} -# asset_path: ./artifacts/workctl-darwin/workctl-darwin -# asset_name: workctl-darwin -# asset_content_type: application/octet-stream - -# - name: Upload Release Asset (Windows) -# uses: actions/upload-release-asset@v1 -# env: -# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$ -# with: -# upload_url: ${{ steps.create_release.outputs.upload_url }} -# asset_path: ./artifacts/workctl-windows/workctl-windows.exe -# asset_name: workctl-windows.exe -# asset_content_type: application/octet-stream + GITEA_TOKEN: ${{ secrets.GITEATOKEN }} \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index cb15f1a..f8e2923 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,9 +1,3 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com - -# The lines below are called `modelines`. See `:help modeline` -# Feel free to remove those if you don't want/need to use them. -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj version: 2 @@ -55,8 +49,9 @@ changelog: - "^test:" release: + name_template: "{{.ProjectName}}-v{{.Version}} {{.Env.USER}}" footer: >- --- - Released by [GoReleaser](https://github.com/goreleaser/goreleaser). + Released by {{.Env.USER}}. From d8743e54c1b7d69371574c12f2c8d3ff14eff67e Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 11 Jun 2025 22:41:48 +0200 Subject: [PATCH 09/17] refactor: use slog instead of log --- app.go | 101 ++++++++++++++++++++------------------ cmd.go | 134 +++++++++++++++++++++++++-------------------------- config.go | 6 +-- export.go | 13 +++-- forwarder.go | 37 ++++++++------ main.go | 8 +-- ssh.go | 4 +- store.go | 42 ++++++++-------- 8 files changed, 180 insertions(+), 165 deletions(-) diff --git a/app.go b/app.go index 33fc50d..0a3bb3c 100644 --- a/app.go +++ b/app.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "log" + "log/slog" "os" "os/exec" "strings" @@ -44,7 +44,7 @@ func (a *App) Close() error { func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert if err := a.timeStore.StartTracking(TagWork); err != nil { - log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err) + slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err)) } a.wakeWorkstation() @@ -54,24 +54,31 @@ func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err) } - log.Println("INFO: SSH connection established. Setting up tunnels...") + // slog.Info("SSH connection established. Setting up tunnels...") + slog.Info("SSH connection established. Setting up tunnels...") sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) go func() { - log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)") + // slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)") + slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)") if err := sshForwarder.forward(); err != nil { - log.Printf("ERROR: SSH forwarder failed: %v", err) + // slog.Error(fmt.Sprintf("SSH forwarder failed: %v", err) + slog.Error(fmt.Sprintf("SSH forwarder failed: %v", err)) } - log.Println("INFO: SSH forwarder stopped.") + // slog.Info("SSH forwarder stopped.") + slog.Info("SSH forwarder stopped.") }() rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) go func() { - log.Println("INFO: Starting RDP forwarder (local :6000 -> remote workstation:3389)") + // slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)") + slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)") if err := rdpForwarder.forward(); err != nil { - log.Printf("ERROR: RDP forwarder failed: %v", err) + // slog.Error(fmt.Sprintf("RDP forwarder failed: %v", err) + slog.Error(fmt.Sprintf("ERROR: RDP forwarder failed: %v", err)) } - log.Println("INFO: RDP forwarder stopped.") + // slog.Info("RDP forwarder stopped.") + slog.Info("RDP forwarder stopped.") }() time.Sleep(500 * time.Millisecond) @@ -80,22 +87,22 @@ func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert } func (a *App) runCommand(name string, args ...string) error { - log.Printf("INFO: Executing command: %s %s", name, strings.Join(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 err := cmd.Run() if err != nil { - log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err) + slog.Error(fmt.Sprintf("Command failed: %s %s -> %v", name, strings.Join(args, " "), err)) return fmt.Errorf("command execution failed: %w", err) } - log.Printf("INFO: Command finished successfully: %s", name) + slog.Info(fmt.Sprintf("Command finished successfully: %s", name)) return nil } func (a *App) wakeWorkstation() { - log.Println("INFO: Attempting to wake workstation...") + 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, @@ -109,14 +116,14 @@ func (a *App) wakeWorkstation() { } if err := a.runCommand("ssh", outerSSHCmd...); err != nil { - log.Println("WARN: Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.") + slog.Warn("Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.") } else { - log.Println("INFO: Wake-on-LAN command executed.") + slog.Info("Wake-on-LAN command executed.") } } func (a *App) connectToJump() { - log.Println("INFO: Connecting to Jump Host with Port Forwarding...") + slog.Info("Connecting to Jump Host with Port Forwarding...") sshArgs := []string{ "-tt", "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), @@ -128,7 +135,7 @@ func (a *App) connectToJump() { } func (a *App) connectToWorkstation() { - log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...") + slog.Info("Connecting to Workstation via local tunnel (localhost:2048)...") sshArgs := []string{ "-tt", "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), @@ -140,7 +147,7 @@ func (a *App) connectToWorkstation() { } func (a *App) startRDPConnection() { - log.Println("INFO: Starting RDP connection to localhost:6000...") + 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, @@ -182,7 +189,7 @@ func (a *App) makeChoice() { fmt.Println("Operation cancelled.") return } - log.Printf("ERROR: Form execution failed: %v", err) + slog.Error(fmt.Sprintf("Form execution failed: %v", err)) return } @@ -191,30 +198,30 @@ func (a *App) makeChoice() { a.connect() case "stop work": if err := a.timeStore.StopTracking(); err != nil { - log.Printf("ERROR: Failed to stop time tracking: %v", err) + slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) } if err := a.killForwardings(); err != nil { - log.Printf("WARN: Could not kill all forwardings: %v", err) + slog.Warn(fmt.Sprintf("Could not kill all forwardings: %v", err)) } case "start break": if err := a.timeStore.StartTracking(TagBreak); err != nil { - log.Printf("ERROR: Failed to start break tracking: %v", err) + slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) } case "stop break": if err := a.timeStore.StartTracking(TagWork); err != nil { - log.Printf("ERROR: Failed to stop break (start work): %v", err) + slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err)) } case "show day summary": if err := a.timeStore.ShowSummary("today"); err != nil { - log.Printf("ERROR: Failed to show day summary: %v", err) + slog.Error(fmt.Sprintf("Failed to show day summary: %v", err)) } case "show week summary": if err := a.timeStore.ShowSummary("week"); err != nil { - log.Printf("ERROR: Failed to show week summary: %v", err) + slog.Error(fmt.Sprintf("ERROR: Failed to show week summary: %v", err)) } case "show month summary": if err := a.timeStore.ShowSummary("month"); err != nil { - log.Printf("ERROR: Failed to show month summary: %v", err) + slog.Error(fmt.Sprintf("Failed to show month summary: %v", err)) } case "export": filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" @@ -222,7 +229,7 @@ func (a *App) makeChoice() { filename = a.flags.ExportName } if err := a.timeStore.ExportSummary(filename); err != nil { - log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) + slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err)) } case "connect to jump": a.connectToJump() @@ -234,15 +241,15 @@ func (a *App) makeChoice() { a.wakeWorkstation() case "kill tunnels": if err := a.killForwardings(); err != nil { - log.Printf("ERROR: Failed to kill forwardings: %v", err) + slog.Error(fmt.Sprintf("Failed to kill forwardings: %v", err)) } else { - log.Println("INFO: Attempted to kill processes on ports 2048 and 6000.") + slog.Info("Attempted to kill processes on ports 2048 and 6000.") } case "exit": fmt.Println("Exiting.") return default: - log.Printf("WARN: Unhandled choice '%s'", choice) + slog.Warn(fmt.Sprintf("Unhandled choice '%s'", choice)) } if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { @@ -257,7 +264,7 @@ func (a *App) getSSHAuth() ssh.AuthMethod { keyBytes, err := os.ReadFile(keyPath) if err != nil { - log.Printf("ERROR: Unable to read private key '%s': %v", keyPath, err) + slog.Error(fmt.Sprintf("Unable to read private key '%s': %v", keyPath, err)) return nil } @@ -265,19 +272,19 @@ func (a *App) getSSHAuth() ssh.AuthMethod { key, err = ssh.ParsePrivateKey(keyBytes) if err != nil { if _, ok := err.(*ssh.PassphraseMissingError); ok { - log.Printf("INFO: Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath) + 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 { - log.Printf("ERROR: Unable to parse private key '%s' with passphrase: %v", keyPath, err) + slog.Error(fmt.Sprintf("Unable to parse private key '%s' with passphrase: %v", keyPath, err)) return nil } } else { - log.Printf("ERROR: Unable to parse private key '%s': %v", keyPath, err) + slog.Error(fmt.Sprintf("Unable to parse private key '%s': %v", keyPath, err)) return nil } } - log.Printf("INFO: Successfully loaded private key '%s'", keyPath) + slog.Info(fmt.Sprintf("Successfully loaded private key '%s'", keyPath)) return ssh.PublicKeys(key) } @@ -295,13 +302,13 @@ func (a *App) newSSHConnection() (*SSHConnection, error) { } target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) - log.Printf("INFO: Dialing SSH to %s...", target) + slog.Info(fmt.Sprintf("Dialing SSH to %s...", target)) client, err := ssh.Dial("tcp", target, sshConfig) if err != nil { return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err) } - log.Printf("INFO: SSH connection to %s successful.", target) + slog.Info(fmt.Sprintf("SSH connection to %s successful.", target)) session, err := client.NewSession() if err != nil { @@ -320,16 +327,16 @@ func (a *App) killForwardings() error { killedSomething := false var lastErr error - log.Println("INFO: Attempting to kill processes listening on ports:", strings.Join(ports, ", ")) + slog.Info(fmt.Sprintf("Attempting to kill processes listening on ports: %v", strings.Join(ports, ", "))) for _, port := range ports { cmd := exec.Command("lsof", "-i", "tcp:"+port, "-t") output, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - log.Printf("INFO: No process found listening on port %s.", port) + slog.Info(fmt.Sprintf("No process found listening on port %s.", port)) } else { - log.Printf("WARN: 'lsof' command failed for port %s: %v", port, err) + slog.Warn(fmt.Sprintf("'lsof' command failed for port %s: %v", port, err)) lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err) } continue @@ -341,29 +348,29 @@ func (a *App) killForwardings() error { if pid == "" { continue } - log.Printf("INFO: Found process PID %s on port %s. Attempting to kill...", pid, port) + slog.Info(fmt.Sprintf("Found process PID %s on port %s. Attempting to kill...", pid, port)) killCmd := exec.Command("kill", pid) if err := killCmd.Run(); err != nil { - log.Printf("WARN: Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err) + slog.Warn(fmt.Sprintf("Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err)) forceKillCmd := exec.Command("kill", "-9", pid) if err := forceKillCmd.Run(); err != nil { - log.Printf("ERROR: Failed to force kill PID %s (port %s): %v", pid, port, err) + slog.Error(fmt.Sprintf("Failed to force kill PID %s (port %s): %v", pid, port, err)) lastErr = fmt.Errorf("kill -9 failed for PID %s: %w", pid, err) } else { - log.Printf("INFO: Force killed PID %s (port %s).", pid, port) + slog.Info(fmt.Sprintf("Force killed PID %s (port %s).", pid, port)) killedSomething = true } } else { - log.Printf("INFO: Killed PID %s (port %s).", pid, port) + slog.Info(fmt.Sprintf("Killed PID %s (port %s).", pid, port)) killedSomething = true } } } if killedSomething { - log.Println("INFO: Finished attempting to kill forwarding processes.") + slog.Info("Finished attempting to kill forwarding processes.") } else { - log.Println("INFO: No forwarding processes found or killed.") + slog.Info("No forwarding processes found or killed.") } return lastErr diff --git a/cmd.go b/cmd.go index c705acb..d58d168 100644 --- a/cmd.go +++ b/cmd.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "log" + "log/slog" "os" "os/signal" "strings" @@ -51,11 +51,11 @@ Use --background (-b) to keep tunnels running in the background without auto-con } defer func() { - log.Println("INFO: Closing SSH connection to jump host (defer)...") + slog.Info("Closing SSH connection to jump host (defer)...") if err := sshCon.Close(); err != nil { - log.Printf("WARN: Error closing SSH connection in defer: %v", err) + slog.Warn(fmt.Sprintf("Error closing SSH connection in defer: %v", err)) } else { - log.Println("INFO: SSH connection closed via defer.") + slog.Info("SSH connection closed via defer.") } }() @@ -70,11 +70,11 @@ Use --background (-b) to keep tunnels running in the background without auto-con <-sigChan fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...") - log.Println("INFO: Received signal, cleanup via defer sshCon.Close() will run.") + slog.Info("Received signal, cleanup via defer sshCon.Close() will run.") if err := a.timeStore.StopTracking(); err != nil { - log.Printf("WARN: Failed to stop time tracking: %v", err) + slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { - log.Println("INFO: Time tracking stopped.") + slog.Info("Time tracking stopped.") } fmt.Println("INFO: Background shutdown complete.") @@ -83,11 +83,11 @@ Use --background (-b) to keep tunnels running in the background without auto-con a.connectToWorkstation() fmt.Println("INFO: Workstation SSH session finished.") - log.Println("INFO: Foreground session ended, cleanup via defer sshCon.Close() will run.") + slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.") if err := a.timeStore.StopTracking(); err != nil { - log.Printf("WARN: Failed to stop time tracking: %v", err) + slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { - log.Println("INFO: Time tracking stopped.") + slog.Info("Time tracking stopped.") } } @@ -108,13 +108,13 @@ func (a *App) stopCommand() *cobra.Command { Run: func(cmd *cobra.Command, args []string) { fmt.Println("Stopping workday procedures...") if err := a.timeStore.StopTracking(); err != nil { - log.Printf("ERROR: Failed to stop time tracking: %v", err) + slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { fmt.Println("Time tracking stopped.") } if err := a.killForwardings(); err != nil { - log.Printf("WARN: Could not kill all forwarding processes: %v", err) + slog.Warn(fmt.Sprintf("Could not kill all forwarding processes: %v", err)) } else { fmt.Println("Attempted to stop SSH tunnels.") } @@ -160,7 +160,7 @@ This also stops any currently running timer.`, default: fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) if err := a.timeStore.StartTracking(tag); err != nil { - log.Printf("ERROR: Failed to start tracking '%s': %v", tag, err) + slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err)) return fmt.Errorf("could not start tracking '%s': %w", tag, err) } return nil // Erfolg @@ -174,7 +174,7 @@ This also stops any currently running timer.`, RunE: func(cmd *cobra.Command, args []string) error { fmt.Println("Starting break...") if err := a.timeStore.StartTracking(TagBreak); err != nil { - log.Printf("ERROR: Failed to start break tracking: %v", err) + slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) return fmt.Errorf("could not start break: %w", err) } return nil @@ -204,27 +204,27 @@ Export: Use the --export flag or the 'export' subcommand.`, filename := a.flags.ExportName if filename == "" || filename == "Arbeitszeiten.xlsx" { filename = "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" - log.Printf("INFO: No export name specified, using default: %s", filename) + slog.Info(fmt.Sprintf("No export name specified, using default: %s", filename)) } fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) if err := a.timeStore.ExportSummary(filename); err != nil { - log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) + 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.timeStore.ShowSummary("week"); err != nil { - log.Printf("ERROR: Failed to show week summary: %v", err) + slog.Error(fmt.Sprintf("Failed to show week summary: %v", err)) } } else if a.flags.ShowMonth { fmt.Println("Showing monthly summary...") if err := a.timeStore.ShowSummary("month"); err != nil { - log.Printf("ERROR: Failed to show month summary: %v", err) + slog.Error(fmt.Sprintf("Failed to show month summary: %v", err)) } } else { fmt.Printf("Showing summary for period: %s...\n", period) if err := a.timeStore.ShowSummary(period); err != nil { - log.Printf("ERROR: Failed to show summary for '%s': %v", period, err) + slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err)) } } }, @@ -248,7 +248,7 @@ Export: Use the --export flag or the 'export' subcommand.`, } fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) if err := a.timeStore.ExportSummary(filename); err != nil { - log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) + slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err)) fmt.Printf("Error: Could not export to '%s'.\n", filename) } }, @@ -321,12 +321,12 @@ Example: workctl import-timew /path/to/timew-summary.txt`, count, err := a.runImport(filepath) if err != nil { - log.Printf("ERROR: Import failed: %v", err) + slog.Error(fmt.Sprintf("Import failed: %v", err)) return fmt.Errorf("import failed: %w", err) } fmt.Printf("Successfully imported %d time entries.\n", count) - log.Printf("INFO: Successfully imported %d time entries from %s", count, filepath) + slog.Info(fmt.Sprintf("Successfully imported %d time entries from %s", count, filepath)) return nil }, } @@ -353,8 +353,8 @@ func (a *App) runImport(filepath string) (int, error) { } defer stmt.Close() - var current_date_str string - imported_count := 0 + var currentDateStr string + importedCount := 0 location := time.Local for i, line := range lines { @@ -364,94 +364,94 @@ func (a *App) runImport(filepath string) (int, error) { } fields := strings.Fields(line) - var tag, start_str, end_str string - has_date := false + var tag, startStr, endStr string + hasDate := false if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 { - current_date_str = fields[1] + currentDateStr = fields[1] tag = fields[3] - start_str = fields[4] - end_str = fields[5] - has_date = true + startStr = fields[4] + endStr = fields[5] + hasDate = true } else if len(fields) >= 4 && strings.Contains(fields[1], ":") && strings.Contains(fields[2], ":") { - if current_date_str == "" { - log.Printf("WARN: Skipping line without preceding date: %s", line) + if currentDateStr == "" { + slog.Warn(fmt.Sprintf("Skipping line without preceding date: %s", line)) continue } tag = fields[0] - start_str = fields[1] - end_str = fields[2] - has_date = false + startStr = fields[1] + endStr = fields[2] + hasDate = false } else if len(fields) >= 6 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 { - current_date_str = fields[1] + currentDateStr = fields[1] tag = fields[3] - start_str = fields[4] - end_str = fields[5] - has_date = true - if start_str == "0:00:00" && end_str == "0:00:00" { - start_time, err := time.ParseInLocation("2006-01-02", current_date_str, location) + startStr = fields[4] + endStr = fields[5] + hasDate = true + if startStr == "0:00:00" && endStr == "0:00:00" { + startTime, err := time.ParseInLocation("2006-01-02", currentDateStr, location) if err != nil { - log.Printf("WARN: Skipping line with invalid date '%s': %v", current_date_str, err) + slog.Warn(fmt.Sprintf("Skipping line with invalid date '%s': %v", currentDateStr, err)) continue } - end_time := start_time.Add(24 * time.Hour) + endTime := startTime.Add(24 * time.Hour) - _, err = stmt.Exec(tag, start_time, end_time) + _, err = stmt.Exec(tag, startTime, endTime) if err != nil { - log.Printf("ERROR: Failed to insert full-day entry for %s (%s): %v", current_date_str, tag, err) + slog.Error(fmt.Sprintf("Failed to insert full-day entry for %s (%s): %v", currentDateStr, tag, err)) } else { - imported_count++ + importedCount++ } continue } } else { - log.Printf("WARN: Skipping unrecognized line format: %s", line) + slog.Warn(fmt.Sprintf("Skipping unrecognized line format: %s", line)) continue } - if end_str == "-" { - log.Printf("INFO: Skipping currently running entry: %s", line) + if endStr == "-" { + slog.Info(fmt.Sprintf("Skipping currently running entry: %s", line)) continue } - start_datetime_str := current_date_str + " " + start_str - end_datetime_str := current_date_str + " " + end_str + startDatetimeStr := currentDateStr + " " + startStr + endDatetimeStr := currentDateStr + " " + endStr - start_time, err_start := time.ParseInLocation("2006-01-02 15:04:05", start_datetime_str, location) - end_time, err_end := time.ParseInLocation("2006-01-02 15:04:05", end_datetime_str, location) + startTime, errStart := time.ParseInLocation("2006-01-02 15:04:05", startDatetimeStr, location) + endTime, errEnd := time.ParseInLocation("2006-01-02 15:04:05", endDatetimeStr, location) - if err_start != nil || err_end != nil { - log.Printf("WARN: Skipping line with invalid date/time format ('%s' / '%s'): %v / %v", start_datetime_str, end_datetime_str, err_start, err_end) + if errStart != nil || errEnd != nil { + slog.Warn(fmt.Sprintf("Skipping line with invalid date/time format ('%s' / '%s'): %v / %v", startDatetimeStr, endDatetimeStr, errStart, errEnd)) continue } - if end_time.Before(start_time) { - if has_date { - log.Printf("WARN: End time is before start time on the same date line, skipping: %s", line) + if endTime.Before(startTime) { + if hasDate { + slog.Warn(fmt.Sprintf("End time is before start time on the same date line, skipping: %s", line)) continue } } - db_tag := strings.ToLower(tag) - switch db_tag { + dbTag := strings.ToLower(tag) + switch dbTag { case "work": - db_tag = TagWork + dbTag = TagWork case "break": - db_tag = TagBreak + dbTag = TagBreak } - _, err = stmt.Exec(db_tag, start_time, end_time) + _, err = stmt.Exec(dbTag, startTime, endTime) if err != nil { - log.Printf("ERROR: Failed to insert entry for %s (%s, %s -> %s): %v", current_date_str, db_tag, start_time.Format(time.RFC3339), end_time.Format(time.RFC3339), err) + slog.Error(fmt.Sprintf("Failed to insert entry for %s (%s, %s -> %s): %v", currentDateStr, dbTag, startTime.Format(time.RFC3339), endTime.Format(time.RFC3339), err)) } else { - imported_count++ + importedCount++ } } if err := tx.Commit(); err != nil { - return imported_count, fmt.Errorf("failed to commit transaction: %w", err) + return importedCount, fmt.Errorf("failed to commit transaction: %w", err) } - return imported_count, nil + return importedCount, nil } diff --git a/config.go b/config.go index 0800523..bae7e01 100644 --- a/config.go +++ b/config.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "log" + "log/slog" "os" "path/filepath" @@ -43,7 +43,7 @@ func loadConfig() (Config, error) { workConfigPath := filepath.Join(configPath, "work") configFile := filepath.Join(workConfigPath, "config.toml") - if err := os.MkdirAll(workConfigPath, 0750); err != nil { + if err := os.MkdirAll(workConfigPath, 0o750); err != nil { return cfg, fmt.Errorf("could not create config directory '%s': %w", workConfigPath, err) } @@ -55,7 +55,7 @@ func loadConfig() (Config, error) { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err) } - log.Printf("INFO: 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 { diff --git a/export.go b/export.go index 1a942f3..8de8584 100644 --- a/export.go +++ b/export.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "log" + "log/slog" "sort" "strings" "time" @@ -35,7 +35,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti now := time.Now().In(location) currentDay := yearStart - log.Println(currentDay) for currentDay.Before(yearEnd) { dayStr := currentDay.Format("2006-01-02") weekday := currentDay.Weekday() @@ -56,7 +55,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti for _, entry := range entries { if entry.StartTime.IsZero() { - log.Printf("WARN: Skipping entry with zero start time (ID: %d)", entry.ID) + slog.Warn(fmt.Sprintf("Skipping entry with zero start time (ID: %d)", entry.ID)) continue } @@ -100,7 +99,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti summary, exists := dailyMap[dayStr] if !exists { - log.Printf("WARN: Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID) + slog.Warn(fmt.Sprintf("Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID)) loopTime = dayEnd continue } @@ -141,7 +140,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti case TagBreak: summary.BreakDuration += segmentDuration default: - log.Printf("INFO: Encountered unknown tag '%s' during interval processing for entry ID %d on %s. Counting duration as 'work'.", entry.Tag, entry.ID, dayStr) + 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 @@ -261,7 +260,7 @@ func getSollExcelTime(dayOfWeek string) any { sollDur, err := time.Parse("15:04", sollString) if err != nil { - log.Printf("ERROR: Could not parse hardcoded soll string '%s': %v", sollString, err) + slog.Error(fmt.Sprintf("Could not parse hardcoded soll string '%s': %v", sollString, err)) return nil } return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0) @@ -271,7 +270,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error { f := excelize.NewFile() defer func() { if err := f.Close(); err != nil { - log.Printf("ERROR: Failed to close excel file handle: %v", err) + slog.Error(fmt.Sprintf("Failed to close excel file handle: %v", err)) } }() diff --git a/forwarder.go b/forwarder.go index c859df1..24d6d22 100644 --- a/forwarder.go +++ b/forwarder.go @@ -3,7 +3,7 @@ package main import ( "fmt" "io" - "log" + "log/slog" "net" "sync" @@ -31,28 +31,28 @@ 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 %s -> remote %s (via SSH)", localAddr, remoteAddr) + pf.logf("INFO", "Starting port forwarder: local %s -> remote %s (via SSH)", localAddr, remoteAddr) listener, err := net.Listen("tcp", localAddr) if err != nil { - pf.logf("ERROR: Failed to open local listener on %s: %v", localAddr, err) + 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 on %s", localAddr) + pf.logf("INFO", "Listener active on %s", 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 on %s closed, stopping forwarder.", localAddr) + pf.logf("INFO", "Listener on %s closed, stopping forwarder.", localAddr) return nil } - pf.logf("ERROR: Failed to accept incoming connection on %s: %v", localAddr, err) + pf.logf("ERROR", "Failed to accept incoming connection on %s: %v", localAddr, err) continue } - pf.logf("INFO: Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr) + pf.logf("INFO", "Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr) go pf.handleConnection(localConn, remoteAddr) } } @@ -60,14 +60,14 @@ func (pf *PortForwarder) forward() error { func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) { defer localConn.Close() - pf.logf("INFO: Dialing remote host %s via SSH tunnel for %s", remoteAddr, localConn.RemoteAddr()) + pf.logf("INFO", "Dialing remote host %s via SSH tunnel for %s", remoteAddr, localConn.RemoteAddr()) remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr) if err != nil { - pf.logf("ERROR: Failed to dial remote host %s via SSH: %v", remoteAddr, err) + pf.logf("ERROR", "Failed to dial remote host %s via SSH: %v", remoteAddr, err) return } defer remoteConn.Close() - pf.logf("INFO: Connection to %s established. Starting data copy.", remoteAddr) + pf.logf("INFO", "Connection to %s established. Starting data copy.", remoteAddr) var wg sync.WaitGroup wg.Add(2) @@ -78,7 +78,7 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) bytesCopied, err := io.Copy(localConn, remoteConn) if err != nil { } - pf.logf("INFO: Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) + pf.logf("INFO", "Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) }() go func() { @@ -87,15 +87,22 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) bytesCopied, err := io.Copy(remoteConn, localConn) if err != nil { } - pf.logf("INFO: Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) + pf.logf("INFO", "Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) }() wg.Wait() - pf.logf("INFO: Closing forwarded connection for %s", localConn.RemoteAddr()) + pf.logf("INFO", "Closing forwarded connection for %s", localConn.RemoteAddr()) } -func (pf *PortForwarder) logf(format string, v ...any) { +func (pf *PortForwarder) logf(level, format string, v ...any) { pf.logMutex.Lock() defer pf.logMutex.Unlock() - log.Printf(format, v...) + switch level { + case "INFO": + slog.Info(format, v...) + case "WARN": + slog.Warn(format, v...) + case "ERROR": + slog.Error(format, v...) + } } diff --git a/main.go b/main.go index 0bc0bd3..a2bcad3 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,20 @@ package main import ( - "log" + "fmt" + "log/slog" "os" ) func main() { app, err := NewApp() if err != nil { - log.Fatalf("ERROR: Unable to setup application: %v", err) + slog.Error(fmt.Sprintf("Unable to setup application: %v", err)) + os.Exit(1) } defer func() { if err := app.Close(); err != nil { - log.Printf("ERROR: Failed to close application resources: %v", err) + slog.Error(fmt.Sprintf("Failed to close application resources: %v", err)) } }() diff --git a/ssh.go b/ssh.go index 62899d0..c1d6a05 100644 --- a/ssh.go +++ b/ssh.go @@ -1,7 +1,7 @@ package main import ( - "log" + "log/slog" "golang.org/x/crypto/ssh" ) @@ -12,7 +12,7 @@ type SSHConnection struct { func (s *SSHConnection) Close() error { if s.client != nil { - log.Println("DEBUG: Closing SSH client connection.") + slog.Debug("Closing SSH client connection.") return s.client.Close() } return nil diff --git a/store.go b/store.go index 7b63aff..789d5dc 100644 --- a/store.go +++ b/store.go @@ -3,7 +3,7 @@ package main import ( "database/sql" "fmt" - "log" + "log/slog" "os" "path/filepath" "strings" @@ -37,7 +37,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) { return nil, fmt.Errorf("could not determine database path: %w", err) } - log.Printf("INFO: Using database at: %s", dbPath) + slog.Info(fmt.Sprintf("Using database at: %s", dbPath)) db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath)) if err != nil { @@ -66,7 +66,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) { createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);` if _, err = db.Exec(createIndexSQL); err != nil { - log.Printf("WARN: Failed to create index on start_time: %v", err) + slog.Warn(fmt.Sprintf("Failed to create index on start_time: %v", err)) } return &TimeStore{db: db, dbPath: dbPath}, nil @@ -80,7 +80,7 @@ func ensureDatabasePath(_ Config) (string, error) { workConfigDir := filepath.Join(configDir, "work") dbPath := filepath.Join(workConfigDir, "worktime.sqlite") - if err := os.MkdirAll(workConfigDir, 0750); err != nil { + if err := os.MkdirAll(workConfigDir, 0o750); err != nil { return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err) } @@ -89,7 +89,7 @@ func ensureDatabasePath(_ Config) (string, error) { func (ts *TimeStore) Close() error { if ts.db != nil { - log.Printf("INFO: Closing database connection to %s", ts.dbPath) + slog.Info(fmt.Sprintf("Closing database connection to %s", ts.dbPath)) return ts.db.Close() } return nil @@ -108,7 +108,7 @@ func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { } if rowsAffected > 1 { - log.Printf("WARN: Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected) + slog.Warn(fmt.Sprintf("Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected)) } return rowsAffected > 0, nil } @@ -124,7 +124,7 @@ func (ts *TimeStore) StartTracking(tag string) error { return err } if stopped { - log.Println("INFO: Stopped previous time entry.") + slog.Info("Stopped previous time entry.") } query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` @@ -132,7 +132,7 @@ func (ts *TimeStore) StartTracking(tag string) error { if err != nil { return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err) } - log.Printf("INFO: Started tracking: %s at %s", tag, now.Format(time.RFC3339)) + slog.Info(fmt.Sprintf("Started tracking: %s at %s", tag, now.Format(time.RFC3339))) return nil } @@ -143,9 +143,9 @@ func (ts *TimeStore) StopTracking() error { return err } if stopped { - log.Printf("INFO: Stopped tracking at %s", now.Format(time.RFC3339)) + slog.Info(fmt.Sprintf("Stopped tracking at %s", now.Format(time.RFC3339))) } else { - log.Println("INFO: No active time entry found to stop.") + slog.Info("No active time entry found to stop.") } return nil } @@ -273,7 +273,7 @@ func getTimeRangeFromPeriod(period string) (time.Time, time.Time) { end := start.AddDate(0, 0, 1) return start, end } - log.Printf("WARN: Unrecognized period string '%s'. Cannot calculate time range.", period) + slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period)) return time.Time{}, time.Time{} } } @@ -329,14 +329,14 @@ func (ts *TimeStore) ShowSummary(period string) error { } func (ts *TimeStore) ExportSummary(filename string) error { - log.Printf("INFO: Starting export to '%s'...", filename) + 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) - log.Printf("INFO: Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")) + 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 @@ -362,7 +362,7 @@ func (ts *TimeStore) ExportSummary(filename string) error { if err = rows.Err(); err != nil { return fmt.Errorf("error during export row iteration: %w", err) } - log.Printf("INFO: Found %d potentially relevant time entries for year %d.", len(entries), currentYear) + 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 { @@ -372,17 +372,17 @@ func (ts *TimeStore) ExportSummary(filename string) error { excelEntries := convertDailyToExcelEntries(dailySummaries) if len(excelEntries) == 0 { - log.Println("WARN: No daily summaries generated for the export period.") + 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 } - log.Printf("INFO: Generated %d daily entries for the Excel export.", len(excelEntries)) + slog.Info(fmt.Sprintf("Generated %d daily entries for the Excel export.", len(excelEntries))) if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err) } - log.Printf("INFO: Successfully exported timetable to %s", filename) + slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename)) fmt.Printf("Successfully exported timetable to %s\n", filename) return nil } @@ -399,16 +399,16 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error { dayEnd := dayStart.Add(24 * time.Hour) dayStr := dayStart.Format("2006-01-02") - log.Printf("INFO: Attempting to log '%s' for the full day %s", tag, dayStr) + slog.Info(fmt.Sprintf("Attempting to log '%s' for the full day %s", tag, dayStr)) // 1. Stoppe den aktuell laufenden Timer (falls vorhanden) // Wir verwenden dayStart als Zeitpunkt für das Stoppen, um Konsistenz zu wahren stopped, err := ts.stopCurrentEntry(dayStart) if err != nil { // Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen. - log.Printf("WARN: Failed to stop current entry before logging full day '%s': %v", tag, err) + slog.Warn(fmt.Sprintf("Failed to stop current entry before logging full day '%s': %v", tag, err)) } else if stopped { - log.Printf("INFO: Stopped active timer before logging '%s' for %s.", tag, dayStr) + slog.Info(fmt.Sprintf("Stopped active timer before logging '%s' for %s.", tag, dayStr)) } tx, err := ts.db.Begin() @@ -436,7 +436,7 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error { } titleCaser := cases.Title(language.English) - log.Printf("INFO: Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)) + 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) // Benutzerfeedback return nil } From 54979319ff2117e8cbe249c0c0db7bf25e6fce06 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Mon, 30 Jun 2025 08:07:53 +0200 Subject: [PATCH 10/17] refactor: perform cleanup --- app.go | 6 ------ cmd.go | 4 ++-- export.go | 3 +-- forwarder.go | 2 +- go.mod | 1 - go.sum | 26 ++++---------------------- store.go | 16 +++++----------- 7 files changed, 13 insertions(+), 45 deletions(-) diff --git a/app.go b/app.go index 0a3bb3c..1c4d18b 100644 --- a/app.go +++ b/app.go @@ -54,30 +54,24 @@ func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err) } - // slog.Info("SSH connection established. Setting up tunnels...") slog.Info("SSH connection established. Setting up tunnels...") sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) go func() { - // slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)") 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.Error(fmt.Sprintf("SSH forwarder failed: %v", err)) } - // slog.Info("SSH forwarder stopped.") slog.Info("SSH forwarder stopped.") }() rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) go func() { - // slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)") 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.") slog.Info("RDP forwarder stopped.") }() diff --git a/cmd.go b/cmd.go index d58d168..dc4ca00 100644 --- a/cmd.go +++ b/cmd.go @@ -155,7 +155,7 @@ This also stops any currently running timer.`, if err := a.timeStore.LogFullDay(tagLower, today); err != nil { return fmt.Errorf("could not log '%s' for today: %w", tagLower, err) } - return nil // Erfolg + return nil default: fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) @@ -163,7 +163,7 @@ This also stops any currently running timer.`, slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err)) return fmt.Errorf("could not start tracking '%s': %w", tag, err) } - return nil // Erfolg + return nil } }, } diff --git a/export.go b/export.go index 8de8584..347c3a5 100644 --- a/export.go +++ b/export.go @@ -441,11 +441,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error { f.SetCellValue(sheetName, "D"+rowStr, "") f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) - // J: Netto ist 0 f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) - default: // Unbekannte Tags oder Tage ohne Eintrag + default: f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) } diff --git a/forwarder.go b/forwarder.go index 24d6d22..c519ad2 100644 --- a/forwarder.go +++ b/forwarder.go @@ -52,7 +52,7 @@ func (pf *PortForwarder) forward() error { continue } - pf.logf("INFO", "Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr) + pf.logf("INFO", fmt.Sprintf("Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr)) go pf.handleConnection(localConn, remoteAddr) } } diff --git a/go.mod b/go.mod index 1b27a54..e80225b 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,6 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect diff --git a/go.sum b/go.sum index 6e86cdb..2a23617 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -105,50 +103,34 @@ 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/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= -github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= 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.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= -github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= 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/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= -github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= 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/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 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/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/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 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/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= diff --git a/store.go b/store.go index 789d5dc..e2ff658 100644 --- a/store.go +++ b/store.go @@ -378,7 +378,7 @@ func (ts *TimeStore) ExportSummary(filename string) error { } slog.Info(fmt.Sprintf("Generated %d daily entries for the Excel export.", len(excelEntries))) - if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion + if err := writeExcelSheet(excelEntries, filename); err != nil { return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err) } @@ -391,21 +391,17 @@ 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) // Stelle sicher, dass der Tag klein geschrieben ist + tag = strings.ToLower(tag) - location := date.Location() // Verwende die Zeitzone des übergebenen Datums - // Berechne Start (00:00:00 des Tages) und Ende (00:00:00 des nächsten Tages) + 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)) - // 1. Stoppe den aktuell laufenden Timer (falls vorhanden) - // Wir verwenden dayStart als Zeitpunkt für das Stoppen, um Konsistenz zu wahren stopped, err := ts.stopCurrentEntry(dayStart) if err != nil { - // Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen. 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)) @@ -415,7 +411,7 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error { if err != nil { return fmt.Errorf("could not begin transaction to log full day: %w", err) } - defer tx.Rollback() // Stellt sicher, dass bei Fehlern nichts gespeichert wird + defer tx.Rollback() query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);` stmt, err := tx.Prepare(query) @@ -426,17 +422,15 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error { _, err = stmt.Exec(tag, dayStart, dayEnd) if err != nil { - // Spezifischere Fehlermeldung, falls es UNIQUE Constraints gäbe return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err) } - // Transaktion erfolgreich abschließen 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) // Benutzerfeedback + fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr) return nil } From 20b4b7ba2d899081f47a1bdd1a4a01c8e5cd6a82 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Tue, 1 Jul 2025 07:50:05 +0200 Subject: [PATCH 11/17] fix: fix wrong formatting in log-output --- forwarder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forwarder.go b/forwarder.go index c519ad2..db05677 100644 --- a/forwarder.go +++ b/forwarder.go @@ -39,7 +39,7 @@ func (pf *PortForwarder) forward() error { return fmt.Errorf("failed to listen on %s: %w", localAddr, err) } defer listener.Close() - pf.logf("INFO", "Listener active on %s", localAddr) + pf.logf("INFO", fmt.Sprintf("Listener active on %s", localAddr)) for { localConn, err := listener.Accept() From 127018b565a19e6dd9d2ebd78911dd87e6b9d2b3 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 2 Jul 2025 11:37:14 +0200 Subject: [PATCH 12/17] refactor: perform more clean up in codebase --- cmd.go | 4 ++-- forwarder.go | 22 +++++++++++----------- main.go | 21 ++++++++++++++++++--- store.go | 6 +++--- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/cmd.go b/cmd.go index dc4ca00..afdae17 100644 --- a/cmd.go +++ b/cmd.go @@ -79,10 +79,10 @@ Use --background (-b) to keep tunnels running in the background without auto-con fmt.Println("INFO: Background shutdown complete.") } else { - fmt.Println("INFO: Automatically connecting to workstation via SSH tunnel...") + fmt.Println("Automatically connecting to workstation via SSH tunnel...") a.connectToWorkstation() - fmt.Println("INFO: Workstation SSH session finished.") + fmt.Println("Workstation SSH session finished.") slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.") if err := a.timeStore.StopTracking(); err != nil { slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) diff --git a/forwarder.go b/forwarder.go index db05677..09764be 100644 --- a/forwarder.go +++ b/forwarder.go @@ -31,7 +31,7 @@ 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 %s -> remote %s (via SSH)", localAddr, remoteAddr) + 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 { @@ -39,20 +39,20 @@ func (pf *PortForwarder) forward() error { return fmt.Errorf("failed to listen on %s: %w", localAddr, err) } defer listener.Close() - pf.logf("INFO", fmt.Sprintf("Listener active on %s", localAddr)) + 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 on %s closed, stopping forwarder.", localAddr) + pf.logf("INFO", "Listener closed, stopping forwarder.", "Local Address", localAddr) return nil } - pf.logf("ERROR", "Failed to accept incoming connection on %s: %v", localAddr, err) + pf.logf("ERROR", "Failed to accept incoming connection:", "Local Address", localAddr, "Error", err) continue } - pf.logf("INFO", fmt.Sprintf("Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr)) + pf.logf("INFO", "Accepted connection:", "Remote Address", localConn.RemoteAddr(), "Local Address", localAddr) go pf.handleConnection(localConn, remoteAddr) } } @@ -60,14 +60,14 @@ func (pf *PortForwarder) forward() error { func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) { defer localConn.Close() - pf.logf("INFO", "Dialing remote host %s via SSH tunnel for %s", remoteAddr, localConn.RemoteAddr()) + 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 %s via SSH: %v", remoteAddr, err) + pf.logf("ERROR", "Failed to dial remote host via SSH:", "Remote Address", remoteAddr, "Error:", err) return } defer remoteConn.Close() - pf.logf("INFO", "Connection to %s established. Starting data copy.", remoteAddr) + pf.logf("INFO", "Connection established. Starting data copy.", "Remote Address", remoteAddr) var wg sync.WaitGroup wg.Add(2) @@ -78,7 +78,7 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) bytesCopied, err := io.Copy(localConn, remoteConn) if err != nil { } - pf.logf("INFO", "Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) + pf.logf("INFO", "Finished copying remote->local", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr()) }() go func() { @@ -87,11 +87,11 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) bytesCopied, err := io.Copy(remoteConn, localConn) if err != nil { } - pf.logf("INFO", "Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) + pf.logf("INFO", "Finished copying local->remote", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr()) }() wg.Wait() - pf.logf("INFO", "Closing forwarded connection for %s", localConn.RemoteAddr()) + pf.logf("INFO", "Closing forwarded connection", "Remote Address", localConn.RemoteAddr()) } func (pf *PortForwarder) logf(level, format string, v ...any) { diff --git a/main.go b/main.go index a2bcad3..8067e3c 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,35 @@ package main import ( - "fmt" "log/slog" "os" + "path/filepath" ) func main() { + configDir, err := os.UserConfigDir() + if err != nil { + slog.Error("Cant get user config dir") + panic(err) + } + + file, err := os.OpenFile(filepath.Join(configDir, "work", "workctl.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) + if err != nil { + panic(err) + } + defer file.Close() + + logger := slog.New(slog.NewTextHandler(file, nil)) + slog.SetDefault(logger) + app, err := NewApp() if err != nil { - slog.Error(fmt.Sprintf("Unable to setup application: %v", err)) + slog.Error("Unable to setup application", "Error", err) os.Exit(1) } defer func() { if err := app.Close(); err != nil { - slog.Error(fmt.Sprintf("Failed to close application resources: %v", err)) + slog.Error("Failed to close application resources", "Error", err) } }() diff --git a/store.go b/store.go index e2ff658..12e0b94 100644 --- a/store.go +++ b/store.go @@ -37,7 +37,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) { return nil, fmt.Errorf("could not determine database path: %w", err) } - slog.Info(fmt.Sprintf("Using database at: %s", 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 { @@ -66,7 +66,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) { 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(fmt.Sprintf("Failed to create index on start_time: %v", err)) + slog.Warn("Failed to create index on start_time:", "Error:", err) } return &TimeStore{db: db, dbPath: dbPath}, nil @@ -89,7 +89,7 @@ func ensureDatabasePath(_ Config) (string, error) { func (ts *TimeStore) Close() error { if ts.db != nil { - slog.Info(fmt.Sprintf("Closing database connection to %s", ts.dbPath)) + slog.Info("Closing database connection", "Database Path", ts.dbPath) return ts.db.Close() } return nil From c0a83b58928db71f4320d03997f826d38ec6f435 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Fri, 10 Oct 2025 09:07:15 +0200 Subject: [PATCH 13/17] feat: add possibility to track time in timewarrior as well --- app.go | 12 ++++++------ cmd.go | 19 ++++++++++++------- helpers.go | 18 ++++++++++++++++++ store.go | 10 ++++++++-- 4 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 helpers.go diff --git a/app.go b/app.go index 1c4d18b..afde762 100644 --- a/app.go +++ b/app.go @@ -42,8 +42,8 @@ func (a *App) Close() error { return nil } -func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert - if err := a.timeStore.StartTracking(TagWork); err != nil { +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)) } @@ -189,20 +189,20 @@ func (a *App) makeChoice() { switch choice { case "start work": - a.connect() + a.connect(withoutTimew) case "stop work": - if err := a.timeStore.StopTracking(); err != nil { + 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)) } case "start break": - if err := a.timeStore.StartTracking(TagBreak); err != nil { + 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.timeStore.StartTracking(TagWork); err != nil { + 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": diff --git a/cmd.go b/cmd.go index afdae17..7180482 100644 --- a/cmd.go +++ b/cmd.go @@ -12,6 +12,8 @@ import ( "github.com/spf13/cobra" ) +var withoutTimew bool + func (a *App) setupCommands() *cobra.Command { rootCmd := &cobra.Command{ Use: "workctl", @@ -44,7 +46,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con RunE: func(cmd *cobra.Command, args []string) error { fmt.Println("Starting workday procedures...") - sshCon, err := a.connect() + 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) @@ -71,7 +73,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con 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(); err != nil { + 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.") @@ -84,7 +86,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con fmt.Println("Workstation SSH session finished.") slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.") - if err := a.timeStore.StopTracking(); err != nil { + 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.") @@ -96,18 +98,19 @@ Use --background (-b) to keep tunnels running in the background without auto-con } cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately") + cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") return cmd } func (a *App) stopCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "stop", Short: "Stop work: Stop time tracking, kill tunnels", 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.timeStore.StopTracking(); 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.") @@ -122,6 +125,8 @@ func (a *App) stopCommand() *cobra.Command { fmt.Println("Workday stop procedures finished.") }, } + cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") + return cmd } func (a *App) trackCommand() *cobra.Command { @@ -159,7 +164,7 @@ This also stops any currently running timer.`, default: fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) - if err := a.timeStore.StartTracking(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) } @@ -173,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.timeStore.StartTracking(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) } diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..c12c05c --- /dev/null +++ b/helpers.go @@ -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) + } +} diff --git a/store.go b/store.go index 12e0b94..00565c4 100644 --- a/store.go +++ b/store.go @@ -113,7 +113,7 @@ func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { return rowsAffected > 0, nil } -func (ts *TimeStore) StartTracking(tag string) error { +func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { if tag == "" { return fmt.Errorf("cannot start tracking with an empty tag") } @@ -126,6 +126,9 @@ func (ts *TimeStore) StartTracking(tag string) error { 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 = ts.db.Exec(query, tag, now) @@ -136,12 +139,15 @@ func (ts *TimeStore) StartTracking(tag string) error { return nil } -func (ts *TimeStore) StopTracking() error { +func (ts *TimeStore) StopTracking(withoutTimew bool) error { now := time.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 { From 99fb97dff3cc48f1827a80c6d0b504057aeb2d3f Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sat, 10 Jan 2026 17:23:43 +0100 Subject: [PATCH 14/17] chore(deps): update dependencies --- go.mod | 81 ++++++++++++++++++++++++++++------------------------ go.sum | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index e80225b..7df5042 100644 --- a/go.mod +++ b/go.mod @@ -3,62 +3,69 @@ module workctl go 1.24.2 require ( - github.com/charmbracelet/huh v0.6.0 - github.com/spf13/cobra v1.9.1 - 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 + github.com/charmbracelet/huh v0.8.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 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.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.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // 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.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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/mscfb v1.0.5 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.4.7 // 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.6 // 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/subosito/gotenv v1.6.0 // indirect - github.com/tiendc/go-deepcopy 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/xuri/efp v0.0.1 // 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 + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // 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 gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.62.1 // indirect + modernc.org/libc v1.67.4 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.9.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 2a23617..cd54cde 100644 --- a/go.sum +++ b/go.sum @@ -6,20 +6,46 @@ 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/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= @@ -32,8 +58,12 @@ 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/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= @@ -48,12 +78,16 @@ 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= @@ -62,16 +96,24 @@ 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= @@ -83,18 +125,32 @@ 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/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= @@ -103,36 +159,62 @@ 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= 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= @@ -142,24 +224,32 @@ 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= From 5b16cef52556b1f2c2231d74ea72e9f8070a5013 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 11 Jan 2026 10:23:03 +0100 Subject: [PATCH 15/17] feat: improve security --- app.go | 77 +++++++++++++++++++++++++++++++++++++++++++----------- cmd.go | 69 ++++++++++++++++++++++++++++++++++++++++++++---- config.go | 13 +++++++++ go.mod | 4 +++ go.sum | 8 ++++++ helpers.go | 18 ------------- secrets.go | 28 ++++++++++++++++++++ store.go | 10 ++----- 8 files changed, 181 insertions(+), 46 deletions(-) delete mode 100644 helpers.go create mode 100644 secrets.go diff --git a/app.go b/app.go index afde762..8db7415 100644 --- a/app.go +++ b/app.go @@ -5,11 +5,13 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" "strings" "time" "github.com/charmbracelet/huh" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) type App struct { @@ -42,8 +44,34 @@ func (a *App) Close() error { return nil } -func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { // Rückgabetyp geändert - if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil { +func (a *App) StartTracking(tag string, withoutTimew bool) error { + if err := a.timeStore.StartTracking(tag); err != nil { + return err + } + + if !withoutTimew { + if err := a.runCommand("timew", "start", tag); err != nil { + slog.Warn("Failed to start timewarrior (ignoring)", "error", err) + } + } + return nil +} + +func (a *App) StopTracking(withoutTimew bool) error { + if err := a.timeStore.StopTracking(); err != nil { + return err + } + + if !withoutTimew { + if err := a.runCommand("timew", "stop"); err != nil { + slog.Warn("Failed to stop timewarrior (ignoring)", "error", err) + } + } + return nil +} + +func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { + if err := a.StartTracking(TagWork, withoutTimew); err != nil { slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err)) } @@ -69,7 +97,6 @@ func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { // Rückgabet 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.") @@ -137,16 +164,24 @@ func (a *App) connectToWorkstation() { fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), } if err := a.runCommand("ssh", sshArgs...); err != nil { + return } } 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 { + + args := []string{ + fmt.Sprintf("/u:%s", a.cfg.RDPUser), + fmt.Sprintf("/p:%s", a.cfg.SSHPassword), + "/v:127.0.0.1:6000", + "/size:3000x1350", + "+clipboard", + "/dynamic-resolution", + } + + if err := a.runCommand("xfreerdp", args...); err != nil { + return } } @@ -191,18 +226,18 @@ func (a *App) makeChoice() { case "start work": a.connect(withoutTimew) case "stop work": - if err := a.timeStore.StopTracking(withoutTimew); err != nil { + if err := a.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)) } case "start break": - if err := a.timeStore.StartTracking(TagBreak, withoutTimew); err != nil { + if err := a.StartTracking(TagBreak, withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) } case "stop break": - if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil { + if err := a.StartTracking(TagWork, withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err)) } case "show day summary": @@ -288,11 +323,23 @@ func (a *App) newSSHConnection() (*SSHConnection, error) { return nil, fmt.Errorf("SSH authentication method could not be obtained") } + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home dir: %w", err) + } + knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts") + + hostKeyCallback, err := knownhosts.New(knownHostsPath) + if err != nil { + slog.Warn("Could not load known_hosts file. Please ensure you have connected to the host manually once to populate it.", "path", knownHostsPath) + return nil, fmt.Errorf("failed to create host key callback (check your known_hosts file): %w", err) + } + sshConfig := &ssh.ClientConfig{ User: a.cfg.SSHUser, Auth: []ssh.AuthMethod{authMethod}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, // Etwas längerer Timeout + HostKeyCallback: hostKeyCallback, + Timeout: 10 * time.Second, } target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) @@ -306,8 +353,8 @@ func (a *App) newSSHConnection() (*SSHConnection, error) { 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) + client.Close() + return nil, fmt.Errorf("failed to create SSH session check: %w", err) } session.Close() diff --git a/cmd.go b/cmd.go index 7180482..c53a0ec 100644 --- a/cmd.go +++ b/cmd.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -30,12 +31,70 @@ 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 := setSecret(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 := setSecret(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", @@ -73,7 +132,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con 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 { + if err := a.StopTracking(withoutTimew); err != nil { slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { slog.Info("Time tracking stopped.") @@ -86,7 +145,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con 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 { + if err := a.StopTracking(withoutTimew); err != nil { slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { slog.Info("Time tracking stopped.") @@ -110,7 +169,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.timeStore.StopTracking(withoutTimew); err != nil { + if err := a.StopTracking(withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { fmt.Println("Time tracking stopped.") @@ -164,7 +223,7 @@ This also stops any currently running timer.`, default: fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) - if err := a.timeStore.StartTracking(tag, withoutTimew); err != nil { + if err := a.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) } @@ -178,7 +237,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.timeStore.StartTracking(TagBreak, withoutTimew); err != nil { + if err := a.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) } diff --git a/config.go b/config.go index bae7e01..85a0bff 100644 --- a/config.go +++ b/config.go @@ -68,5 +68,18 @@ func loadConfig() (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 } diff --git a/go.mod b/go.mod index 7df5042..0bfb4d7 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( ) 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 @@ -27,10 +28,12 @@ require ( 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/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/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -57,6 +60,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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 diff --git a/go.sum b/go.sum index cd54cde..a21dc12 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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= @@ -47,6 +49,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX 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= @@ -64,6 +68,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx 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= @@ -173,6 +179,8 @@ 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= diff --git a/helpers.go b/helpers.go deleted file mode 100644 index c12c05c..0000000 --- a/helpers.go +++ /dev/null @@ -1,18 +0,0 @@ -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) - } -} diff --git a/secrets.go b/secrets.go new file mode 100644 index 0000000..5cc1584 --- /dev/null +++ b/secrets.go @@ -0,0 +1,28 @@ +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) +} diff --git a/store.go b/store.go index 00565c4..12e0b94 100644 --- a/store.go +++ b/store.go @@ -113,7 +113,7 @@ func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { return rowsAffected > 0, nil } -func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { +func (ts *TimeStore) StartTracking(tag string) error { if tag == "" { return fmt.Errorf("cannot start tracking with an empty tag") } @@ -126,9 +126,6 @@ func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { 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 = ts.db.Exec(query, tag, now) @@ -139,15 +136,12 @@ func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { return nil } -func (ts *TimeStore) StopTracking(withoutTimew bool) error { +func (ts *TimeStore) StopTracking() error { now := time.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 { From 4ed6a61b1d26e4ef0db4e6c60a6f044a1a7231a7 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 11 Jan 2026 11:33:26 +0100 Subject: [PATCH 16/17] refactor: refactor project structure to use golang best practices --- app.go | 392 +++++++++++-------------- cmd.go | 105 ++----- forwarder.go | 108 ------- config.go => internal/config/config.go | 40 ++- internal/ssh/client.go | 55 ++++ internal/ssh/forwarder.go | 103 +++++++ export.go => internal/store/export.go | 102 +++++-- store.go => internal/store/store.go | 364 ++++++++++------------- main.go | 31 +- ssh.go | 19 -- 10 files changed, 617 insertions(+), 702 deletions(-) delete mode 100644 forwarder.go rename config.go => internal/config/config.go (72%) create mode 100644 internal/ssh/client.go create mode 100644 internal/ssh/forwarder.go rename export.go => internal/store/export.go (83%) rename store.go => internal/store/store.go (51%) delete mode 100644 ssh.go diff --git a/app.go b/app.go index 8db7415..64d36e1 100644 --- a/app.go +++ b/app.go @@ -1,191 +1,158 @@ package main import ( + "context" "fmt" "log/slog" "os" "os/exec" - "path/filepath" "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" - "golang.org/x/crypto/ssh/knownhosts" ) +type Flags struct { + ShowWeek bool + ShowMonth bool + ShowExport bool + ExportName string + StartInBackground bool + WithoutTimew bool +} + type App struct { - cfg Config - flags Flags - timeStore *TimeStore + cfg config.Config + store *store.Store + flags Flags } func NewApp() (*App, error) { - cfg, err := loadConfig() + cfg, err := config.Load() if err != nil { return nil, fmt.Errorf("error loading config: %w", err) } - ts, err := NewTimeStore(cfg) + st, err := store.NewStore() if err != nil { return nil, fmt.Errorf("error initializing time store: %w", err) } return &App{ - cfg: cfg, - timeStore: ts, + cfg: cfg, + store: st, + flags: Flags{}, }, nil } func (a *App) Close() error { - if a.timeStore != nil { - return a.timeStore.Close() + if a.store != nil { + return a.store.Close() } return nil } -func (a *App) StartTracking(tag string, withoutTimew bool) error { - if err := a.timeStore.StartTracking(tag); err != 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 !withoutTimew { - if err := a.runCommand("timew", "start", tag); err != nil { - slog.Warn("Failed to start timewarrior (ignoring)", "error", err) - } + if !a.flags.WithoutTimew { + _ = a.runCommand("timew", "start", tag) } return nil } -func (a *App) StopTracking(withoutTimew bool) error { - if err := a.timeStore.StopTracking(); err != nil { +func (a *App) StopTracking(ctx context.Context) error { + if err := a.store.StopTracking(ctx); err != nil { return err } - - if !withoutTimew { - if err := a.runCommand("timew", "stop"); err != nil { - slog.Warn("Failed to stop timewarrior (ignoring)", "error", err) - } + if !a.flags.WithoutTimew { + _ = a.runCommand("timew", "stop") } return nil } -func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { - if err := a.StartTracking(TagWork, withoutTimew); err != nil { - slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err)) +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) } a.wakeWorkstation() - sshCon, err := a.newSSHConnection() + sshCon, err := ssh.NewConnection(a.cfg.SSHUser, a.cfg.SSHHost, a.cfg.SSHPort, a.getSSHAuth()) if err != nil { - return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err) + return fmt.Errorf("failed to establish primary SSH connection: %w", err) } - + defer sshCon.Close() slog.Info("SSH connection established. Setting up tunnels...") - sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) + tunnelCtx, cancelTunnels := context.WithCancel(ctx) + defer cancelTunnels() + + sshForwarder := ssh.NewForwarder(sshCon.Client, "2048", "22", a.cfg.WorkstationIP) + rdpForwarder := ssh.NewForwarder(sshCon.Client, "6000", "3389", a.cfg.WorkstationIP) + go func() { - 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)) + if err := sshForwarder.Start(tunnelCtx); err != nil { + slog.Error("SSH forwarder stopped", "error", err) + } + }() + go func() { + if err := rdpForwarder.Start(tunnelCtx); err != nil { + slog.Error("RDP forwarder stopped", "error", err) } - slog.Info("SSH forwarder stopped.") }() - 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("ERROR: RDP forwarder failed: %v", err)) - } - slog.Info("RDP forwarder stopped.") - }() + time.Sleep(200 * time.Millisecond) - time.Sleep(500 * time.Millisecond) + if a.flags.StartInBackground { + fmt.Println("\nINFO: Tunnels are active in background.") + fmt.Println(" Connect manually via SSH: ssh -p 2048 @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.") + } - return sshCon, nil + 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 } func (a *App) runCommand(name string, args ...string) error { - slog.Info(fmt.Sprintf("Executing command: %s %s", name, strings.Join(args, " "))) + slog.Info("Executing command", "cmd", name, "args", args) 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(fmt.Sprintf("Command failed: %s %s -> %v", name, strings.Join(args, " "), err)) - return fmt.Errorf("command execution failed: %w", err) + if err := cmd.Run(); err != nil { + slog.Error("Command failed", "cmd", name, "error", err) + return err } - slog.Info(fmt.Sprintf("Command finished successfully: %s", name)) return nil } -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 { - return - } -} - -func (a *App) startRDPConnection() { - slog.Info("Starting RDP connection to localhost:6000...") - - args := []string{ - fmt.Sprintf("/u:%s", a.cfg.RDPUser), - fmt.Sprintf("/p:%s", a.cfg.SSHPassword), - "/v:127.0.0.1:6000", - "/size:3000x1350", - "+clipboard", - "/dynamic-resolution", - } - - if err := a.runCommand("xfreerdp", args...); err != nil { - return - } -} - -func (a *App) makeChoice() { +func (a *App) makeChoice(ctx context.Context) error { var choice string form := huh.NewForm( @@ -201,65 +168,47 @@ func (a *App) makeChoice() { 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 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("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("Wake Workstation", "wake workstation"), - huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"), + huh.NewOption("Kill Active Tunnels", "kill tunnels"), + huh.NewOption("Config: Set Secrets", "set secrets"), huh.NewOption("Exit", "exit"), ). Value(&choice), ), ) - 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 + if err := form.Run(); err != nil { + return nil } switch choice { case "start work": - a.connect(withoutTimew) + return a.connect(ctx) case "stop work": - if err := a.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)) + if err := a.StopTracking(ctx); err != nil { + slog.Error("Failed to stop time tracking", "error", err) } + _ = a.killForwardings() case "start break": - if err := a.StartTracking(TagBreak, withoutTimew); err != nil { - slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) + if err := a.StartTracking(ctx, store.TagBreak); err != nil { + slog.Error("Failed to start break", "error", err) } case "stop break": - if err := a.StartTracking(TagWork, withoutTimew); err != nil { - slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err)) + if err := a.StartTracking(ctx, store.TagWork); err != nil { + slog.Error("Failed to stop break", "error", err) } case "show day summary": - if err := a.timeStore.ShowSummary("today"); err != nil { - slog.Error(fmt.Sprintf("Failed to show day summary: %v", err)) - } + _ = a.store.ShowSummary(ctx, "today") case "show week summary": - if err := a.timeStore.ShowSummary("week"); err != nil { - slog.Error(fmt.Sprintf("ERROR: Failed to show week summary: %v", err)) - } + _ = a.store.ShowSummary(ctx, "week") case "show month summary": - if err := a.timeStore.ShowSummary("month"); err != nil { - slog.Error(fmt.Sprintf("Failed to show month summary: %v", err)) - } + _ = a.store.ShowSummary(ctx, "month") case "export": filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" - 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)) - } + _ = a.store.ExportSummary(ctx, filename) case "connect to jump": a.connectToJump() case "connect to workstation": @@ -269,98 +218,91 @@ func (a *App) makeChoice() { case "wake workstation": a.wakeWorkstation() case "kill tunnels": - 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.") - } + _ = a.killForwardings() + case "set secrets": + _ = a.configCommand() + fmt.Println("Please run 'workctl config set-secrets' directly from CLI.") case "exit": - fmt.Println("Exiting.") - return - default: - slog.Warn(fmt.Sprintf("Unhandled choice '%s'", choice)) - } - - if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { - fmt.Println("\nPress Enter to continue...") - fmt.Scanln() - a.makeChoice() - } -} - -func (a *App) getSSHAuth() ssh.AuthMethod { - keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg") - - keyBytes, err := os.ReadFile(keyPath) - if err != nil { - slog.Error(fmt.Sprintf("Unable to read private key '%s': %v", keyPath, err)) return nil } - var key ssh.Signer - key, err = ssh.ParsePrivateKey(keyBytes) + if choice != "exit" && choice != "start work" { + fmt.Println("\nPress Enter to continue...") + fmt.Scanln() + return a.makeChoice(ctx) + } + return nil +} + +func (a *App) getSSHAuth() sshPkg.AuthMethod { + keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg") + keyBytes, err := os.ReadFile(keyPath) if err != nil { - 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)) + slog.Error("Unable to read private key", "path", keyPath, "error", err) + return nil + } + + key, err := sshPkg.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 err != nil { - slog.Error(fmt.Sprintf("Unable to parse private key '%s' with passphrase: %v", keyPath, err)) + slog.Error("Failed to parse key with passphrase", "error", err) return nil } } else { - slog.Error(fmt.Sprintf("Unable to parse private key '%s': %v", keyPath, err)) + slog.Error("Failed to parse private key", "error", err) return nil } } - - slog.Info(fmt.Sprintf("Successfully loaded private key '%s'", keyPath)) - return ssh.PublicKeys(key) + return sshPkg.PublicKeys(key) } -func (a *App) newSSHConnection() (*SSHConnection, error) { - authMethod := a.getSSHAuth() - if authMethod == nil { - return nil, fmt.Errorf("SSH authentication method could not be obtained") +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, } + _ = a.runCommand("ssh", args...) +} - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home dir: %w", err) +func (a *App) connectToJump() { + args := []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), } - knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts") + _ = a.runCommand("ssh", args...) +} - hostKeyCallback, err := knownhosts.New(knownHostsPath) - if err != nil { - slog.Warn("Could not load known_hosts file. Please ensure you have connected to the host manually once to populate it.", "path", knownHostsPath) - return nil, fmt.Errorf("failed to create host key callback (check your known_hosts file): %w", err) +func (a *App) connectToWorkstation() { + args := []string{ + "-tt", + "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), + "-p", "2048", + fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), } + _ = a.runCommand("ssh", args...) +} - sshConfig := &ssh.ClientConfig{ - User: a.cfg.SSHUser, - Auth: []ssh.AuthMethod{authMethod}, - HostKeyCallback: hostKeyCallback, - Timeout: 10 * time.Second, +func (a *App) startRDPConnection() { + args := []string{ + fmt.Sprintf("/u:%s", a.cfg.RDPUser), + fmt.Sprintf("/p:%s", a.cfg.RDPPassword), + "/v:127.0.0.1:6000", + "/size:3000x1350", + "+clipboard", + "/dynamic-resolution", } - - target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) - slog.Info(fmt.Sprintf("Dialing SSH to %s...", target)) - - client, err := ssh.Dial("tcp", target, sshConfig) - if err != nil { - return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err) - } - slog.Info(fmt.Sprintf("SSH connection to %s successful.", target)) - - session, err := client.NewSession() - if err != nil { - client.Close() - return nil, fmt.Errorf("failed to create SSH session check: %w", err) - } - session.Close() - - return &SSHConnection{ - client: client, - }, nil + _ = a.runCommand("xfreerdp", args...) } func (a *App) killForwardings() error { diff --git a/cmd.go b/cmd.go index c53a0ec..3e344cf 100644 --- a/cmd.go +++ b/cmd.go @@ -4,17 +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", @@ -71,14 +70,14 @@ func (a *App) configCommand() *cobra.Command { } if sshPw != "" { - if err := setSecret(keySSHPassword, sshPw); err != nil { + 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 := setSecret(keyRDPPassword, rdpPw); err != nil { + 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.") @@ -100,64 +99,15 @@ func (a *App) startCommand() *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. 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.`, +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.`, RunE: func(cmd *cobra.Command, args []string) error { - 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 @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.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.StopTracking(withoutTimew); err != nil { - slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) - } else { - slog.Info("Time tracking stopped.") - } - } - - return nil + return a.connect(cmd.Context()) }, } cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately") - cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") + cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") return cmd } @@ -169,7 +119,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(withoutTimew); err != nil { + if err := a.StopTracking(cmd.Context()); err != nil { slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { fmt.Println("Time tracking stopped.") @@ -184,7 +134,7 @@ func (a *App) stopCommand() *cobra.Command { fmt.Println("Workday stop procedures finished.") }, } - cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") + cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") return cmd } @@ -201,7 +151,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 := TagWork + tag := store.TagWork if len(args) > 0 { tag = args[0] } @@ -216,14 +166,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.timeStore.LogFullDay(tagLower, today); err != nil { + if err := a.store.LogFullDay(cmd.Context(), 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(tag, withoutTimew); err != nil { + if err := a.StartTracking(cmd.Context(), tag); 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) } @@ -237,7 +187,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(TagBreak, withoutTimew); err != nil { + if err := a.StartTracking(cmd.Context(), store.TagBreak); err != nil { slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) return fmt.Errorf("could not start break: %w", err) } @@ -271,23 +221,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.timeStore.ExportSummary(filename); err != nil { + if err := a.store.ExportSummary(cmd.Context(), 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.timeStore.ShowSummary("week"); err != nil { + if err := a.store.ShowSummary(cmd.Context(), "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.timeStore.ShowSummary("month"); err != nil { + if err := a.store.ShowSummary(cmd.Context(), "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.timeStore.ShowSummary(period); err != nil { + if err := a.store.ShowSummary(cmd.Context(), period); err != nil { slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err)) } } @@ -311,7 +261,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.timeStore.ExportSummary(filename); err != nil { + if err := a.store.ExportSummary(cmd.Context(), 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) } @@ -343,7 +293,6 @@ 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() }, @@ -352,7 +301,6 @@ 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() }, @@ -361,7 +309,6 @@ 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() }, @@ -374,11 +321,7 @@ func (a *App) importTimewarriorCommand() *cobra.Command { cmd := &cobra.Command{ Use: "import-timew [filepath]", Short: "Import time entries from 'timewarrior summary' output file", - 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), + 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) @@ -405,7 +348,7 @@ func (a *App) runImport(filepath string) (int, error) { content := string(contentBytes) lines := strings.Split(content, "\n") - tx, err := a.timeStore.db.Begin() + tx, err := a.store.DB().Begin() if err != nil { return 0, fmt.Errorf("could not begin database transaction: %w", err) } @@ -500,9 +443,9 @@ func (a *App) runImport(filepath string) (int, error) { dbTag := strings.ToLower(tag) switch dbTag { case "work": - dbTag = TagWork + dbTag = store.TagWork case "break": - dbTag = TagBreak + dbTag = store.TagBreak } _, err = stmt.Exec(dbTag, startTime, endTime) diff --git a/forwarder.go b/forwarder.go deleted file mode 100644 index 09764be..0000000 --- a/forwarder.go +++ /dev/null @@ -1,108 +0,0 @@ -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...) - } -} diff --git a/config.go b/internal/config/config.go similarity index 72% rename from config.go rename to internal/config/config.go index 85a0bff..1e75913 100644 --- a/config.go +++ b/internal/config/config.go @@ -1,4 +1,4 @@ -package main +package config import ( "fmt" @@ -7,6 +7,13 @@ import ( "path/filepath" "github.com/spf13/viper" + "github.com/zalando/go-keyring" +) + +const ( + serviceName = "workctl" + keySSHPassword = "ssh-password" + keyRDPPassword = "rdp-password" ) type Config struct { @@ -22,18 +29,9 @@ type Config struct { RDPPassword string `mapstructure:"RDP_PASSWORD"` WorkstationIP string `mapstructure:"WORKSTATION_IP"` SSHPort int `mapstructure:"SSH_PORT"` - // DatabasePath string `mapstructure:"DATABASE_PATH"` } -type Flags struct { - ShowWeek bool - ShowMonth bool - ShowExport bool - ExportName string - StartInBackground bool -} - -func loadConfig() (Config, error) { +func Load() (Config, error) { var cfg Config configPath, err := os.UserConfigDir() if err != nil { @@ -55,7 +53,7 @@ func loadConfig() (Config, error) { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err) } - slog.Info(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile)) + slog.Debug(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile)) } if err := viper.UnmarshalKey("default", &cfg); err != nil { @@ -69,13 +67,13 @@ func loadConfig() (Config, error) { } if cfg.SSHPassword == "" { - if secret, err := getSecret(keySSHPassword); err == nil { + 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 { + if secret, err := GetSecret(keyRDPPassword); err == nil { cfg.RDPPassword = secret slog.Debug("Loaded RDP password from keyring.") } @@ -83,3 +81,17 @@ func loadConfig() (Config, error) { 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 } diff --git a/internal/ssh/client.go b/internal/ssh/client.go new file mode 100644 index 0000000..2e55c4a --- /dev/null +++ b/internal/ssh/client.go @@ -0,0 +1,55 @@ +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 +} diff --git a/internal/ssh/forwarder.go b/internal/ssh/forwarder.go new file mode 100644 index 0000000..281d25e --- /dev/null +++ b/internal/ssh/forwarder.go @@ -0,0 +1,103 @@ +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) 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) + } + + 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(): + } +} diff --git a/export.go b/internal/store/export.go similarity index 83% rename from export.go rename to internal/store/export.go index 347c3a5..96e395e 100644 --- a/export.go +++ b/internal/store/export.go @@ -1,6 +1,7 @@ -package main +package store import ( + "context" "fmt" "log/slog" "sort" @@ -29,6 +30,63 @@ 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() @@ -55,14 +113,13 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti for _, entry := range entries { if entry.StartTime.IsZero() { - slog.Warn(fmt.Sprintf("Skipping entry with zero start time (ID: %d)", entry.ID)) + slog.Warn("Skipping entry with zero start time", "ID", entry.ID) continue } startTime := entry.StartTime.In(location) endTime := entry.EndTime.Time.In(location) - validEndTime := entry.EndTime.Valid - if !validEndTime { + if !entry.EndTime.Valid { endTime = now } @@ -99,7 +156,6 @@ 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 } @@ -132,7 +188,6 @@ 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 } @@ -140,7 +195,6 @@ 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 @@ -157,7 +211,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti summary.Tag = TagWork } } - loopTime = dayEnd } } @@ -230,23 +283,6 @@ 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 { @@ -270,7 +306,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error { f := excelize.NewFile() defer func() { if err := f.Close(); err != nil { - slog.Error(fmt.Sprintf("Failed to close excel file handle: %v", err)) + slog.Error("Failed to close excel file handle", "error", err) } }() @@ -288,7 +324,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error { sheetName = "Sheet1" index, _ = f.GetSheetIndex(sheetName) if index == -1 { - return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err) + return fmt.Errorf("could not create sheet '%s': %w", sheetName, err) } } else { index = existingIndex @@ -327,6 +363,10 @@ 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" @@ -370,8 +410,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 := 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) + startExcelTime := toExcelTime(startTime) + endExcelTime := toExcelTime(endTime) if endExcelTime < startExcelTime { endExcelTime += 1.0 } @@ -382,10 +422,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) // Saldo-Style für Dauer + f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle) breakDur, _ := time.Parse("15:04:05", entry.BreakDuration) - breakExcelTime := float64(breakDur.Hour())/24.0 + float64(breakDur.Minute())/(24.0*60.0) + float64(breakDur.Second())/(24.0*60.0*60.0) + breakExcelTime := toExcelTime(breakDur) thirtyMinBreak := float64(30) / (24 * 60) if breakExcelTime < thirtyMinBreak { breakExcelTime = thirtyMinBreak diff --git a/store.go b/internal/store/store.go similarity index 51% rename from store.go rename to internal/store/store.go index 12e0b94..0fc656f 100644 --- a/store.go +++ b/internal/store/store.go @@ -1,6 +1,7 @@ -package main +package store import ( + "context" "database/sql" "fmt" "log/slog" @@ -26,18 +27,18 @@ type TimeEntry struct { EndTime sql.NullTime } -type TimeStore struct { +type Store struct { db *sql.DB dbPath string } -func NewTimeStore(cfg Config) (*TimeStore, error) { - dbPath, err := ensureDatabasePath(cfg) +func NewStore() (*Store, error) { + dbPath, err := ensureDatabasePath() if err != nil { return nil, fmt.Errorf("could not determine database path: %w", err) } - slog.Info("Using database at:", "Database Path", dbPath) + slog.Debug("Using database at:", "path", dbPath) db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath)) if err != nil { @@ -49,77 +50,75 @@ func NewTimeStore(cfg Config) (*TimeStore, 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 <> ''), -- Stelle sicher, dass Tag nicht leer ist + tag TEXT NOT NULL CHECK(tag <> ''), start_time DATETIME NOT NULL, end_time DATETIME NULL, - -- 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 + CHECK (end_time IS NULL OR end_time >= start_time) );` - if _, err = db.Exec(createTableSQL); err != nil { - db.Close() - return nil, fmt.Errorf("failed to create table 'time_entries': %w", err) + if _, err := db.Exec(createTableSQL); err != nil { + return 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 &TimeStore{db: db, dbPath: dbPath}, nil + return nil } -func ensureDatabasePath(_ Config) (string, error) { +func ensureDatabasePath() (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 dbPath, nil + return filepath.Join(workConfigDir, "worktime.sqlite"), nil } -func (ts *TimeStore) Close() error { - if ts.db != nil { - slog.Info("Closing database connection", "Database Path", ts.dbPath) - return ts.db.Close() +func (s *Store) Close() error { + if s.db != nil { + slog.Debug("Closing database connection", "path", s.dbPath) + return s.db.Close() } return nil } -func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { +func (s *Store) stopCurrentEntry(ctx context.Context, now time.Time) (bool, error) { query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;` - result, err := ts.db.Exec(query, now) + result, err := s.db.ExecContext(ctx, 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 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 false, fmt.Errorf("failed to get affected rows: %w", err) } return rowsAffected > 0, nil } -func (ts *TimeStore) StartTracking(tag string) error { +func (s *Store) StartTracking(ctx context.Context, tag string) error { if tag == "" { return fmt.Errorf("cannot start tracking with an empty tag") } now := time.Now() - stopped, err := ts.stopCurrentEntry(now) + stopped, err := s.stopCurrentEntry(ctx, now) if err != nil { return err } @@ -128,17 +127,17 @@ func (ts *TimeStore) StartTracking(tag string) error { } query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` - _, err = ts.db.Exec(query, tag, now) + _, err = s.db.ExecContext(ctx, 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 at %s", tag, now.Format(time.RFC3339))) + slog.Info(fmt.Sprintf("Started tracking: %s", tag)) return nil } -func (ts *TimeStore) StopTracking() error { +func (s *Store) StopTracking(ctx context.Context) error { now := time.Now() - stopped, err := ts.stopCurrentEntry(now) + stopped, err := s.stopCurrentEntry(ctx, now) if err != nil { return err } @@ -150,7 +149,42 @@ func (ts *TimeStore) StopTracking() error { return nil } -func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) { +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) { if start.IsZero() || end.IsZero() || end.Before(start) { return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end) } @@ -161,7 +195,7 @@ func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error WHERE start_time >= ? AND start_time < ? ORDER BY start_time ASC;` - rows, err := ts.db.Query(query, start, end) + rows, err := s.db.QueryContext(ctx, query, start, end) if err != nil { return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err) } @@ -183,8 +217,8 @@ func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error return entries, nil } -func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) { - start, end := getTimeRangeFromPeriod(period) +func (s *Store) CalculateSummary(ctx context.Context, period string) (map[string]time.Duration, error) { + start, end := GetTimeRangeFromPeriod(period) if start.IsZero() { return nil, fmt.Errorf("invalid period string: '%s'", period) } @@ -192,13 +226,13 @@ func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, query := ` SELECT id, tag, start_time, end_time FROM time_entries - WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums - AND start_time < ? -- Beginnt vor dem Ende des Zeitraums + WHERE (end_time IS NULL OR end_time > ?) + AND start_time < ? ORDER BY start_time ASC;` - rows, err := ts.db.Query(query, start, end) + rows, err := s.db.QueryContext(ctx, query, start, end) if err != nil { - return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err) + return nil, fmt.Errorf("failed to query entries: %w", err) } defer rows.Close() @@ -208,86 +242,38 @@ func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, 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 overlapping entry row: %w", err) + return nil, fmt.Errorf("failed to scan entry: %w", err) } - effectiveStart := entry.StartTime - if effectiveStart.Before(start) { - effectiveStart = start + 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 } - 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 + if effEnd.After(effStart) { + summary[entry.Tag] += effEnd.Sub(effStart) } } - - if err = rows.Err(); err != nil { - return nil, fmt.Errorf("error during overlapping row iteration: %w", err) - } - - return summary, nil + return summary, rows.Err() } -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) +func (s *Store) ShowSummary(ctx context.Context, period string) error { + summary, err := s.CalculateSummary(ctx, period) if err != nil { - return fmt.Errorf("error calculating summary for '%s': %w", period, err) + return 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" { @@ -328,109 +314,63 @@ func (ts *TimeStore) ShowSummary(period string) error { return nil } -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) +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) } - defer rows.Close() + 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) +} - 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) +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 } - entries = append(entries, entry) + 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{} } - 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 (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 +func (s *Store) DB() *sql.DB { + return s.db } diff --git a/main.go b/main.go index 8067e3c..4d4140b 100644 --- a/main.go +++ b/main.go @@ -1,43 +1,50 @@ package main import ( + "context" + "fmt" "log/slog" "os" + "os/signal" "path/filepath" + "syscall" ) func main() { configDir, err := os.UserConfigDir() if err != nil { - slog.Error("Cant get user config dir") - panic(err) + fmt.Fprintf(os.Stderr, "Error getting config dir: %v\n", err) + os.Exit(1) } + logFile := filepath.Join(configDir, "work", "workctl.log") + _ = os.MkdirAll(filepath.Dir(logFile), 0750) - file, err := os.OpenFile(filepath.Join(configDir, "work", "workctl.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) if err != nil { - panic(err) + fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err) + os.Exit(1) } 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) + slog.Error("Unable to setup application", "error", err) + fmt.Fprintf(os.Stderr, "Error setting up application: %v\n", 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 len(os.Args) > 1 { - if err := app.setupCommands().Execute(); err != nil { - os.Exit(1) - } - } else { - app.makeChoice() + if err := app.Execute(ctx); err != nil { + os.Exit(1) } } diff --git a/ssh.go b/ssh.go deleted file mode 100644 index c1d6a05..0000000 --- a/ssh.go +++ /dev/null @@ -1,19 +0,0 @@ -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 -} From 5de9ff7961b9df81d600348c9e445d5e688d00ab Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 11 Jan 2026 11:44:45 +0100 Subject: [PATCH 17/17] feat: implement ready chanel to wait for established connection --- app.go | 41 +++++++++++++++++++++++++++++---------- internal/config/config.go | 5 +++++ internal/ssh/forwarder.go | 6 +++++- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app.go b/app.go index 64d36e1..8ff9874 100644 --- a/app.go +++ b/app.go @@ -101,21 +101,43 @@ func (a *App) connect(ctx context.Context) error { tunnelCtx, cancelTunnels := context.WithCancel(ctx) defer cancelTunnels() - sshForwarder := ssh.NewForwarder(sshCon.Client, "2048", "22", a.cfg.WorkstationIP) - rdpForwarder := ssh.NewForwarder(sshCon.Client, "6000", "3389", a.cfg.WorkstationIP) + 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{}) go func() { - if err := sshForwarder.Start(tunnelCtx); err != nil { + if err := sshForwarder.Start(tunnelCtx, sshReady); err != nil { slog.Error("SSH forwarder stopped", "error", err) } }() go func() { - if err := rdpForwarder.Start(tunnelCtx); err != nil { + if err := rdpForwarder.Start(tunnelCtx, rdpReady); err != nil { slog.Error("RDP forwarder stopped", "error", err) } }() - time.Sleep(200 * time.Millisecond) + slog.Info("Waiting for tunnels to initialize...") + + readyCtx, cancelReady := context.WithTimeout(ctx, 5*time.Second) + defer cancelReady() + + 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.") @@ -220,7 +242,6 @@ func (a *App) makeChoice(ctx context.Context) error { case "kill tunnels": _ = a.killForwardings() case "set secrets": - _ = a.configCommand() fmt.Println("Please run 'workctl config set-secrets' directly from CLI.") case "exit": return nil @@ -276,7 +297,7 @@ func (a *App) wakeWorkstation() { func (a *App) connectToJump() { args := []string{ "-tt", - "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), + "-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), } @@ -286,8 +307,8 @@ func (a *App) connectToJump() { func (a *App) connectToWorkstation() { args := []string{ "-tt", - "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), - "-p", "2048", + "-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...) @@ -297,7 +318,7 @@ func (a *App) startRDPConnection() { args := []string{ fmt.Sprintf("/u:%s", a.cfg.RDPUser), fmt.Sprintf("/p:%s", a.cfg.RDPPassword), - "/v:127.0.0.1:6000", + fmt.Sprintf("/v:127.0.0.1:%s", config.PortLocalRDP), "/size:3000x1350", "+clipboard", "/dynamic-resolution", diff --git a/internal/config/config.go b/internal/config/config.go index 1e75913..17c7240 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,11 @@ const ( serviceName = "workctl" keySSHPassword = "ssh-password" keyRDPPassword = "rdp-password" + + PortLocalSSH = "2048" + PortLocalRDP = "6000" + PortRemoteSSH = "22" + PortRemoteRDP = "3389" ) type Config struct { diff --git a/internal/ssh/forwarder.go b/internal/ssh/forwarder.go index 281d25e..803ca09 100644 --- a/internal/ssh/forwarder.go +++ b/internal/ssh/forwarder.go @@ -28,7 +28,7 @@ func NewForwarder(client *ssh.Client, localPort, remotePort, remoteHost string) } } -func (f *Forwarder) Start(ctx context.Context) error { +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) @@ -37,6 +37,10 @@ func (f *Forwarder) Start(ctx context.Context) error { return fmt.Errorf("failed to listen on %s: %w", localAddr, err) } + if ready != nil { + close(ready) + } + go func() { <-ctx.Done() listener.Close()