Skip to content

Commit f11614f

Browse files
authored
Merge pull request #208 from elpatron68/master
Windows port
2 parents d96aa8d + 01e081b commit f11614f

File tree

10 files changed

+214
-34
lines changed

10 files changed

+214
-34
lines changed

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,37 @@ jobs:
4848
run: |
4949
go test -v -mod=vendor ./...
5050
./integrationtest.sh
51+
52+
windows-test:
53+
runs-on: windows-latest
54+
55+
steps:
56+
- name: Checkout code
57+
uses: actions/checkout@v4
58+
with:
59+
fetch-depth: 50
60+
61+
- name: Set up Go
62+
uses: actions/setup-go@v5
63+
with:
64+
go-version-file: go.mod
65+
66+
- name: Go mod vendor
67+
shell: pwsh
68+
run: go mod vendor
69+
70+
- name: Configure Git User
71+
shell: pwsh
72+
run: |
73+
git config --global user.name "GitHub Actions Bot"
74+
git config --global user.email "actions@github.com"
75+
76+
- name: Run Go tests
77+
shell: pwsh
78+
run: go test -v -mod=vendor ./...
79+
80+
- name: Build Windows binaries
81+
shell: pwsh
82+
run: |
83+
go build -mod=vendor -o dstask.exe ./cmd/dstask
84+
go build -mod=vendor -o dstask-import.exe ./cmd/dstask-import

README.md

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Features:
4545
- Task listing won't break with long task text
4646
- `note` command -- edit a **full markdown note** for each task. **Checklists are useful here.**
4747
- `open` command -- **open URLs found in specified task** (including notes) in the browser
48-
- zsh/bash completion (including tags and projects in current context) for speed
48+
- zsh/bash completion (including tags and projects in current context) for speed; PowerShell completion on Windows
4949
- A single statically-linked binary
5050
- [import tool](doc/dstask-import.md) which can import GitHub issues or taskwarrior tasks.
5151

@@ -105,10 +105,53 @@ Requirements:
105105

106106
# Installation
107107

108-
1. Copy the executable (from the [releases page][releases]) to somewhere in your path, named `dstask` and mark it executable. `/usr/local/bin/` is suggested.
109-
1. Enable bash completions by copying `source <(dstask bash-completion)` into your `.bashrc`. There's also a `zsh-completion` subcommand.
108+
1. Copy the executable (from the [releases page][releases]) to somewhere in your PATH.
109+
- Linux/macOS: name it `dstask` and mark it executable (e.g. `/usr/local/bin/`).
110+
- Windows: use `dstask.exe` and place it in a directory on `PATH` (e.g. `%USERPROFILE%\bin`).
111+
1. Enable shell completions:
112+
- Bash: add `source <(dstask bash-completion)` to your `.bashrc`.
113+
- Zsh: add `dstask zsh-completion > /usr/local/share/zsh/site-functions/_dstask` or source `completions/zsh.sh` in your `~/.zshrc`.
114+
- PowerShell (Windows): see section "PowerShell completion" below.
110115
1. Set up an alias in your `.bashrc`: `alias task=dstask` or `alias t=dstask` to make task management slightly faster.
111-
1. Create or clone a ~/.dstask git repository for the data, if you haven't already: `mkdir ~/.dstask && git -C ~/.dstask init`.
116+
1. Create or clone a `~/.dstask` git repository for the data, if you haven't already:
117+
- Linux/macOS: `mkdir ~/.dstask && git -C ~/.dstask init`
118+
- Windows: `mkdir %USERPROFILE%\.dstask` then `git -C %USERPROFILE%\.dstask init`
119+
120+
## Windows notes
121+
122+
- Default data location: `%USERPROFILE%\.dstask` (can be overridden via `DSTASK_GIT_REPO`).
123+
- On first run, dstask may prompt to create the repository if it doesn't exist; answer `y`/`yes` to proceed.
124+
- Terminal: use Windows Terminal or PowerShell with a 256-color capable profile for best results.
125+
- Shell completions: PowerShell completion is supported; see next section.
126+
127+
### PowerShell completion
128+
129+
To enable PowerShell tab completion for `dstask` on Windows, use the embedded script in `completions/powershell.ps1`.
130+
131+
One-off for the current session:
132+
133+
```powershell
134+
. ./completions/powershell.ps1
135+
```
136+
137+
Enable permanently by dot-sourcing from your PowerShell profile:
138+
139+
```powershell
140+
# Create a profile if it doesn't exist
141+
if (!(Test-Path -LiteralPath $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null }
142+
143+
# Open your profile for editing
144+
notepad $PROFILE
145+
146+
# Add the following line (adjust path if needed):
147+
. "$PSScriptRoot/completions/powershell.ps1"
148+
```
149+
150+
Alternatively, hardcode the absolute path to the repository if `PSScriptRoot` is not suitable for your setup, e.g.:
151+
152+
```powershell
153+
. "C:\\Users\\<you>\\source\\repos\\dstask-win-port\\completions\\powershell.ps1"
154+
```
112155

