Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cmd/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"github.com/spf13/cobra"

larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/update"
)

// DoctorOptions holds inputs for the doctor command.
Expand Down Expand Up @@ -60,6 +62,10 @@ func fail(name, msg, hint string) checkResult {
return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint}
}

func warn(name, msg, hint string) checkResult {
return checkResult{Name: name, Status: "warn", Message: msg, Hint: hint}
}

func skip(name, msg string) checkResult {
return checkResult{Name: name, Status: "skip", Message: msg}
}
Expand All @@ -68,6 +74,12 @@ func doctorRun(opts *DoctorOptions) error {
f := opts.Factory
var checks []checkResult

// ── 0. CLI version & update check ──
checks = append(checks, pass("cli_version", build.Version))
if !opts.Offline {
checks = append(checks, checkCLIUpdate()...)
}

// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
Expand Down Expand Up @@ -214,6 +226,23 @@ func mustHTTPClient(f *cmdutil.Factory) *http.Client {
return c
}

// checkCLIUpdate actively queries the npm registry for the latest version.
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := update.FetchLatest()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}
current := build.Version
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: npm update -g @larksuite/cli")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}

func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
allOK := true
for _, c := range checks {
Expand Down
60 changes: 59 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/url"
"os"
"strconv"

"github.com/larksuite/cli/cmd/api"
Expand All @@ -24,6 +25,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -65,7 +67,7 @@ AI AGENT SKILLS:
teach the agent Lark API patterns, best practices, and workflows.

Install all skills:
npx skills add larksuite/cli --all -y
npx skills add larksuite/cli -g -y

Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
Expand Down Expand Up @@ -105,12 +107,68 @@ func Execute() int {
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)

// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupUpdateNotice()
}

if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}

// setupUpdateNotice starts an async update check and wires the output decorator.
func setupUpdateNotice() {
// Sync: check cache immediately (no network, fast).
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}

// Async: refresh cache for this run (and future runs).
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
update.RefreshCache(build.Version)
// If cache was just populated for the first time, set pending now.
if update.GetPending() == nil {
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
}
}()

// Wire the output decorator so JSON envelopes include "_notice".
output.PendingNotice = func() map[string]interface{} {
info := update.GetPending()
if info == nil {
return nil
}
return map[string]interface{}{
"update": map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
},
}
}
}
Comment thread
MaxHuang22 marked this conversation as resolved.

// isCompletionCommand returns true if args indicate a shell completion request.
// Update notifications must be suppressed for these to avoid corrupting
// machine-parseable completion output.
func isCompletionCommand(args []string) bool {
for _, arg := range args {
if arg == "completion" || arg == "__complete" {
return true
}
}
return false
Comment thread
MaxHuang22 marked this conversation as resolved.
}

// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {
Expand Down
32 changes: 24 additions & 8 deletions internal/output/envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ package output

// Envelope is the standard success response wrapper.
type Envelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}

