feat: init command, README, fix config creation, move cicd
- cmd_init.go: interactive setup wizard (gosleep-timer init) - README.md: comprehensive documentation - config/config.go: fix os.IsNotExist → errors.Is for wrapped err - main.go: remove '(no TUI)' from run help - cicd.yaml moved to .github/workflows/cicd.yaml
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
# ⏱ gosleep-timer
|
||||||
|
|
||||||
|
TUI sleep timer with modular WM/DE support. Go rewrite of `timer.py`.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run TUI
|
||||||
|
gosleep-timer
|
||||||
|
|
||||||
|
# First time? Interactive setup wizard
|
||||||
|
gosleep-timer init
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/forust/gosleep-timer@latest
|
||||||
|
# or
|
||||||
|
git clone https://github.com/forust/gosleep-timer
|
||||||
|
cd gosleep-timer
|
||||||
|
go build -o gosleep-timer .
|
||||||
|
sudo cp gosleep-timer /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### TUI mode (default)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gosleep-timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys:
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `s` | Start timer |
|
||||||
|
| `S` | Stop timer |
|
||||||
|
| `Tab` | Next field |
|
||||||
|
| `↑`/`↓` | Previous/next profile |
|
||||||
|
| `1`-`0` | Toggle modules |
|
||||||
|
| `h` | History |
|
||||||
|
| `?` | Help |
|
||||||
|
| `q`/`Esc` | Quit |
|
||||||
|
|
||||||
|
### CLI mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with defaults (25m)
|
||||||
|
gosleep-timer run
|
||||||
|
|
||||||
|
# Custom duration
|
||||||
|
gosleep-timer run 45m
|
||||||
|
gosleep-timer run 1h30m
|
||||||
|
|
||||||
|
# With profile
|
||||||
|
gosleep-timer run 20m --profile work
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive setup
|
||||||
|
gosleep-timer init
|
||||||
|
|
||||||
|
# List profiles
|
||||||
|
gosleep-timer list-profiles
|
||||||
|
|
||||||
|
# Export/import config
|
||||||
|
gosleep-timer export > config-backup.yaml
|
||||||
|
gosleep-timer import < config-backup.yaml
|
||||||
|
|
||||||
|
# History and stats
|
||||||
|
gosleep-timer history
|
||||||
|
gosleep-timer stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
File: `~/.config/gosleep-timer/config.yaml`
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
profiles:
|
||||||
|
default:
|
||||||
|
modules:
|
||||||
|
workspace:
|
||||||
|
enabled: true
|
||||||
|
backend: auto # auto, niri, hyprland, kde
|
||||||
|
workspace: 12
|
||||||
|
media:
|
||||||
|
enabled: true
|
||||||
|
action: stop # stop, pause, play-pause, next, previous
|
||||||
|
lock:
|
||||||
|
enabled: true
|
||||||
|
command: "" # custom command or auto-detect
|
||||||
|
kill:
|
||||||
|
enabled: false
|
||||||
|
processes: [firefox, telegram-desktop]
|
||||||
|
notify:
|
||||||
|
enabled: true
|
||||||
|
sound: "" # path or empty
|
||||||
|
sound:
|
||||||
|
enabled: true
|
||||||
|
file: /usr/share/sounds/freedesktop/stereo/complete.oga
|
||||||
|
brightness:
|
||||||
|
enabled: false
|
||||||
|
value: 0
|
||||||
|
mute:
|
||||||
|
enabled: false
|
||||||
|
custom:
|
||||||
|
enabled: false
|
||||||
|
pre: []
|
||||||
|
post: []
|
||||||
|
script:
|
||||||
|
enabled: false
|
||||||
|
path: ""
|
||||||
|
|
||||||
|
work:
|
||||||
|
extends: default
|
||||||
|
modules:
|
||||||
|
workspace:
|
||||||
|
workspace: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile inheritance
|
||||||
|
|
||||||
|
Profiles can extend others. Modules from the base profile are merged and overlaid.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Stage | Description |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| **workspace** | pre | Switch WM workspace (niri/Hyprland/KDE) |
|
||||||
|
| **media** | pre | Control music player via playerctl |
|
||||||
|
| **lock** | post | Lock screen |
|
||||||
|
| **kill** | pre | Kill processes by name |
|
||||||
|
| **notify** | post | Desktop notification |
|
||||||
|
| **sound** | post | Play audio file |
|
||||||
|
| **brightness** | pre/post | Dim/restore brightness |
|
||||||
|
| **mute** | pre/post | Mute/unmute audio |
|
||||||
|
| **custom** | pre/post | User-defined shell commands |
|
||||||
|
| **script** | pre/post | Run external script with args |
|
||||||
|
|
||||||
|
### Workspace backends
|
||||||
|
|
||||||
|
| Backend | Command |
|
||||||
|
|---------|---------|
|
||||||
|
| niri | `niri msg action focus-workspace {n}` |
|
||||||
|
| Hyprland | `hyprctl dispatch workspace {n}` |
|
||||||
|
| KDE | `qdbus org.kde.KWin /KWin setCurrentDesktop {n}` |
|
||||||
|
|
||||||
|
Auto-detection checks `$XDG_CURRENT_DESKTOP`, then tries `hyprctl` and `niri msg`.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gosleep-timer/
|
||||||
|
├── main.go # CLI entrypoint
|
||||||
|
├── cmd_init.go # Interactive setup wizard
|
||||||
|
├── config/ # YAML config load/save/types
|
||||||
|
├── engine/ # Timer core, executor, stages
|
||||||
|
├── modules/ # Module interface + 10 implementations
|
||||||
|
├── profiles/ # Profile manager
|
||||||
|
├── history/ # JSONL log + statistics
|
||||||
|
├── tui/ # Bubbletea TUI app
|
||||||
|
├── cicd.yaml # CI/CD pipeline
|
||||||
|
└── Dockerfile # Multi-stage build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o gosleep-timer .
|
||||||
|
|
||||||
|
# Test
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
golangci-lint run --config .golangci.yml
|
||||||
|
|
||||||
|
# Static analysis
|
||||||
|
staticcheck ./...
|
||||||
|
gosec ./...
|
||||||
|
revive -config .revive.toml ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Pipeline (`.github/workflows/cicd.yaml`):
|
||||||
|
- **lint-go**: golangci-lint, staticcheck, gosec, revive
|
||||||
|
- **lint-yaml**: yamllint
|
||||||
|
- **build**: go build + test with race detector
|
||||||
|
- **publish**: Docker image to registry (main/dev branches)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/forust/gosleep-timer/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var initCmd = &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Interactive config setup wizard",
|
||||||
|
Long: `Creates a new config.yaml with interactive prompts.
|
||||||
|
Walks through all modules and profiles step by step.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfgPath, err := getConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if config already exists
|
||||||
|
if _, err := os.Stat(cfgPath); err == nil {
|
||||||
|
fmt.Printf("Config already exists at %s\n", cfgPath)
|
||||||
|
fmt.Print("Overwrite? [y/N]: ")
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
answer, _ := reader.ReadString('\n')
|
||||||
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||||
|
if answer != "y" && answer != "yes" {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := runWizard()
|
||||||
|
if cfg == nil {
|
||||||
|
fmt.Println("\nSetup aborted.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(cfgPath, cfg); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n✅ Config saved to %s\n", cfgPath)
|
||||||
|
fmt.Println("Run 'gosleep-timer' to start the TUI.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(initCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWizard() *config.Config {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
p := &config.Profile{}
|
||||||
|
|
||||||
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
|
fmt.Println(" ⏱ gosleep-timer Setup Wizard")
|
||||||
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
|
|
||||||
|
// Profile name
|
||||||
|
fmt.Print("\nProfile name [default]: ")
|
||||||
|
name := readLine(reader, "default")
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
fmt.Print("Default duration (e.g. 25m, 1h, 90s) [25m]: ")
|
||||||
|
dur := readLine(reader, "25m")
|
||||||
|
|
||||||
|
// Workspace module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Workspace — switch to a workspace on timer start")
|
||||||
|
wsEnabled := askYesNo(reader, "Enable workspace switch?", true)
|
||||||
|
ws := &config.WorkspaceConfig{Enabled: wsEnabled}
|
||||||
|
if wsEnabled {
|
||||||
|
fmt.Print("Workspace number [12]: ")
|
||||||
|
wsStr := readLine(reader, "12")
|
||||||
|
ws.Workspace, _ = strconv.Atoi(wsStr)
|
||||||
|
if ws.Workspace == 0 {
|
||||||
|
ws.Workspace = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Backend (auto-detect / niri / hyprland / kde):")
|
||||||
|
fmt.Print(" [auto]: ")
|
||||||
|
ws.Backend = readLine(reader, "auto")
|
||||||
|
if ws.Backend == "auto" {
|
||||||
|
ws.Backend = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Media — control music player on timer start")
|
||||||
|
mediaEnabled := askYesNo(reader, "Enable media control?", true)
|
||||||
|
media := &config.MediaConfig{Enabled: mediaEnabled}
|
||||||
|
if mediaEnabled {
|
||||||
|
fmt.Println("Player action (stop / pause / play-pause / next / previous / none):")
|
||||||
|
fmt.Print(" [stop]: ")
|
||||||
|
action := readLine(reader, "stop")
|
||||||
|
media.Action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Lock — lock screen when timer finishes")
|
||||||
|
lockEnabled := askYesNo(reader, "Enable screen lock?", true)
|
||||||
|
lock := &config.LockConfig{Enabled: lockEnabled}
|
||||||
|
if lockEnabled {
|
||||||
|
fmt.Print("Custom lock command (leave empty for auto): ")
|
||||||
|
cmd := readLine(reader, "")
|
||||||
|
lock.Command = cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Kill — terminate apps when timer starts")
|
||||||
|
killEnabled := askYesNo(reader, "Enable process killer?", false)
|
||||||
|
kill := &config.KillConfig{Enabled: killEnabled}
|
||||||
|
if killEnabled {
|
||||||
|
fmt.Print("Processes to kill (comma separated, e.g. firefox,telegram): ")
|
||||||
|
procs := readLine(reader, "")
|
||||||
|
if procs != "" {
|
||||||
|
for _, p := range strings.Split(procs, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
kill.Processes = append(kill.Processes, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Notify — desktop notification when timer finishes")
|
||||||
|
notifyEnabled := askYesNo(reader, "Enable notifications?", true)
|
||||||
|
notify := &config.NotifyConfig{Enabled: notifyEnabled}
|
||||||
|
if notifyEnabled {
|
||||||
|
fmt.Print("Notification sound path (empty = no sound): ")
|
||||||
|
notify.Sound = readLine(reader, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Sound — play audio when timer finishes")
|
||||||
|
soundEnabled := askYesNo(reader, "Enable sound?", true)
|
||||||
|
sound := &config.SoundConfig{Enabled: soundEnabled}
|
||||||
|
if soundEnabled {
|
||||||
|
sound.File = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
||||||
|
fmt.Printf(" Default: %s\n", sound.File)
|
||||||
|
fmt.Print("Custom sound file (leave empty for default): ")
|
||||||
|
custom := readLine(reader, "")
|
||||||
|
if custom != "" {
|
||||||
|
sound.File = custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brightness module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Brightness — dim screen during timer")
|
||||||
|
brightnessEnabled := askYesNo(reader, "Enable brightness control?", false)
|
||||||
|
brightness := &config.BrightnessConfig{Enabled: brightnessEnabled}
|
||||||
|
if brightnessEnabled {
|
||||||
|
fmt.Print("Brightness level 0-100 [0]: ")
|
||||||
|
valStr := readLine(reader, "0")
|
||||||
|
val, _ := strconv.Atoi(valStr)
|
||||||
|
brightness.Value = val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mute module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Mute — mute audio during timer")
|
||||||
|
muteEnabled := askYesNo(reader, "Enable mute?", false)
|
||||||
|
mute := &config.MuteConfig{Enabled: muteEnabled}
|
||||||
|
|
||||||
|
// Custom commands
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Custom — run your own commands")
|
||||||
|
customEnabled := askYesNo(reader, "Enable custom commands?", false)
|
||||||
|
custom := &config.CustomConfig{Enabled: customEnabled}
|
||||||
|
if customEnabled {
|
||||||
|
fmt.Println("Pre-commands (run BEFORE timer, one per line, empty line to end):")
|
||||||
|
custom.Pre = readLines(reader, "pre")
|
||||||
|
fmt.Println("Post-commands (run AFTER timer, one per line, empty line to end):")
|
||||||
|
custom.Post = readLines(reader, "post")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script module
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Println("Script — run a script with timer context (args: pre/post, profile, duration)")
|
||||||
|
scriptEnabled := askYesNo(reader, "Enable script module?", false)
|
||||||
|
script := &config.ScriptConfig{Enabled: scriptEnabled}
|
||||||
|
if scriptEnabled {
|
||||||
|
fmt.Print("Script path: ")
|
||||||
|
script.Path = readLine(reader, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final config
|
||||||
|
p.Modules = &config.Modules{
|
||||||
|
Workspace: ws,
|
||||||
|
Media: media,
|
||||||
|
Lock: lock,
|
||||||
|
Kill: kill,
|
||||||
|
Notify: notify,
|
||||||
|
Sound: sound,
|
||||||
|
Brightness: brightness,
|
||||||
|
Mute: mute,
|
||||||
|
Custom: custom,
|
||||||
|
Script: script,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Profiles = map[string]config.Profile{
|
||||||
|
name: *p,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
|
fmt.Println(" ✅ Setup complete!")
|
||||||
|
fmt.Printf(" Profile: %s\n", name)
|
||||||
|
fmt.Printf(" Duration: %s\n", dur)
|
||||||
|
fmt.Println(strings.Repeat("=", 50))
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLine(reader *bufio.Reader, fallback string) string {
|
||||||
|
line, _ := reader.ReadString('\n')
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func askYesNo(reader *bufio.Reader, prompt string, defaultYes bool) bool {
|
||||||
|
suffix := "[Y/n]"
|
||||||
|
if !defaultYes {
|
||||||
|
suffix = "[y/N]"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s: ", prompt, suffix)
|
||||||
|
answer := strings.ToLower(strings.TrimSpace(readLine(reader, "")))
|
||||||
|
if answer == "" {
|
||||||
|
return defaultYes
|
||||||
|
}
|
||||||
|
return answer == "y" || answer == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLines(reader *bufio.Reader, label string) []string {
|
||||||
|
var lines []string
|
||||||
|
for {
|
||||||
|
fmt.Printf(" %s> ", label)
|
||||||
|
line, _ := reader.ReadString('\n')
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -70,7 +71,7 @@ func LoadOrCreate(path string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !os.IsNotExist(err) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func init() {
|
|||||||
|
|
||||||
runCmd := &cobra.Command{
|
runCmd := &cobra.Command{
|
||||||
Use: "run [duration]",
|
Use: "run [duration]",
|
||||||
Short: "Run timer in CLI mode (no TUI)",
|
Short: "Run timer in CLI mode",
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cliMode = true
|
cliMode = true
|
||||||
|
|||||||
Reference in New Issue
Block a user