mirror of
https://github.com/larksuite/cli.git
synced 2026-07-14 05:31:59 +08:00
feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping
Nudge users whose lark-sheets skill predates the refactor to migrate off the pre-refactor aliases (+read, +write, ...), without requiring anyone to read --help. - internal/deprecation: process-level pending Notice slot (mirrors internal/skillscheck), surfaced in the JSON "_notice" envelope under a "deprecated_command" key. - internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both --help rendering and the unknown-subcommand path classify aliases the same way. - shortcuts/register.go: applySheetsCompatGroups splits the aliases into a dedicated "update your skill" help group with "(-> +new)" pointers; wrapSheetsBackwardDeprecation records the notice from Validate/Execute so direct callers that never read --help still get flagged. - cmd/root.go: extract composePendingNotice (now unit-testable) and split availableSubcommandNames into current vs deprecated buckets while still ranking unknown-subcommand suggestions across both.
This commit is contained in:
58
cmd/notice_test.go
Normal file
58
cmd/notice_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
104
cmd/root.go
104
cmd/root.go
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
@@ -149,29 +150,46 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -338,32 +356,45 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
suggestions := suggest.Closest(unknown, available, 6)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
},
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -372,10 +403,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -164,7 +165,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
got, _ := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -175,3 +176,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
@@ -69,7 +70,7 @@ func init() {
|
||||
// kept under shortcuts/sheets/backward so external callers relying on the
|
||||
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
|
||||
// refactored ones. Command names are disjoint from sheets.Shortcuts().
|
||||
allShortcuts = append(allShortcuts, sheetsbackward.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
@@ -152,6 +153,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
@@ -195,3 +199,146 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
// --help bypasses RunE, so surface the restriction in Long too.
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility help grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
// `sheets --help` grouping and the generic unknown-subcommand path
|
||||
// (cmd/root.go) classify these aliases the same way.
|
||||
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
"+info": "+workbook-info",
|
||||
"+export": "+workbook-export",
|
||||
"+create-sheet": "+sheet-create",
|
||||
"+copy-sheet": "+sheet-copy",
|
||||
"+delete-sheet": "+sheet-delete",
|
||||
"+update-sheet": "+sheet-rename / +sheet-move / …",
|
||||
// cell data
|
||||
"+read": "+cells-get",
|
||||
"+write": "+cells-set",
|
||||
"+append": "+cells-set",
|
||||
"+find": "+cells-search",
|
||||
"+replace": "+cells-replace",
|
||||
// cell style / merge / image
|
||||
"+set-style": "+cells-set-style",
|
||||
"+batch-set-style": "+cells-batch-set-style",
|
||||
"+merge-cells": "+cells-merge",
|
||||
"+unmerge-cells": "+cells-unmerge",
|
||||
"+write-image": "+cells-set-image",
|
||||
// row / column dimensions
|
||||
"+add-dimension": "+dim-insert",
|
||||
"+insert-dimension": "+dim-insert",
|
||||
"+update-dimension": "+rows-resize / +dim-hide / …",
|
||||
"+move-dimension": "+dim-move",
|
||||
"+delete-dimension": "+dim-delete",
|
||||
// filter views (conditions folded into the view flags)
|
||||
"+create-filter-view": "+filter-view-create",
|
||||
"+update-filter-view": "+filter-view-update",
|
||||
"+list-filter-views": "+filter-view-list",
|
||||
"+get-filter-view": "+filter-view-list",
|
||||
"+delete-filter-view": "+filter-view-delete",
|
||||
"+create-filter-view-condition": "+filter-view-update",
|
||||
"+update-filter-view-condition": "+filter-view-update",
|
||||
"+list-filter-view-conditions": "+filter-view-list",
|
||||
"+get-filter-view-condition": "+filter-view-list",
|
||||
"+delete-filter-view-condition": "+filter-view-update",
|
||||
// dropdowns
|
||||
"+set-dropdown": "+dropdown-set",
|
||||
"+update-dropdown": "+dropdown-update",
|
||||
"+get-dropdown": "+dropdown-get",
|
||||
"+delete-dropdown": "+dropdown-delete",
|
||||
// float images (media-upload folded into create)
|
||||
"+media-upload": "+float-image-create",
|
||||
"+create-float-image": "+float-image-create",
|
||||
"+update-float-image": "+float-image-update",
|
||||
"+get-float-image": "+float-image-list",
|
||||
"+list-float-images": "+float-image-list",
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
&cobra.Group{
|
||||
ID: sheetsDeprecatedGroupID,
|
||||
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
|
||||
},
|
||||
)
|
||||
|
||||
deprecated := make(map[string]struct{})
|
||||
for _, s := range sheetsbackward.Shortcuts() {
|
||||
deprecated[s.Command] = struct{}{}
|
||||
}
|
||||
|
||||
for _, c := range svc.Commands() {
|
||||
name := c.Name()
|
||||
if _, ok := deprecated[name]; ok {
|
||||
c.GroupID = sheetsDeprecatedGroupID
|
||||
if repl := sheetsAliasReplacement[name]; repl != "" {
|
||||
c.Short = c.Short + " (→ " + repl + ")"
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
|
||||
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
|
||||
// auto-added help/completion ungrouped so cobra files them under
|
||||
// "Additional Commands".
|
||||
if len(name) > 0 && name[0] == '+' {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
// alias so that invoking it records a process-level deprecation notice, which
|
||||
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
|
||||
// the --help grouping cannot: those whose pre-refactor skill calls +read /
|
||||
// +write directly and never reads --help. Replacement targets come from
|
||||
// sheetsAliasReplacement — the same single source of truth that drives the
|
||||
// "(→ +new)" help pointers.
|
||||
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
|
||||
for i := range list {
|
||||
notice := &deprecation.Notice{
|
||||
Command: list[i].Command,
|
||||
Replacement: sheetsAliasReplacement[list[i].Command],
|
||||
Skill: "lark-sheets",
|
||||
}
|
||||
// Record the notice as soon as the command's own logic runs, so it is
|
||||
// surfaced even when Validate rejects the call — an out-of-date skill
|
||||
// can pass pre-refactor argument shapes (e.g. a range without the new
|
||||
// sheet-id prefix) and fail validation before Execute — and when
|
||||
// --dry-run short-circuits before Execute. Both hooks store the same
|
||||
// pointer, so setting it twice is harmless.
|
||||
if origValidate := list[i].Validate; origValidate != nil {
|
||||
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origValidate(ctx, runtime)
|
||||
}
|
||||
}
|
||||
if origExecute := list[i].Execute; origExecute != nil {
|
||||
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origExecute(ctx, runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,7 +17,9 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -471,3 +474,152 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
}
|
||||
t.Logf("wrote %d bytes to %s", len(data), output)
|
||||
}
|
||||
|
||||
// applySheetsCompatGroups must split the sheets service into a current group
|
||||
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
|
||||
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
|
||||
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
|
||||
// under "Additional Commands".
|
||||
func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
|
||||
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
|
||||
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
|
||||
svc.AddCommand(newCmd, aliasCmd, metaCmd)
|
||||
|
||||
applySheetsCompatGroups(svc)
|
||||
|
||||
if !svc.ContainsGroup(sheetsCurrentGroupID) {
|
||||
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
|
||||
}
|
||||
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
|
||||
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
|
||||
}
|
||||
if newCmd.GroupID != sheetsCurrentGroupID {
|
||||
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
|
||||
}
|
||||
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
|
||||
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
|
||||
}
|
||||
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
|
||||
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
|
||||
}
|
||||
if metaCmd.GroupID != "" {
|
||||
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
sheetsCmd, _, err := program.Find([]string{"sheets"})
|
||||
if err != nil {
|
||||
t.Fatalf("find sheets command: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
sheetsCmd.SetOut(&out)
|
||||
if err := sheetsCmd.Help(); err != nil {
|
||||
t.Fatalf("sheets help failed: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
// invoking it records a process-level deprecation notice (reusing
|
||||
// sheetsAliasReplacement for the migration target) while still calling the
|
||||
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
|
||||
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
called := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("wrapped list len = %d, want 1", len(out))
|
||||
}
|
||||
if deprecation.GetPending() != nil {
|
||||
t.Fatal("notice set before wrapped Execute ran")
|
||||
}
|
||||
|
||||
if err := out[0].Execute(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Execute returned error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("original Execute was not invoked by the wrapper")
|
||||
}
|
||||
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil {
|
||||
t.Fatal("expected a pending deprecation notice after Execute")
|
||||
}
|
||||
if dep.Command != "+read" {
|
||||
t.Errorf("notice Command = %q, want +read", dep.Command)
|
||||
}
|
||||
if dep.Replacement != "+cells-get" {
|
||||
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
|
||||
}
|
||||
if dep.Skill != "lark-sheets" {
|
||||
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must also decorate Validate, so an out-of-date skill whose
|
||||
// pre-refactor argument shape fails validation (before Execute) still gets the
|
||||
// deprecation notice in its error envelope.
|
||||
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
validated := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
validated = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if out[0].Validate == nil {
|
||||
t.Fatal("Validate hook was dropped by the wrapper")
|
||||
}
|
||||
if err := out[0].Validate(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Validate returned error: %v", err)
|
||||
}
|
||||
if !validated {
|
||||
t.Fatal("original Validate was not invoked")
|
||||
}
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
|
||||
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user