Skip to content

Commit c73fc07

Browse files
authored
Merge pull request #311 from lets-cli/codex/add-lets-self-doc-command
Add lets self doc command
2 parents 6086d46 + 1b56346 commit c73fc07

9 files changed

Lines changed: 311 additions & 4 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ title: Changelog
1414
* `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`.
1515
* `[Changed]` Migrate the LSP YAML parser from the CGO-based tree-sitter bindings to pure-Go [`gotreesitter`](https://github.com/odvcencio/gotreesitter), removing the C toolchain requirement from normal builds and release packaging.
1616
* `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher.
17+
* `[Added]` Add `lets self doc` command to open the online documentation in a browser.
1718

1819
## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)
1920

internal/cli/cli.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,29 @@ func getExitCode(err error, defaultCode int) int {
161161
return defaultCode
162162
}
163163

164-
// do not fail on config error in it is help (-h, --help) or --init or completion command.
164+
// do not fail on config error if it is help (-h, --help), --init, completion, or lets self.
165165
func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool {
166-
rootCommands := set.NewSet("completion", "help", "lsp")
167-
return (root.Flags().NFlag() == 0 && !rootCommands.Contains(current.Name())) && !rootFlags.help && !rootFlags.init
166+
return (root.Flags().NFlag() == 0 && !allowsMissingConfig(current)) && !rootFlags.help && !rootFlags.init
167+
}
168+
169+
func allowsMissingConfig(current *cobra.Command) bool {
170+
if current == nil {
171+
return false
172+
}
173+
174+
switch current.Name() {
175+
case "completion", "help":
176+
return true
177+
}
178+
179+
for cmd := current; cmd != nil; cmd = cmd.Parent() {
180+
parent := cmd.Parent()
181+
if cmd.Name() == "self" && parent != nil && parent.Name() == "lets" {
182+
return true
183+
}
184+
}
185+
186+
return false
168187
}
169188

170189
type flags struct {

internal/cli/cli_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
cmdpkg "github.com/lets-cli/lets/internal/cmd"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func TestAllowsMissingConfig(t *testing.T) {
11+
t.Run("help", func(t *testing.T) {
12+
command := &cobra.Command{Use: "help"}
13+
if !allowsMissingConfig(command) {
14+
t.Fatal("expected help to allow missing config")
15+
}
16+
})
17+
18+
t.Run("completion", func(t *testing.T) {
19+
root := cmdpkg.CreateRootCommand("v0.0.0-test", "")
20+
cmdpkg.InitCompletionCmd(root, nil)
21+
22+
command, _, err := root.Find([]string{"completion"})
23+
if err != nil {
24+
t.Fatalf("unexpected error: %v", err)
25+
}
26+
27+
if !allowsMissingConfig(command) {
28+
t.Fatal("expected completion to allow missing config")
29+
}
30+
})
31+
32+
t.Run("self subcommand", func(t *testing.T) {
33+
root := cmdpkg.CreateRootCommand("v0.0.0-test", "")
34+
cmdpkg.InitSelfCmd(root, "v0.0.0-test")
35+
36+
command, _, err := root.Find([]string{"self", "doc"})
37+
if err != nil {
38+
t.Fatalf("unexpected error: %v", err)
39+
}
40+
41+
if !allowsMissingConfig(command) {
42+
t.Fatal("expected lets self doc to allow missing config")
43+
}
44+
})
45+
46+
t.Run("top level doc does not match self", func(t *testing.T) {
47+
root := cmdpkg.CreateRootCommand("v0.0.0-test", "")
48+
root.AddCommand(&cobra.Command{Use: "doc"})
49+
50+
command, _, err := root.Find([]string{"doc"})
51+
if err != nil {
52+
t.Fatalf("unexpected error: %v", err)
53+
}
54+
55+
if allowsMissingConfig(command) {
56+
t.Fatal("expected top-level doc to require config")
57+
}
58+
})
59+
}

internal/cmd/doc.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
const letsDocsURL = "https://lets-cli.org/docs/config"
10+
11+
func initDocCommand(openURL func(string) error) *cobra.Command {
12+
docCmd := &cobra.Command{
13+
Use: "doc",
14+
Aliases: []string{"docs"},
15+
Short: "Open lets documentation in browser",
16+
Args: cobra.NoArgs,
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
if err := openURL(letsDocsURL); err != nil {
19+
return fmt.Errorf("can not open documentation: %w", err)
20+
}
21+
22+
fmt.Fprintf(cmd.OutOrStdout(), "Opening %s\n", letsDocsURL)
23+
24+
return nil
25+
},
26+
}
27+
28+
return docCmd
29+
}

internal/cmd/root_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,61 @@ func TestPrintVersionMessage(t *testing.T) {
176176
}
177177

178178
func TestSelfCmd(t *testing.T) {
179+
t.Run("should open documentation in browser", func(t *testing.T) {
180+
bufOut := new(bytes.Buffer)
181+
called := false
182+
gotURL := ""
183+
184+
openURL := func(url string) error {
185+
called = true
186+
gotURL = url
187+
188+
return nil
189+
}
190+
191+
rootCmd := CreateRootCommand("v0.0.0-test", "")
192+
rootCmd.SetArgs([]string{"self", "doc"})
193+
rootCmd.SetOut(bufOut)
194+
rootCmd.SetErr(bufOut)
195+
initSelfCmd(rootCmd, "v0.0.0-test", openURL)
196+
197+
err := rootCmd.Execute()
198+
if err != nil {
199+
t.Fatalf("unexpected error: %v", err)
200+
}
201+
202+
if !called {
203+
t.Fatal("expected documentation opener to be called")
204+
}
205+
206+
if gotURL != letsDocsURL {
207+
t.Fatalf("expected docs url %q, got %q", letsDocsURL, gotURL)
208+
}
209+
})
210+
211+
t.Run("should return opener error for documentation command", func(t *testing.T) {
212+
bufOut := new(bytes.Buffer)
213+
214+
openURL := func(url string) error {
215+
return errors.New("open failed")
216+
}
217+
218+
rootCmd := CreateRootCommand("v0.0.0-test", "")
219+
rootCmd.SetArgs([]string{"self", "doc"})
220+
rootCmd.SetOut(bufOut)
221+
rootCmd.SetErr(bufOut)
222+
initSelfCmd(rootCmd, "v0.0.0-test", openURL)
223+
224+
err := rootCmd.Execute()
225+
if err == nil {
226+
t.Fatal("expected documentation opener error")
227+
}
228+
229+
if !strings.Contains(err.Error(), "can not open documentation") {
230+
t.Fatalf("expected documentation error, got %q", err.Error())
231+
}
232+
})
233+
179234
t.Run("should return exit code 2 for unknown self subcommand", func(t *testing.T) {
180235
bufOut := new(bytes.Buffer)
181236

internal/cmd/self.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package cmd
22

33
import (
4+
"github.com/lets-cli/lets/internal/util"
45
"github.com/spf13/cobra"
56
)
67

78
// InitSelfCmd intializes root 'self' subcommand.
89
func InitSelfCmd(rootCmd *cobra.Command, version string) {
10+
initSelfCmd(rootCmd, version, util.OpenURL)
11+
}
12+
13+
func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) error) {
914
selfCmd := &cobra.Command{
1015
Use: "self",
1116
Hidden: false,
@@ -19,5 +24,6 @@ func InitSelfCmd(rootCmd *cobra.Command, version string) {
1924

2025
rootCmd.AddCommand(selfCmd)
2126

27+
selfCmd.AddCommand(initDocCommand(openURL))
2228
selfCmd.AddCommand(initLspCommand(version))
2329
}

internal/util/browser.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package util
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"runtime"
7+
)
8+
9+
func browserCommand(goos string, url string) (*exec.Cmd, error) {
10+
switch goos {
11+
case "darwin":
12+
return exec.Command("open", url), nil
13+
case "linux":
14+
return exec.Command("xdg-open", url), nil
15+
default:
16+
return nil, fmt.Errorf("unsupported platform %q", goos)
17+
}
18+
}
19+
20+
func OpenURL(url string) error {
21+
cmd, err := browserCommand(runtime.GOOS, url)
22+
if err != nil {
23+
return err
24+
}
25+
26+
if err := cmd.Start(); err != nil {
27+
return fmt.Errorf("start %s: %w", cmd.Path, err)
28+
}
29+
30+
if cmd.Process != nil {
31+
_ = cmd.Process.Release()
32+
}
33+
34+
return nil
35+
}

internal/util/browser_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package util
2+
3+
import (
4+
"reflect"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestBrowserCommand(t *testing.T) {
10+
t.Run("darwin", func(t *testing.T) {
11+
cmd, err := browserCommand("darwin", "https://lets-cli.org")
12+
if err != nil {
13+
t.Fatalf("unexpected error: %v", err)
14+
}
15+
16+
if cmd.Args[0] != "open" {
17+
t.Fatalf("expected open, got %q", cmd.Args[0])
18+
}
19+
20+
expectedArgs := []string{"open", "https://lets-cli.org"}
21+
if !reflect.DeepEqual(cmd.Args, expectedArgs) {
22+
t.Fatalf("expected args %v, got %v", expectedArgs, cmd.Args)
23+
}
24+
})
25+
26+
t.Run("linux", func(t *testing.T) {
27+
cmd, err := browserCommand("linux", "https://lets-cli.org")
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
32+
if cmd.Args[0] != "xdg-open" {
33+
t.Fatalf("expected xdg-open, got %q", cmd.Args[0])
34+
}
35+
36+
expectedArgs := []string{"xdg-open", "https://lets-cli.org"}
37+
if !reflect.DeepEqual(cmd.Args, expectedArgs) {
38+
t.Fatalf("expected args %v, got %v", expectedArgs, cmd.Args)
39+
}
40+
})
41+
42+
t.Run("unsupported", func(t *testing.T) {
43+
_, err := browserCommand("windows", "https://lets-cli.org")
44+
if err == nil {
45+
t.Fatal("expected unsupported platform error")
46+
}
47+
if !strings.Contains(err.Error(), "windows") {
48+
t.Fatalf("expected error to mention platform %q, got %q", "windows", err.Error())
49+
}
50+
})
51+
}

tests/no_lets_file.bats

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ setup() {
88
}
99

1010
NOT_EXISTED_LETS_FILE="lets-not-existed.yaml"
11+
TEMP_FAKE_BIN_DIR=""
12+
TEMP_OPENED_URL_FILE=""
13+
14+
teardown() {
15+
if [[ -n "${TEMP_FAKE_BIN_DIR}" ]]; then
16+
rm -rf "${TEMP_FAKE_BIN_DIR}"
17+
fi
18+
19+
if [[ -n "${TEMP_OPENED_URL_FILE}" ]]; then
20+
rm -f "${TEMP_OPENED_URL_FILE}"
21+
fi
22+
23+
TEMP_FAKE_BIN_DIR=""
24+
TEMP_OPENED_URL_FILE=""
25+
cleanup
26+
}
1127

1228
@test "no_lets_file: should not create .lets dir" {
1329
LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets
@@ -49,4 +65,40 @@ NOT_EXISTED_LETS_FILE="lets-not-existed.yaml"
4965
LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets -h
5066
assert_success
5167
assert_line --index 0 "A CLI task runner"
52-
}
68+
}
69+
70+
@test "no_lets_file: lets self doc opens docs without config" {
71+
TEMP_FAKE_BIN_DIR="$(mktemp -d)"
72+
TEMP_OPENED_URL_FILE="$(mktemp)"
73+
rm -f "${TEMP_OPENED_URL_FILE}"
74+
75+
cat > "${TEMP_FAKE_BIN_DIR}/xdg-open" <<'EOF'
76+
#!/usr/bin/env bash
77+
printf "%s" "$1" > "${LETS_TEST_OPENED_URL_FILE}"
78+
EOF
79+
chmod +x "${TEMP_FAKE_BIN_DIR}/xdg-open"
80+
81+
cat > "${TEMP_FAKE_BIN_DIR}/open" <<'EOF'
82+
#!/usr/bin/env bash
83+
printf "%s" "$1" > "${LETS_TEST_OPENED_URL_FILE}"
84+
EOF
85+
chmod +x "${TEMP_FAKE_BIN_DIR}/open"
86+
87+
PATH="${TEMP_FAKE_BIN_DIR}:${PATH}" \
88+
LETS_CONFIG=${NOT_EXISTED_LETS_FILE} \
89+
LETS_TEST_OPENED_URL_FILE="${TEMP_OPENED_URL_FILE}" \
90+
run lets self doc
91+
92+
assert_success
93+
94+
for _ in $(seq 1 20); do
95+
if [[ -f "${TEMP_OPENED_URL_FILE}" ]]; then
96+
break
97+
fi
98+
sleep 0.1
99+
done
100+
101+
run cat "${TEMP_OPENED_URL_FILE}"
102+
assert_success
103+
assert_output "https://lets-cli.org/docs/config"
104+
}

0 commit comments

Comments
 (0)