113156
There are also unofficial packages for:
114157

@@ -288,8 +331,12 @@ See [etc/PERFORMANCE.md](etc/PERFORMANCE.md)
288331

289332
See [etc/DATABASE_FORMAT.md](etc/DATABASE_FORMAT.md)
290333

291-
The default database location is `~/.dstask/`, but can be configured by the
292-
environment variable `DSTASK_GIT_REPO`.
334+
The default database location is:
335+
336+
- Linux/macOS: `~/.dstask/`
337+
- Windows: `%USERPROFILE%\\.dstask`
338+
339+
It can be configured by the environment variable `DSTASK_GIT_REPO`.
293340

294341
# Alternatives
295342

cmd/dstask-import/main.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"os"
6+
"path/filepath"
67

78
"github.com/naggie/dstask"
89
"github.com/naggie/dstask/pkg/imp/config"
@@ -49,8 +50,13 @@ func main() {
4950
dstask.ExitFail(err.Error())
5051
}
5152
case "github":
52-
repo := getEnv("DSTASK_GIT_REPO", os.ExpandEnv("$HOME/.dstask"))
53-
configFile := os.ExpandEnv("$HOME/.dstask-import.toml")
53+
// Determine platform-safe default paths
54+
home, err := os.UserHomeDir()
55+
if err != nil {
56+
home = os.Getenv("HOME")
57+
}
58+
repo := getEnv("DSTASK_GIT_REPO", filepath.Join(home, ".dstask"))
59+
configFile := filepath.Join(home, ".dstask-import.toml")
5460