// ErrorEnvelope is the standard error response wrapper.
type ErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error *ErrDetail `json:"error"`
Meta *Meta `json:"meta,omitempty"`
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error *ErrDetail `json:"error"`
Meta *Meta `json:"meta,omitempty"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}

// ErrDetail describes a structured error.
Expand All @@ -34,3 +36,17 @@ type Meta struct {
Count int `json:"count,omitempty"`
Rollback string `json:"rollback,omitempty"`
}

// PendingNotice, if set, returns system-level notices to inject as the
// "_notice" field in JSON output envelopes. Set by cmd/root.go.
// Returns nil when there is nothing to report.
var PendingNotice func() map[string]interface{}

// GetNotice returns the current pending notice for struct-based callers.
// Returns nil when there is nothing to report.
func GetNotice() map[string]interface{} {
if PendingNotice == nil {
return nil
}
return PendingNotice()
}
3 changes: 2 additions & 1 deletion internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
if err.Detail == nil {
return
}
env := ErrorEnvelope{
env := &ErrorEnvelope{
OK: false,
Identity: identity,
Error: err.Detail,
Notice: GetNotice(),
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
Expand Down
111 changes: 111 additions & 0 deletions internal/output/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package output

import (
"bytes"
"encoding/json"
"fmt"
"testing"
)
Expand Down Expand Up @@ -37,3 +39,112 @@ func TestMarkRaw_Nil(t *testing.T) {
t.Error("expected MarkRaw(nil) to return nil")
}
}

func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
// Set up PendingNotice
origNotice := PendingNotice
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{
"update": map[string]interface{}{
"current": "1.0.0",
"latest": "2.0.0",
},
}
}
defer func() { PendingNotice = origNotice }()

exitErr := &ExitError{
Code: 1,
Detail: &ErrDetail{Type: "api_error", Message: "something failed"},
}

var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "user")

var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("failed to parse output: %v", err)
}

// Verify _notice is present
notice, ok := env["_notice"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice field in output")
}
update, ok := notice["update"].(map[string]interface{})
if !ok {
t.Fatal("expected _notice.update field")
}
if update["latest"] != "2.0.0" {
t.Errorf("expected latest=2.0.0, got %v", update["latest"])
}

// Verify standard fields
if env["ok"] != false {
t.Error("expected ok=false")
}
if env["identity"] != "user" {
t.Errorf("expected identity=user, got %v", env["identity"])
}
}

func TestWriteErrorEnvelope_WithoutNotice(t *testing.T) {
// Ensure PendingNotice is nil
origNotice := PendingNotice
PendingNotice = nil
defer func() { PendingNotice = origNotice }()

exitErr := &ExitError{
Code: 1,
Detail: &ErrDetail{Type: "api_error", Message: "something failed"},
}

var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "bot")

var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("failed to parse output: %v", err)
}

if _, ok := env["_notice"]; ok {
t.Error("expected no _notice field when PendingNotice is nil")
}
}

func TestWriteErrorEnvelope_NilDetail(t *testing.T) {
exitErr := &ExitError{Code: 1}

var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "user")

if buf.Len() != 0 {
t.Errorf("expected no output for nil Detail, got: %s", buf.String())
}
}

func TestGetNotice(t *testing.T) {
// Nil PendingNotice → nil
origNotice := PendingNotice
PendingNotice = nil
if got := GetNotice(); got != nil {
t.Errorf("expected nil, got %v", got)
}

// With PendingNotice → returns value
PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"update": "test"}
}
got := GetNotice()
if got == nil || got["update"] != "test" {
t.Errorf("expected {update: test}, got %v", got)
}

// PendingNotice returns nil → nil
PendingNotice = func() map[string]interface{} { return nil }
if got := GetNotice(); got != nil {
t.Errorf("expected nil, got %v", got)
}

PendingNotice = origNotice
}
26 changes: 26 additions & 0 deletions internal/output/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
Expand All @@ -22,6 +23,31 @@ func PrintJson(w io.Writer, data interface{}) {
fmt.Fprintln(w, string(b))
}

// injectNotice adds a "_notice" field into CLI envelope maps.
// Only modifies map[string]interface{} values that have an "ok" key
// (e.g. doctor, auth, config commands that build map envelopes directly).
//
// Struct-based envelopes (Envelope, ErrorEnvelope) are NOT handled here —
// callers must set the Notice field explicitly via GetNotice().
// See: shortcuts/common/runner.go Out(), output/errors.go WriteErrorEnvelope().
func injectNotice(data interface{}) {
if PendingNotice == nil {
return
}
m, ok := data.(map[string]interface{})
if !ok {
return
}
if _, isEnvelope := m["ok"]; !isEnvelope {
return
}
notice := PendingNotice()
if notice == nil {
return
}
m["_notice"] = notice
}

// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
emit := func(item interface{}) {
Expand Down
Loading
Loading