Skip to content

Commit 1625e02

Browse files
committed
fix windows no-console hang and add windows CI build
Avoid querying the terminal for background color when stdin/stderr are not TTYs, which previously caused the CLI to hang on Windows when launched with no attached console. Fall back to env-based dark-background detection in that case. Add a Windows CI job and a subprocess regression test that runs the binary with CREATE_NO_WINDOW to guard against the hang. Switch both workflows to go-version-file with module caching and use `go mod download` instead of `go get`. Bump pb33f/doctor to v0.0.65, promote charmbracelet/x/term to a direct dependency, and bump @pb33f/cowboy-components to 0.15.1 with rebuilt bundles.
1 parent 54478f5 commit 1625e02

12 files changed

Lines changed: 846 additions & 557 deletions

File tree

.github/workflows/build.yaml

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,44 @@ on:
99
- main
1010

1111
jobs:
12+
build-windows:
13+
name: Build Windows
14+
runs-on: blacksmith-4vcpu-windows-2025
15+
env:
16+
GOWORK: off
17+
steps:
18+
- name: Checkout code
19+
uses: actions/checkout@v5
20+
21+
- name: Set up Go
22+
uses: actions/setup-go@v6
23+
with:
24+
go-version-file: go.mod
25+
cache: true
26+
27+
- name: Download dependencies
28+
run: go mod download
29+
30+
- name: Test
31+
run: go test ./...
32+
33+
- name: Build
34+
run: go build -o openapi-changes.exe .
35+
1236
build:
1337
name: Build
1438
runs-on: blacksmith-4vcpu-ubuntu-2404
39+
env:
40+
GOWORK: off
1541
steps:
1642
- name: Checkout code
1743
uses: actions/checkout@v5
1844

19-
- name: Set up Go 1.x
45+
- name: Set up Go
2046
uses: actions/setup-go@v6
2147
with:
22-
go-version: ^1.24
48+
go-version-file: go.mod
49+
cache: true
2350
id: go
2451

2552
- name: Set up Node.js
@@ -36,8 +63,8 @@ jobs:
3663
status="$(git status --porcelain --untracked-files=all -- html-report/ui/build/static)"
3764
test -z "$status" || { echo "$status"; exit 1; }
3865
39-
- name: Get dependencies
40-
run: go get -v -t -d ./...
66+
- name: Download dependencies
67+
run: go mod download
4168

4269
- name: Test
4370
run: go test ./...

.github/workflows/publish.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ jobs:
1919
uses: actions/setup-go@v6
2020
id: go
2121
with:
22-
go-version: ^1.24
22+
go-version-file: go.mod
23+
cache: true
2324

2425
- name: Set up Node.js
2526
uses: actions/setup-node@v5

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go
122122
- `git/read_local.go` still forces `ExcludeExtensionRefs = true`, which weakens the practical effect of `--ext-refs` on that path.
123123
- libopenapi breaking-rule configuration is global. `cmd/engine.go` guards this with `breakingConfigMu`, but `git/read_local.go` also writes to the global config via `SetActiveBreakingRulesConfig` — if the `git` layer is ever parallelized, it needs its own guard.
124124
- The residual `tui/` package still exists in-repo, but it is not the canonical console implementation. The canonical console is `tui/v2/` (Bubbletea).
125-
- **Dependency version sensitivity**: `doctor` (currently `v0.0.49`) and `libopenapi` (currently `v0.34.4`) version bumps can silently change comparison output, count semantics, or tree structure. After upgrading either dependency, always re-run the petstore regression fixtures and verify counts match expectations.
125+
- **Dependency version sensitivity**: `doctor` (currently `v0.0.65`) and `libopenapi` (currently `v0.36.1`) version bumps can silently change comparison output, count semantics, or tree structure. After upgrading either dependency, always re-run the petstore regression fixtures and verify counts match expectations.
126126

127127
## Regression Fixtures
128128

cmd/common.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111

1212
"charm.land/lipgloss/v2"
13+
"github.com/charmbracelet/x/term"
1314
"github.com/pb33f/doctor/terminal"
1415
whatChangedModel "github.com/pb33f/libopenapi/what-changed/model"
1516
"github.com/pb33f/openapi-changes/model"
@@ -73,6 +74,22 @@ func commandStylesFor(palette terminal.Palette) commandStyles {
7374
}
7475
}
7576