5561
cfg, err := config.Load(configFile, repo)
5662
if err != nil {

completions/embed.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ var Bash string
1717
//
1818
//go:embed completions.fish
1919
var Fish string
20+
21+
// PowerShell completion script
22+
//
23+
//go:embed powershell.ps1
24+
var PowerShell string

completions/powershell.ps1

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Register PowerShell argument completer for dstask using the built-in completion engine.
2+
# It invokes `dstask _completions` with the current command line to get suggestions.
3+
4+
Register-ArgumentCompleter -Native -CommandName dstask,task -ScriptBlock {
5+
param($wordToComplete, $commandAst, $cursorPosition)
6+
7+
try {
8+
# Build args: dstask _completions <user command line>
9+
# We collect only argument tokens (ignore command name itself)
10+
$tokens = [System.Management.Automation.PSParser]::Tokenize($commandAst.Extent.Text, [ref]$null)
11+
$argTokens = $tokens | Where-Object { $_.Type -eq 'CommandArgument' } | ForEach-Object { $_.Content }
12+
$args = @('_completions') + $argTokens
13+
14+
$completions = & dstask @args 2>$null
15+
if (-not $completions) { return }
16+
17+
foreach ($c in $completions) {
18+
if ($c -like "$wordToComplete*") {
19+
[System.Management.Automation.CompletionResult]::new($c, $c, 'ParameterValue', $c)
20+
}
21+
}
22+
} catch {
23+
# no-op on errors to avoid noisy completion failures
24+
}
25+
}
26+
27+

config.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package dstask
22

33
import (
44
"os"
5-
"path"
5+
"path/filepath"
66
)
77

88
// Config models the dstask application's required configuration. All paths
@@ -23,9 +23,16 @@ func NewConfig() Config {
2323
var conf Config
2424

2525
conf.CtxFromEnvVar = getEnv("DSTASK_CONTEXT", "")
26-
conf.Repo = getEnv("DSTASK_GIT_REPO", os.ExpandEnv("$HOME/.dstask"))
27-
conf.StateFile = path.Join(conf.Repo, ".git", "dstask", "state.bin")
28-
conf.IDsFile = path.Join(conf.Repo, ".git", "dstask", "ids.bin")
26+
// Determine home directory in a platform-independent way
27+
home, err := os.UserHomeDir()
28+
if err != nil {
29+
// Fallback: use $HOME if present
30+
home = os.Getenv("HOME")
31+
}
32+
defaultRepo := filepath.Join(home, ".dstask")
33+
conf.Repo = getEnv("DSTASK_GIT_REPO", defaultRepo)
34+
conf.StateFile = filepath.Join(conf.Repo, ".git", "dstask", "state.bin")
35+
conf.IDsFile = filepath.Join(conf.Repo, ".git", "dstask", "ids.bin")
2936

3037
return conf
3138
}

integration/main_test.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,23 @@ import (
66
"log"
77
"os"
88
"os/exec"
9+
"runtime"
910
"testing"
1011

1112
"github.com/naggie/dstask"
1213
"gotest.tools/assert"
1314
)
1415

1516
func TestMain(m *testing.M) {
16-
if err := compile(); err != nil {
17+
binary := binaryPath()
18+
19+
if err := compile(binary); err != nil {
1720
log.Fatalf("compile error: %v", err)
1821
}
1922

2023
cleanup := func() {
21-
if err := os.Remove("dstask"); err != nil {
22-
log.Panic("could not remove integration test binary")
24+
if err := os.Remove(binary); err != nil {
25+
log.Panicf("could not remove integration test binary %s", binary)
2326
}
2427
}
2528

@@ -29,20 +32,28 @@ func TestMain(m *testing.M) {
2932
os.Exit(exitCode)
3033
}
3134

32-
func compile() error {
35+
func compile(outputPath string) error {
3336
// We expect to execute in the ./integration directory, and we will output
3437
// our test binary there.
35-
cmd := exec.Command("go", "build", "-mod=vendor", "-o", "./dstask", "../cmd/dstask/main.go")
38+
cmd := exec.Command("go", "build", "-mod=vendor", "-o", outputPath, "../cmd/dstask/main.go")
3639

3740
return cmd.Run()
3841
}
3942

43+
func binaryPath() string {
44+
if runtime.GOOS == "windows" {
45+
return "./dstask.exe"
46+
}
47+
48+
return "./dstask"
49+
}
50+
4051
// Create a callable closure that will run our test binary against a
4152
// particular repository path. Any variables set in the environment will be
4253
// passed to the test subprocess.
4354
func testCmd(repoPath string) func(args ...string) ([]byte, *exec.ExitError, bool) {
4455
return func(args ...string) ([]byte, *exec.ExitError, bool) {
45-
cmd := exec.Command("./dstask", args...)
56+
cmd := exec.Command(binaryPath(), args...)
4657
env := os.Environ()
4758
cmd.Env = append(env, "DSTASK_GIT_REPO="+repoPath)
4859
output, err := cmd.Output()
@@ -77,7 +88,11 @@ func setEnv(key, value string) func() {
7788
func logFailure(t *testing.T, output []byte, exiterr *exec.ExitError) {
7889
t.Helper()
7990
t.Logf("stdout: %s", string(output))
80-
t.Logf("stderr: %v", string(exiterr.Stderr))
91+
if exiterr != nil {
92+
t.Logf("stderr: %s", string(exiterr.Stderr))
93+
} else {
94+
t.Log("stderr: <nil>")
95+
}
8196
}
8297

8398
func unmarshalTaskArray(t *testing.T, data []byte) []dstask.Task {
@@ -134,6 +149,9 @@ func assertProgramResult(
134149

135150
if exiterr != nil || !successExpected {
136151
logFailure(t, output, exiterr)
137-
t.Fatalf("%v", exiterr)
152+
if exiterr != nil {
153+
t.Fatalf("%v", exiterr)
154+
}
155+
t.Fatalf("command exited unsuccessfully")
138156
}
139157
}

util.go

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414

1515
"github.com/gofrs/uuid"
1616
"github.com/mattn/go-isatty"
17-
"golang.org/x/sys/unix"
1817
)
1918

2019
func ExitFail(format string, a ...any) {
@@ -32,7 +31,9 @@ func ConfirmOrAbort(format string, a ...any) {
3231
panic(err)
3332
}
3433

35-
if input == "y\n" {
34+
// Normalize input: remove CR/LF/whitespace and compare in lowercase
35+
normalized := strings.ToLower(strings.TrimSpace(input))
36+
if normalized == "y" || normalized == "yes" {
3637
return
3738
}
3839

@@ -268,18 +269,7 @@ func DeduplicateStrings(s []string) []string {
268269
return s[:j]
269270
}
270271

271-
func MustGetTermSize() (int, int) {
272-
if FAKE_PTY {
273-
return 80, 24
274-
}
275-
276-
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
277-
if err != nil {
278-
ExitFail("Not a TTY")
279-
}
280-
281-
return int(ws.Col), int(ws.Row)
282-
}
272+
// MustGetTermSize is implemented per-OS in util_unix.go and util_windows.go
283273

284274
func StdoutIsTTY() bool {
285275
isTTY := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())

util_unix.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//go:build !windows
2+
3+
package dstask
4+
5+
import (
6+
"os"
7+
8+
"golang.org/x/sys/unix"
9+
)
10+
11+
func MustGetTermSize() (int, int) {
12+
if FAKE_PTY {
13+
return 80, 24
14+
}
15+
16+
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
17+
if err != nil {
18+
ExitFail("Not a TTY")
19+
}
20+
21+
return int(ws.Col), int(ws.Row)
22+
}

util_windows.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build windows
2+
3+
package dstask
4+
5+
import (
6+
"os"
7+
8+
"github.com/mattn/go-isatty"
9+
)
10+
11+
func MustGetTermSize() (int, int) {
12+
if FAKE_PTY {
13+
return 80, 24
14+
}
15+
16+
// Fallback: if not a TTY, fail as vorher
17+
if !(isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())) {
18+
ExitFail("Not a TTY")
19+
}
20+
21+
// On Windows, golang.org/x/sys/unix is unavailable; use conservative default
22+
// Many Windows terminals handle wrapping; we pick a reasonable width
23+
return 80, 24
24+
}

0 commit comments

Comments
 (0)