diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 63c9bff1..d9700449 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -14,6 +14,7 @@ title: Changelog * `[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`. * `[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. * `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher. +* `[Added]` Add `lets self doc` command to open the online documentation in a browser. ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index eb0b1107..d84d2a23 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -161,10 +161,29 @@ func getExitCode(err error, defaultCode int) int { return defaultCode } -// do not fail on config error in it is help (-h, --help) or --init or completion command. +// do not fail on config error if it is help (-h, --help), --init, completion, or lets self. func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool { - rootCommands := set.NewSet("completion", "help", "lsp") - return (root.Flags().NFlag() == 0 && !rootCommands.Contains(current.Name())) && !rootFlags.help && !rootFlags.init + return (root.Flags().NFlag() == 0 && !allowsMissingConfig(current)) && !rootFlags.help && !rootFlags.init +} + +func allowsMissingConfig(current *cobra.Command) bool { + if current == nil { + return false + } + + switch current.Name() { + case "completion", "help": + return true + } + + for cmd := current; cmd != nil; cmd = cmd.Parent() { + parent := cmd.Parent() + if cmd.Name() == "self" && parent != nil && parent.Name() == "lets" { + return true + } + } + + return false } type flags struct { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 00000000..9915e9ff --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "testing" + + cmdpkg "github.com/lets-cli/lets/internal/cmd" + "github.com/spf13/cobra" +) + +func TestAllowsMissingConfig(t *testing.T) { + t.Run("help", func(t *testing.T) { + command := &cobra.Command{Use: "help"} + if !allowsMissingConfig(command) { + t.Fatal("expected help to allow missing config") + } + }) + + t.Run("completion", func(t *testing.T) { + root := cmdpkg.CreateRootCommand("v0.0.0-test", "") + cmdpkg.InitCompletionCmd(root, nil) + + command, _, err := root.Find([]string{"completion"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !allowsMissingConfig(command) { + t.Fatal("expected completion to allow missing config") + } + }) + + t.Run("self subcommand", func(t *testing.T) { + root := cmdpkg.CreateRootCommand("v0.0.0-test", "") + cmdpkg.InitSelfCmd(root, "v0.0.0-test") + + command, _, err := root.Find([]string{"self", "doc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !allowsMissingConfig(command) { + t.Fatal("expected lets self doc to allow missing config") + } + }) + + t.Run("top level doc does not match self", func(t *testing.T) { + root := cmdpkg.CreateRootCommand("v0.0.0-test", "") + root.AddCommand(&cobra.Command{Use: "doc"}) + + command, _, err := root.Find([]string{"doc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if allowsMissingConfig(command) { + t.Fatal("expected top-level doc to require config") + } + }) +} diff --git a/internal/cmd/doc.go b/internal/cmd/doc.go new file mode 100644 index 00000000..b88d7807 --- /dev/null +++ b/internal/cmd/doc.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +const letsDocsURL = "https://lets-cli.org/docs/config" + +func initDocCommand(openURL func(string) error) *cobra.Command { + docCmd := &cobra.Command{ + Use: "doc", + Aliases: []string{"docs"}, + Short: "Open lets documentation in browser", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := openURL(letsDocsURL); err != nil { + return fmt.Errorf("can not open documentation: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Opening %s\n", letsDocsURL) + + return nil + }, + } + + return docCmd +} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 1c311bf7..1cd5ddfe 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -176,6 +176,61 @@ func TestPrintVersionMessage(t *testing.T) { } func TestSelfCmd(t *testing.T) { + t.Run("should open documentation in browser", func(t *testing.T) { + bufOut := new(bytes.Buffer) + called := false + gotURL := "" + + openURL := func(url string) error { + called = true + gotURL = url + + return nil + } + + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "doc"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufOut) + initSelfCmd(rootCmd, "v0.0.0-test", openURL) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !called { + t.Fatal("expected documentation opener to be called") + } + + if gotURL != letsDocsURL { + t.Fatalf("expected docs url %q, got %q", letsDocsURL, gotURL) + } + }) + + t.Run("should return opener error for documentation command", func(t *testing.T) { + bufOut := new(bytes.Buffer) + + openURL := func(url string) error { + return errors.New("open failed") + } + + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "doc"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufOut) + initSelfCmd(rootCmd, "v0.0.0-test", openURL) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected documentation opener error") + } + + if !strings.Contains(err.Error(), "can not open documentation") { + t.Fatalf("expected documentation error, got %q", err.Error()) + } + }) + t.Run("should return exit code 2 for unknown self subcommand", func(t *testing.T) { bufOut := new(bytes.Buffer) diff --git a/internal/cmd/self.go b/internal/cmd/self.go index fe1cf4d8..1d80b956 100644 --- a/internal/cmd/self.go +++ b/internal/cmd/self.go @@ -1,11 +1,16 @@ package cmd import ( + "github.com/lets-cli/lets/internal/util" "github.com/spf13/cobra" ) // InitSelfCmd intializes root 'self' subcommand. func InitSelfCmd(rootCmd *cobra.Command, version string) { + initSelfCmd(rootCmd, version, util.OpenURL) +} + +func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) error) { selfCmd := &cobra.Command{ Use: "self", Hidden: false, @@ -19,5 +24,6 @@ func InitSelfCmd(rootCmd *cobra.Command, version string) { rootCmd.AddCommand(selfCmd) + selfCmd.AddCommand(initDocCommand(openURL)) selfCmd.AddCommand(initLspCommand(version)) } diff --git a/internal/util/browser.go b/internal/util/browser.go new file mode 100644 index 00000000..de0e07ea --- /dev/null +++ b/internal/util/browser.go @@ -0,0 +1,35 @@ +package util + +import ( + "fmt" + "os/exec" + "runtime" +) + +func browserCommand(goos string, url string) (*exec.Cmd, error) { + switch goos { + case "darwin": + return exec.Command("open", url), nil + case "linux": + return exec.Command("xdg-open", url), nil + default: + return nil, fmt.Errorf("unsupported platform %q", goos) + } +} + +func OpenURL(url string) error { + cmd, err := browserCommand(runtime.GOOS, url) + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start %s: %w", cmd.Path, err) + } + + if cmd.Process != nil { + _ = cmd.Process.Release() + } + + return nil +} diff --git a/internal/util/browser_test.go b/internal/util/browser_test.go new file mode 100644 index 00000000..449e9d5a --- /dev/null +++ b/internal/util/browser_test.go @@ -0,0 +1,51 @@ +package util + +import ( + "reflect" + "strings" + "testing" +) + +func TestBrowserCommand(t *testing.T) { + t.Run("darwin", func(t *testing.T) { + cmd, err := browserCommand("darwin", "https://lets-cli.org") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd.Args[0] != "open" { + t.Fatalf("expected open, got %q", cmd.Args[0]) + } + + expectedArgs := []string{"open", "https://lets-cli.org"} + if !reflect.DeepEqual(cmd.Args, expectedArgs) { + t.Fatalf("expected args %v, got %v", expectedArgs, cmd.Args) + } + }) + + t.Run("linux", func(t *testing.T) { + cmd, err := browserCommand("linux", "https://lets-cli.org") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cmd.Args[0] != "xdg-open" { + t.Fatalf("expected xdg-open, got %q", cmd.Args[0]) + } + + expectedArgs := []string{"xdg-open", "https://lets-cli.org"} + if !reflect.DeepEqual(cmd.Args, expectedArgs) { + t.Fatalf("expected args %v, got %v", expectedArgs, cmd.Args) + } + }) + + t.Run("unsupported", func(t *testing.T) { + _, err := browserCommand("windows", "https://lets-cli.org") + if err == nil { + t.Fatal("expected unsupported platform error") + } + if !strings.Contains(err.Error(), "windows") { + t.Fatalf("expected error to mention platform %q, got %q", "windows", err.Error()) + } + }) +} diff --git a/tests/no_lets_file.bats b/tests/no_lets_file.bats index 59f1bd26..d2e9293e 100644 --- a/tests/no_lets_file.bats +++ b/tests/no_lets_file.bats @@ -8,6 +8,22 @@ setup() { } NOT_EXISTED_LETS_FILE="lets-not-existed.yaml" +TEMP_FAKE_BIN_DIR="" +TEMP_OPENED_URL_FILE="" + +teardown() { + if [[ -n "${TEMP_FAKE_BIN_DIR}" ]]; then + rm -rf "${TEMP_FAKE_BIN_DIR}" + fi + + if [[ -n "${TEMP_OPENED_URL_FILE}" ]]; then + rm -f "${TEMP_OPENED_URL_FILE}" + fi + + TEMP_FAKE_BIN_DIR="" + TEMP_OPENED_URL_FILE="" + cleanup +} @test "no_lets_file: should not create .lets dir" { LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets @@ -49,4 +65,40 @@ NOT_EXISTED_LETS_FILE="lets-not-existed.yaml" LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets -h assert_success assert_line --index 0 "A CLI task runner" -} \ No newline at end of file +} + +@test "no_lets_file: lets self doc opens docs without config" { + TEMP_FAKE_BIN_DIR="$(mktemp -d)" + TEMP_OPENED_URL_FILE="$(mktemp)" + rm -f "${TEMP_OPENED_URL_FILE}" + + cat > "${TEMP_FAKE_BIN_DIR}/xdg-open" <<'EOF' +#!/usr/bin/env bash +printf "%s" "$1" > "${LETS_TEST_OPENED_URL_FILE}" +EOF + chmod +x "${TEMP_FAKE_BIN_DIR}/xdg-open" + + cat > "${TEMP_FAKE_BIN_DIR}/open" <<'EOF' +#!/usr/bin/env bash +printf "%s" "$1" > "${LETS_TEST_OPENED_URL_FILE}" +EOF + chmod +x "${TEMP_FAKE_BIN_DIR}/open" + + PATH="${TEMP_FAKE_BIN_DIR}:${PATH}" \ + LETS_CONFIG=${NOT_EXISTED_LETS_FILE} \ + LETS_TEST_OPENED_URL_FILE="${TEMP_OPENED_URL_FILE}" \ + run lets self doc + + assert_success + + for _ in $(seq 1 20); do + if [[ -f "${TEMP_OPENED_URL_FILE}" ]]; then + break + fi + sleep 0.1 + done + + run cat "${TEMP_OPENED_URL_FILE}" + assert_success + assert_output "https://lets-cli.org/docs/config" +}