diff --git a/cmd/build.go b/cmd/build.go index 7ecb4c3b..c8461e32 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -214,6 +214,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B groupRootCommands(rootCmd) installUnknownSubcommandGuard(rootCmd) + // Bare `lark-cli` in an interactive terminal offers an interactive upgrade + // before printing help; non-bare invocations and non-TTY are unaffected. + installRootUpgradePrompt(f, rootCmd) if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode { pruneForStrictMode(rootCmd, mode) diff --git a/cmd/root_upgrade.go b/cmd/root_upgrade.go new file mode 100644 index 00000000..eadec786 --- /dev/null +++ b/cmd/root_upgrade.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bufio" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/update" + "github.com/spf13/cobra" +) + +// runRootUpgrade locates the registered `update` subcommand and runs it, so the +// interactive root-command upgrade reuses exactly `lark-cli update` behavior +// (install-method detection, output, error handling). Package-level var so +// tests can stub it and avoid real network / self-update. +var runRootUpgrade = func(cmd *cobra.Command) { + for _, c := range cmd.Root().Commands() { + if c.Name() == "update" && c.RunE != nil { + _ = c.RunE(c, nil) // update prints its own output/errors; swallow here + return + } + } +} + +// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand, +// no flags) — the only invocation that triggers the interactive upgrade prompt. +// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty +// AND no flag tokens in the raw invocation. +func isBareRootInvocation(args []string) bool { + return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0 +} + +// readYes reads one line and reports whether it is an affirmative y/yes. +// EOF / empty / anything else → false (default No, matching the [y/N] prompt). +func readYes(r io.Reader) bool { + line, _ := bufio.NewReader(r).ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + default: + return false + } +} + +// offerRootUpgrade prompts for an interactive upgrade when running bare +// `lark-cli` in an interactive terminal with a cached newer version. Every +// failure is swallowed — it must never affect help output or the exit code. +func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) { + ios := f.IOStreams + // Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require + // stdout TTY too so this only fires in a pure foreground terminal session. + if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal { + return + } + // Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip) + // and the IsNewer/semver validation chain; it reads the on-disk cache that + // the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL). + info := update.CheckCached(build.Version) + if info == nil { + return + } + fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current) + if !readYes(ios.In) { + return + } + runRootUpgrade(cmd) +} + +// installRootUpgradePrompt wraps the root command's RunE (set to +// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli` +// invocation offers an interactive upgrade before printing help. Non-bare +// invocations are passed straight through, unchanged. +func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) { + inner := root.RunE + if inner == nil { + return + } + root.RunE = func(cmd *cobra.Command, args []string) error { + if isBareRootInvocation(args) { + offerRootUpgrade(f, cmd) + } + return inner(cmd, args) + } +} diff --git a/cmd/root_upgrade_test.go b/cmd/root_upgrade_test.go new file mode 100644 index 00000000..bc28b858 --- /dev/null +++ b/cmd/root_upgrade_test.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +func writeUpdateState(t *testing.T, dir, latest string) { + t.Helper() + data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix()) + if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestReadYes(t *testing.T) { + cases := map[string]bool{ + "y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true, + "n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false, + } + for in, want := range cases { + if got := readYes(strings.NewReader(in)); got != want { + t.Errorf("readYes(%q) = %v, want %v", in, got, want) + } + } +} + +func TestIsBareRootInvocation(t *testing.T) { + orig := rawInvocationArgs + t.Cleanup(func() { rawInvocationArgs = orig }) + + rawInvocationArgs = nil + if !isBareRootInvocation([]string{}) { + t.Error("empty args + no raw flag tokens should be bare") + } + rawInvocationArgs = []string{"--profile", "x"} + if isBareRootInvocation([]string{}) { + t.Error("flag token present → not bare") + } + rawInvocationArgs = nil + if isBareRootInvocation([]string{"im"}) { + t.Error("positional arg → not bare") + } +} + +func TestOfferRootUpgrade(t *testing.T) { + origV := build.Version + build.Version = "1.0.0" // release version so shouldSkip()==false + t.Cleanup(func() { build.Version = origV }) + + origRun := runRootUpgrade + t.Cleanup(func() { runRootUpgrade = origRun }) + + // This test builds a Factory literal (no NewDefault), so it never runs + // workspace detection; pin the process-global workspace to Local so + // statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale + // subdir inherited from a prior test in the package. + origWS := core.CurrentWorkspace() + t.Cleanup(func() { core.SetCurrentWorkspace(origWS) }) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + cases := []struct { + name string + in, out, err bool + input string + latest string // "" → no state file (CheckCached nil) + optOut bool + wantPrompt, wantRun bool + }{ + {"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true}, + {"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true}, + {"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false}, + {"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false}, + {"all-tty+eof", true, true, true, "", "2.0.0", false, true, false}, + {"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false}, + {"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false}, + {"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false}, + {"no-newer-version", true, true, true, "y\n", "", false, false, false}, + {"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt + {"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false}, + {"opt-out", true, true, true, "y\n", "2.0.0", true, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + // Clear env that update.shouldSkip treats as "suppress" so the + // test is deterministic regardless of host (GitHub Actions sets + // CI=true, which would otherwise suppress the prompt). + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "") + if tc.latest != "" { + writeUpdateState(t, dir, tc.latest) + } + if tc.optOut { + t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") + } + called := false + runRootUpgrade = func(*cobra.Command) { called = true } + + var errBuf bytes.Buffer + f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + In: strings.NewReader(tc.input), + Out: &bytes.Buffer{}, + ErrOut: &errBuf, + IsTerminal: tc.in, + OutIsTerminal: tc.out, + StderrIsTerminal: tc.err, + }} + offerRootUpgrade(f, &cobra.Command{}) + + gotPrompt := strings.Contains(errBuf.String(), "available") + if gotPrompt != tc.wantPrompt { + t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String()) + } + if called != tc.wantRun { + t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun) + } + }) + } +} + +func TestInstallRootUpgradePromptPreservesInner(t *testing.T) { + orig := rawInvocationArgs + t.Cleanup(func() { rawInvocationArgs = orig }) + rawInvocationArgs = nil + + innerCalls := 0 + root := &cobra.Command{Use: "lark-cli"} + root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil } + + f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, + }} + installRootUpgradePrompt(f, root) + + if err := root.RunE(root, []string{}); err != nil { + t.Fatalf("bare RunE err = %v", err) + } + if err := root.RunE(root, []string{"im"}); err != nil { + t.Fatalf("non-bare RunE err = %v", err) + } + if innerCalls != 2 { + t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls) + } +} + +// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch +// path (not the stub used elsewhere): from any command it must locate the +// registered "update" subcommand via cmd.Root() and invoke its RunE. +func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + ran := 0 + root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }}) + child := &cobra.Command{Use: "im"} + root.AddCommand(child) + + runRootUpgrade(child) // child.Root() resolves to root, which has "update" + + if ran != 1 { + t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran) + } +} + +// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard: +// when root has no RunE, installRootUpgradePrompt must not wrap it. +func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} // RunE is nil + f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, + }} + installRootUpgradePrompt(f, root) + if root.RunE != nil { + t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)") + } +} diff --git a/internal/cmdutil/iostreams.go b/internal/cmdutil/iostreams.go index a067b67d..b5f7c65a 100644 --- a/internal/cmdutil/iostreams.go +++ b/internal/cmdutil/iostreams.go @@ -18,6 +18,9 @@ type IOStreams struct { Out io.Writer ErrOut io.Writer IsTerminal bool + // OutIsTerminal reports whether Out is an interactive terminal. Mirrors + // IsTerminal; computed once in NewIOStreams and assignable directly in tests. + OutIsTerminal bool // StderrIsTerminal reports whether ErrOut is an interactive terminal. // Advisory warnings written to stderr (e.g. the proxy notice) gate on this // so they stay out of non-interactive output (pipes, CI, agent runs). @@ -27,19 +30,24 @@ type IOStreams struct { } // NewIOStreams builds an IOStreams from arbitrary readers/writers. -// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying -// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield -// false. +// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the +// underlying *os.File of in / out / errOut respectively; non-file +// readers/writers (bytes.Buffer, strings.Reader, …) yield false. func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams { - isTerminal := false - if f, ok := in.(*os.File); ok { - isTerminal = term.IsTerminal(int(f.Fd())) + fileIsTerminal := func(v any) bool { + if f, ok := v.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false } - stderrIsTerminal := false - if f, ok := errOut.(*os.File); ok { - stderrIsTerminal = term.IsTerminal(int(f.Fd())) + return &IOStreams{ + In: in, + Out: out, + ErrOut: errOut, + IsTerminal: fileIsTerminal(in), + OutIsTerminal: fileIsTerminal(out), + StderrIsTerminal: fileIsTerminal(errOut), } - return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal} } // SystemIO creates an IOStreams wired to the process's standard file descriptors. diff --git a/internal/cmdutil/iostreams_test.go b/internal/cmdutil/iostreams_test.go new file mode 100644 index 00000000..bf6910d9 --- /dev/null +++ b/internal/cmdutil/iostreams_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "os" + "testing" +) + +func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) { + s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) + if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal { + t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v", + s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal) + } +} + +func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + s := NewIOStreams(r, w, w) + if s.OutIsTerminal || s.StderrIsTerminal { + t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal) + } +}