77+
func commandPaletteForTheme(theme terminal.ThemeName) terminal.Palette {
78+
if canQueryTerminalBackground(os.Stdin, os.Stderr) {
79+
return terminal.PaletteForTheme(theme)
80+
}
81+
env := os.Environ()
82+
return terminal.PaletteFor(theme, os.Stdout, env, terminal.DetectDarkBackgroundFromEnv(env))
83+
}
84+
85+
func canQueryTerminalBackground(in, out *os.File) bool {
86+
return isTerminalFile(in) && isTerminalFile(out)
87+
}
88+
89+
func isTerminalFile(file *os.File) bool {
90+
return file != nil && term.IsTerminal(file.Fd())
91+
}
92+
7693
func addTerminalThemeFlags(cmd *cobra.Command) {
7794
cmd.Flags().BoolP("no-color", "n", false, "Use the light Roger monochrome terminal theme")
7895
cmd.Flags().Bool("roger-mode", false, "Alias for --no-color")
@@ -101,7 +118,7 @@ func readCommonFlags(cmd *cobra.Command) (opts summaryOpts, configFlag string, e
101118
if err != nil {
102119
return opts, configFlag, err
103120
}
104-
opts.palette = terminal.PaletteForTheme(opts.theme)
121+
opts.palette = commandPaletteForTheme(opts.theme)
105122
return
106123
}
107124

cmd/common_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package cmd
55

66
import (
7+
"os"
78
"testing"
89

910
"github.com/pb33f/doctor/terminal"
@@ -50,6 +51,24 @@ func TestResolveTheme_RejectsConflictingFlags(t *testing.T) {
5051
assert.Contains(t, err.Error(), "--no-color/--roger-mode and --tektronix cannot be used together")
5152
}
5253

54+
func TestCanQueryTerminalBackground_RejectsRedirectedHandles(t *testing.T) {
55+
stdinReader, stdinWriter, err := os.Pipe()
56+
require.NoError(t, err)
57+
t.Cleanup(func() {
58+
_ = stdinReader.Close()
59+
_ = stdinWriter.Close()
60+
})
61+
62+
stderrReader, stderrWriter, err := os.Pipe()
63+
require.NoError(t, err)
64+
t.Cleanup(func() {
65+
_ = stderrReader.Close()
66+
_ = stderrWriter.Close()
67+
})
68+
69+
assert.False(t, canQueryTerminalBackground(stdinReader, stderrWriter))
70+
}
71+
5372
// --- prepareCommandRun ---
5473

5574
func TestPrepareCommandRun_ZeroArgs_PrintsUsageAndReturnsNil(t *testing.T) {

cmd/windows_subprocess_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//go:build windows
2+
3+
package cmd
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"encoding/json"
9+
"fmt"
10+
"os"
11+
"os/exec"
12+
"path/filepath"
13+
"strings"
14+
"syscall"
15+
"testing"
16+
"time"
17+
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
const windowsCreateNoWindow = 0x08000000
22+
23+
func TestWindowsNoConsoleSubprocessDoesNotHang(t *testing.T) {
24+
tests := []struct {
25+
name string
26+
args []string
27+
}{
28+
{
29+
name: "root",
30+
args: []string{"--no-logo"},
31+
},
32+
{
33+
name: "summary",
34+
args: []string{
35+
"summary",
36+
"--no-logo",
37+
filepath.Join("..", "sample-specs", "petstorev3.json"),
38+
filepath.Join("..", "sample-specs", "petstorev3.json"),
39+
},
40+
},
41+
}
42+
43+
for _, tc := range tests {
44+
t.Run(tc.name, func(t *testing.T) {
45+
runWindowsNoConsoleHelper(t, tc.name, tc.args)
46+
})
47+
}
48+
}
49+
50+
func TestWindowsNoConsoleSubprocessHelper(t *testing.T) {
51+
rawArgs := os.Getenv("OPENAPI_CHANGES_WINDOWS_HELPER_ARGS")
52+
if rawArgs == "" {
53+
return
54+
}
55+
56+
_ = os.Unsetenv("PB33F_DARK_BACKGROUND")
57+
_ = os.Unsetenv("COLORFGBG")
58+
59+
Version = "test"
60+
Commit = "test"
61+
Date = "test"
62+
var args []string
63+
if err := json.Unmarshal([]byte(rawArgs), &args); err != nil {
64+
fmt.Fprintln(os.Stderr, err)
65+
os.Exit(1)
66+
}
67+
rootCmd.SetArgs(args)
68+
if err := rootCmd.Execute(); err != nil {
69+
fmt.Fprintln(os.Stderr, err)
70+
os.Exit(1)
71+
}
72+
os.Exit(0)
73+
}
74+
75+
func runWindowsNoConsoleHelper(t *testing.T, name string, args []string) {
76+
t.Helper()
77+
78+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
79+
defer cancel()
80+
81+
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestWindowsNoConsoleSubprocessHelper")
82+
cmd.Env = envWithoutKeys(os.Environ(), "PB33F_DARK_BACKGROUND", "COLORFGBG")
83+
rawArgs, err := json.Marshal(args)
84+
require.NoError(t, err)
85+
cmd.Env = append(cmd.Env, "OPENAPI_CHANGES_WINDOWS_HELPER_ARGS="+string(rawArgs))
86+
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windowsCreateNoWindow}
87+
88+
stdin, err := cmd.StdinPipe()
89+
require.NoError(t, err)
90+
defer stdin.Close()
91+
92+
var stdout bytes.Buffer
93+
var stderr bytes.Buffer
94+
cmd.Stdout = &stdout
95+
cmd.Stderr = &stderr
96+
97+
err = cmd.Run()
98+
if ctx.Err() == context.DeadlineExceeded {
99+
t.Fatalf("%s hung with no console and redirected stdin; stdout=%q stderr=%q", name, stdout.String(), stderr.String())
100+
}
101+
require.NoError(t, err, "stdout=%q stderr=%q", stdout.String(), stderr.String())
102+
}
103+
104+
func envWithoutKeys(env []string, keys ...string) []string {
105+
filtered := env[:0]
106+
for _, entry := range env {
107+
keep := true
108+
for _, key := range keys {
109+
if strings.HasPrefix(entry, key+"=") {
110+
keep = false
111+
break
112+
}
113+
}
114+
if keep {
115+
filtered = append(filtered, entry)
116+
}
117+
}
118+
return filtered
119+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ require (
1010
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
1111
github.com/charmbracelet/colorprofile v0.4.3
1212
github.com/charmbracelet/glamour v0.8.0
13+
github.com/charmbracelet/x/term v0.2.2
1314
github.com/google/uuid v1.6.0
1415
github.com/mattn/go-runewidth v0.0.23
1516
github.com/muesli/termenv v0.16.0
16-
github.com/pb33f/doctor v0.0.59
17+
github.com/pb33f/doctor v0.0.65
1718
github.com/pb33f/libopenapi v0.36.1
1819
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
1920
github.com/spf13/cobra v1.10.2
@@ -31,7 +32,6 @@ require (
3132
github.com/charmbracelet/lipgloss v1.0.0 // indirect
3233
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
3334
github.com/charmbracelet/x/ansi v0.11.7 // indirect
34-
github.com/charmbracelet/x/term v0.2.2 // indirect
3535
github.com/charmbracelet/x/termios v0.1.1 // indirect
3636
github.com/charmbracelet/x/windows v0.2.2 // indirect
3737
github.com/clipperhouse/displaywidth v0.11.0 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
9292
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
9393
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
9494
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
95-
github.com/pb33f/doctor v0.0.59 h1:uy4l/oYHk9YLheL1c17dTbET9gHnp2PAhwaIUBFZNY4=
96-
github.com/pb33f/doctor v0.0.59/go.mod h1:kN+wcMNwBN8RoQbzfi7xYnRkuqHREYj7ir/2wbN5ECY=
95+
github.com/pb33f/doctor v0.0.65 h1:fkvKwYpa+P+aBOsh/t3Gn/kmtiRPptq+/M/Zmsqy9/g=
96+
github.com/pb33f/doctor v0.0.65/go.mod h1:G3z5J8l+YmSR/T94TjnfaDgc+eHR6DZ2I0C/a8lI4u0=
9797
github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y=
9898
github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo=
9999
github.com/pb33f/libopenapi v0.36.1 h1:CNZ52e+/W9fA1kAgL8EePDQQrKPfN9+HdLR6XAxUEpw=

0 commit comments

Comments
 (0)