mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
20 Commits
feat/outpu
...
v1.0.70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b3645362 | ||
|
|
64caef1526 | ||
|
|
64e10a0954 | ||
|
|
8897196dee | ||
|
|
49b4ccceb9 | ||
|
|
4b2d012af9 | ||
|
|
90aad64b8d | ||
|
|
2919084103 | ||
|
|
36bd82cb27 | ||
|
|
2e77d8db80 | ||
|
|
d9061ffcbc | ||
|
|
08d9b28ee8 | ||
|
|
168fb13e3e | ||
|
|
55c2e5c819 | ||
|
|
16a93cd277 | ||
|
|
e9dabb2184 | ||
|
|
8acd55e907 | ||
|
|
6ecbfaf690 | ||
|
|
ac2508d3b0 | ||
|
|
1c3674487f |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.70] - 2026-07-15
|
||||
|
||||
### Features
|
||||
|
||||
- add minutes permission application shortcut (#1876)
|
||||
- **drive**: support apps in list comments (#1877)
|
||||
- slide style
|
||||
- edit ppt template
|
||||
- **slides**: add sxsd validation to slides lint
|
||||
- **slides**: validate iconpark icon types in slides lint
|
||||
- **slides**: lint before create
|
||||
- **apps**: add automation trigger commands for Miaoda (#1886)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- unify dry-run output contract (#1870)
|
||||
- **skills**: align skill guidance with the typed error contract (#1786)
|
||||
- **slides**: limit slides screenshot page requests
|
||||
- **slides**: detect lark slides text overflow overlap
|
||||
- **vc**: align meeting query scopes by identity (#1850)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify task search relevance filters (#1884)
|
||||
- surface minutes permission application in skill description (#1890)
|
||||
- clarify okr progress children (#1861)
|
||||
- **slides**: prefer slides xml-get shortcut
|
||||
- **calendar**: document setting meeting owner via full API (#1903)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **slides**: streamline create workflow and validate SML namespaces
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: address PR review feedback
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
@@ -1469,6 +1506,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
|
||||
@@ -40,7 +40,6 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JSON bool
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string
|
||||
@@ -89,11 +88,6 @@ Examples:
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -109,8 +103,8 @@ Examples:
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
@@ -122,7 +116,7 @@ Examples:
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
@@ -269,7 +263,10 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, _ := output.ParseFormat(opts.Format)
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
@@ -346,24 +343,6 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatPretty:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
scanResult := output.ScanForSafety(commandPath, result, errOut)
|
||||
if scanResult.Blocked {
|
||||
return errs.MarkRaw(scanResult.BlockErr)
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return nil
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
|
||||
@@ -68,42 +68,6 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_OutputFormatResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "json shorthand", args: []string{"--json"}, want: "json"},
|
||||
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
|
||||
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
args := []string{"GET", "/open-apis/test", "--as", "bot"}
|
||||
cmd.SetArgs(append(args, tt.args...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected options to be captured")
|
||||
}
|
||||
if gotOpts.Format != tt.want {
|
||||
t.Fatalf("format = %q, want %q", gotOpts.Format, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -205,43 +169,6 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PrettyFormatsRealResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -644,46 +571,6 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("page-all pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type apiContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
|
||||
@@ -355,31 +355,7 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONShorthand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected options to be captured")
|
||||
}
|
||||
if !gotOpts.JSON || gotOpts.Format != "json" {
|
||||
t.Fatalf("JSON = %v, format = %q; want true, json", gotOpts.JSON, gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -400,8 +376,8 @@ func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected explicit format pretty, got %s", gotOpts.Format)
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -43,11 +41,8 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.JSONPrettyFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return output.JSONPrettyFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -80,10 +75,10 @@ func authScopesRun(opts *ScopesOptions) error {
|
||||
"failed to get app scope info: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
for _, s := range appInfo.UserScopes {
|
||||
fmt.Fprintf(f.IOStreams.Out, " • %s\n", s)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
|
||||
}
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -28,35 +26,6 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||
t.Cleanup(func() { getAppInfoFn = prev })
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_PrettyWritesBulletedScopesToStdout(t *testing.T) {
|
||||
prev := getAppInfoFn
|
||||
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
return &appInfo{UserScopes: []string{"im:message"}}, nil
|
||||
}
|
||||
t.Cleanup(func() { getAppInfoFn = prev })
|
||||
|
||||
opts := scopesTestFactory(t)
|
||||
opts.Format = "pretty"
|
||||
out, ok := opts.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout type = %T, want *bytes.Buffer", opts.Factory.IOStreams.Out)
|
||||
}
|
||||
errOut, ok := opts.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stderr type = %T, want *bytes.Buffer", opts.Factory.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
if err := authScopesRun(opts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), " • im:message\n") {
|
||||
t.Fatalf("stdout missing bulleted scope: %q", out.String())
|
||||
}
|
||||
if strings.Contains(errOut.String(), "im:message") {
|
||||
t.Fatalf("scope should remain on stdout, stderr = %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
|
||||
// authScopesRun. Config has a non-empty AppID so we get past the config gate
|
||||
// and reach the getAppInfoFn call.
|
||||
|
||||
@@ -234,7 +234,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
installCobraValidationGuards(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)
|
||||
|
||||
@@ -4,15 +4,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -30,85 +26,6 @@ func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildValidationTestRoot(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
return Build(context.Background(), cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}),
|
||||
WithoutPlugins(),
|
||||
WithoutServiceCommands(),
|
||||
WithoutStrictMode(),
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuiltRoot_TopLevelTypoReturnsStructuredSuggestion(t *testing.T) {
|
||||
root := buildValidationTestRoot(t)
|
||||
root.SetArgs([]string{"imm"})
|
||||
|
||||
err := root.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "imm" {
|
||||
t.Fatalf("params = %v, want one entry named imm", validationErr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range validationErr.Params[0].Suggestions {
|
||||
if candidate == "im" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("suggestions = %v, want im", validationErr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltRoot_SheetsOneRequiredGroupReturnsValidationExit(t *testing.T) {
|
||||
root := buildValidationTestRoot(t)
|
||||
root.SetArgs([]string{
|
||||
"sheets", "+csv-put",
|
||||
"--url", "https://example.com/sheets/token",
|
||||
"--sheet-name", "Sheet1",
|
||||
"--csv", "a,b",
|
||||
})
|
||||
|
||||
err := root.Execute()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltRoot_MailUnknownFlagUsesSharedSuggestions(t *testing.T) {
|
||||
root := buildValidationTestRoot(t)
|
||||
root.SetArgs([]string{"mail", "+send", "--tos", "alice@example.com"})
|
||||
|
||||
err := root.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "--tos" {
|
||||
t.Fatalf("params = %v, want one entry named --tos", validationErr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range validationErr.Params[0].Suggestions {
|
||||
if candidate == "--to" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("suggestions = %v, want --to", validationErr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func findCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
parts := strings.Fields(path)
|
||||
cmd := root
|
||||
|
||||
@@ -9,18 +9,28 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func parseFlagError(t *testing.T, c *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
err := c.Flags().Parse(args)
|
||||
if err == nil {
|
||||
t.Fatalf("Parse(%v) returned nil", args)
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
@@ -29,7 +39,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
@@ -72,86 +82,23 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("find", "", "")
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--find"))
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
// Non-unknown-flag errors retain the same validation shape and identify the
|
||||
// flag from pflag's typed ValueRequiredError.
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--find" {
|
||||
t.Errorf("Params=%v, want one entry named --find", verr.Params)
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_SheetsListsVisibleFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
sheets := &cobra.Command{Use: "sheets"}
|
||||
cmdmeta.SetDomain(sheets, "sheets")
|
||||
root.AddCommand(sheets)
|
||||
sheets.Flags().String("range", "", "")
|
||||
sheets.Flags().Int("width", 0, "")
|
||||
|
||||
err := flagDidYouMean(sheets, parseFlagError(t, sheets, "--cols"))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
for _, want := range []string{"--range", "--width"} {
|
||||
if !strings.Contains(validationErr.Hint, want) {
|
||||
t.Errorf("hint should include %q, got %q", want, validationErr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_InvalidValueTypedError(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().Int("width", 0, "")
|
||||
|
||||
// A non-numeric value for a typed flag surfaces pflag's InvalidValueError.
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--width=abc"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--width" || verr.Params[0].Reason != "invalid flag value" {
|
||||
t.Errorf("Params = %v, want one --width entry with reason 'invalid flag value'", verr.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_InvalidSyntaxTypedError(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
|
||||
// An empty flag name is bad flag syntax and surfaces pflag's InvalidSyntaxError.
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--=oops"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Reason != "invalid flag syntax" {
|
||||
t.Errorf("Params = %v, want one entry with reason 'invalid flag syntax'", verr.Params)
|
||||
}
|
||||
}
|
||||
|
||||
296
cmd/root.go
296
cmd/root.go
@@ -241,10 +241,10 @@ func configureFlagCompletions(args []string) {
|
||||
// dispatcher no longer promotes any legacy shape here.
|
||||
// 2. PartialFailure / BareError signals: the result envelope is already on
|
||||
// stdout; honor the exit code and write nothing to stderr.
|
||||
// 3. Any untyped error that reaches this boundary is an internal fault.
|
||||
// Cobra argument, required-flag and flag-group errors are typed at their
|
||||
// execution stages by installCobraValidationGuards; flag parse errors are
|
||||
// typed by the root FlagErrorFunc.
|
||||
// 3. Residual cobra usage errors (missing required flag, unknown command,
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
@@ -283,14 +283,57 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// Every user-input stage is typed before execution. A bare error here has
|
||||
// crossed that boundary unexpectedly and must remain visible as an internal
|
||||
// fault instead of being guessed from English message fragments.
|
||||
fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
// Errors reaching here are untyped: every RunE returns a typed errs.* error
|
||||
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
|
||||
// is either a cobra usage mistake (missing required flag, unknown command,
|
||||
// wrong arg count), which cobra surfaces as a plain error identified by its
|
||||
// stable text — the same external contract unknownFlagName relies on — or an
|
||||
// untyped error that leaked past the typed boundary. Classify the former as
|
||||
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
|
||||
// internal fault (exit 5) rather than blaming the user's input. The message
|
||||
// is preserved either way, and the typed envelope still carries any pending
|
||||
// deprecation notice.
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
||||
return output.ExitCodeOf(fallback)
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
@@ -302,10 +345,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
// with reason_code=risk_not_annotated.
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
// Cobra's legacy Args fallback rejects an unknown top-level token before
|
||||
// RunE can produce ranked suggestions. Explicitly accepting positional
|
||||
// tokens lets every pure group, including root, reach the shared guard.
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
@@ -323,132 +362,6 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// installCobraValidationGuards types errors at the stage where Cobra knows
|
||||
// they are user input: positional argument validation, required flags and flag
|
||||
// groups. This removes the need for final-boundary message matching.
|
||||
func installCobraValidationGuards(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if validateArgs := cmd.Args; validateArgs != nil {
|
||||
cmd.Args = func(c *cobra.Command, args []string) error {
|
||||
err := validateArgs(c, args)
|
||||
if err == nil || errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
previousPreRunE := cmd.PreRunE
|
||||
previousPreRun := cmd.PreRun
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if previousPreRunE != nil {
|
||||
if err := previousPreRunE(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if previousPreRun != nil {
|
||||
previousPreRun(c, args)
|
||||
}
|
||||
if err := c.ValidateRequiredFlags(); err != nil {
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
c.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 {
|
||||
return
|
||||
}
|
||||
validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"})
|
||||
})
|
||||
return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath())
|
||||
}
|
||||
if err := c.ValidateFlagGroups(); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).
|
||||
WithParams(invalidFlagGroupParams(err.Error())...).
|
||||
WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.PreRun = nil
|
||||
|
||||
for _, child := range cmd.Commands() {
|
||||
installCobraValidationGuards(child)
|
||||
}
|
||||
}
|
||||
|
||||
type flagGroupConstraint int
|
||||
|
||||
const (
|
||||
flagGroupRequiredTogether flagGroupConstraint = iota
|
||||
flagGroupOneRequired
|
||||
flagGroupMutuallyExclusive
|
||||
)
|
||||
|
||||
// invalidFlagGroupParams extracts the offending flag group from Cobra's
|
||||
// flag-group validation error. The message always names the group as
|
||||
// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are
|
||||
// read straight from the message instead of re-deriving Cobra's private group
|
||||
// state. Pinned to cobra v1.10.2's message format (see go.mod).
|
||||
func invalidFlagGroupParams(message string) []errs.InvalidParam {
|
||||
names := flagGroupNamesFromMessage(message)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message))
|
||||
}
|
||||
|
||||
func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam {
|
||||
flagNames := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimLeft(name, "-")
|
||||
if name != "" {
|
||||
flagNames = append(flagNames, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(flagNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
group := "[" + strings.Join(flagNames, " ") + "]"
|
||||
reason := "invalid flag combination in " + group
|
||||
switch constraint {
|
||||
case flagGroupRequiredTogether:
|
||||
reason = "all of " + group + " required together"
|
||||
case flagGroupOneRequired:
|
||||
reason = "one of " + group + " required"
|
||||
case flagGroupMutuallyExclusive:
|
||||
reason = "only one of " + group + " allowed"
|
||||
}
|
||||
params := make([]errs.InvalidParam, 0, len(flagNames))
|
||||
for _, name := range flagNames {
|
||||
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func flagGroupNamesFromMessage(message string) []string {
|
||||
start := strings.IndexByte(message, '[')
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
end := strings.IndexByte(message[start+1:], ']')
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(message[start+1 : start+1+end])
|
||||
}
|
||||
|
||||
func flagGroupConstraintFromMessage(message string) flagGroupConstraint {
|
||||
switch {
|
||||
case strings.Contains(message, "at least one of the flags"):
|
||||
return flagGroupOneRequired
|
||||
case strings.Contains(message, "must all be set"):
|
||||
return flagGroupRequiredTogether
|
||||
case strings.Contains(message, "none of the others can be"):
|
||||
return flagGroupMutuallyExclusive
|
||||
default:
|
||||
return flagGroupConstraint(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
|
||||
// with a typed *errs.ValidationError: a flag that belongs to a missing
|
||||
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
|
||||
@@ -679,21 +592,17 @@ func isLarkDomain(c *cobra.Command) bool {
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
|
||||
// classifies pflag's typed parse errors and emits one stable validation shape.
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
|
||||
// nothing) and the offending flag in `params`. Other flag errors stay typed
|
||||
// but generic.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
if ferr == nil {
|
||||
return nil
|
||||
}
|
||||
var notExist *pflag.NotExistError
|
||||
if !errors.As(ferr, ¬Exist) {
|
||||
return typedFlagParseError(c, ferr)
|
||||
}
|
||||
|
||||
name := notExist.GetSpecifiedName()
|
||||
rawName := "--" + name
|
||||
if notExist.GetSpecifiedShortnames() != "" {
|
||||
rawName = "-" + name
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
@@ -705,69 +614,36 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
if cmdmeta.Domain(c) == "sheets" {
|
||||
if list := inlineVisibleFlags(valid); list != "" {
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list)
|
||||
} else {
|
||||
hint = "valid flags: " + list
|
||||
}
|
||||
}
|
||||
}
|
||||
// The ranked candidates ride on the param as machine-readable Suggestions so
|
||||
// an agent can retry without parsing the hint; the hint carries the same
|
||||
// candidates as prose. The full valid-flag list stays recoverable via --help.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", rawName, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint).
|
||||
WithCause(ferr)
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func typedFlagParseError(c *cobra.Command, ferr error) error {
|
||||
param := ""
|
||||
reason := "flag parse error"
|
||||
var valueRequired *pflag.ValueRequiredError
|
||||
var invalidValue *pflag.InvalidValueError
|
||||
var invalidSyntax *pflag.InvalidSyntaxError
|
||||
switch {
|
||||
case errors.As(ferr, &valueRequired):
|
||||
param = "--" + valueRequired.GetSpecifiedName()
|
||||
if valueRequired.GetSpecifiedShortnames() != "" {
|
||||
param = "-" + valueRequired.GetSpecifiedName()
|
||||
}
|
||||
reason = "flag value is required"
|
||||
case errors.As(ferr, &invalidValue):
|
||||
if invalidValue.GetFlag() != nil {
|
||||
param = "--" + invalidValue.GetFlag().Name
|
||||
}
|
||||
reason = "invalid flag value"
|
||||
case errors.As(ferr, &invalidSyntax):
|
||||
param = invalidSyntax.GetSpecifiedFlag()
|
||||
reason = "invalid flag syntax"
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath()).
|
||||
WithCause(ferr)
|
||||
if param != "" {
|
||||
validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason})
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func inlineVisibleFlags(names []string) string {
|
||||
const limit = 25
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := names
|
||||
suffix := ""
|
||||
if len(shown) > limit {
|
||||
shown = shown[:limit]
|
||||
suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit)
|
||||
}
|
||||
flags := make([]string, len(shown))
|
||||
for i, name := range shown {
|
||||
flags[i] = "--" + name
|
||||
}
|
||||
return strings.Join(flags, ", ") + suffix
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
|
||||
111
cmd/root_test.go
111
cmd/root_test.go
@@ -6,7 +6,6 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -285,9 +284,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values").
|
||||
WithParam("--values")
|
||||
exit := handleRootError(f, err)
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
|
||||
// errs.* error, so it reaches the deprecation fallback.
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -382,9 +381,10 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
|
||||
return errObj
|
||||
}
|
||||
|
||||
// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are
|
||||
// typed at the Cobra validation stage, before the final dispatcher.
|
||||
func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
|
||||
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
|
||||
// usage error (missing required flag) is typed as invalid_argument with exit 2
|
||||
// even with no deprecation pending — never cobra's plain "Error:" line.
|
||||
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
@@ -393,15 +393,7 @@ func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmd.Flags().String("values", "", "")
|
||||
cmd.MarkFlagRequired("values")
|
||||
installCobraValidationGuards(cmd)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing required flag error")
|
||||
}
|
||||
exit := handleRootError(f, err)
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -419,93 +411,6 @@ func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCobraValidationGuardTypesFlagGroupErrorsWithParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mark func(*cobra.Command)
|
||||
args []string
|
||||
wantNames []string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "one required",
|
||||
mark: func(cmd *cobra.Command) {
|
||||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||||
},
|
||||
wantNames: []string{"--start-cell", "--range"},
|
||||
wantReason: "one of [--start-cell --range] required",
|
||||
},
|
||||
{
|
||||
name: "required together",
|
||||
mark: func(cmd *cobra.Command) {
|
||||
cmd.MarkFlagsRequiredTogether("start-cell", "range")
|
||||
},
|
||||
args: []string{"--start-cell", "A1"},
|
||||
wantNames: []string{"--start-cell", "--range"},
|
||||
wantReason: "all of [--start-cell --range] required together",
|
||||
},
|
||||
{
|
||||
name: "mutually exclusive",
|
||||
mark: func(cmd *cobra.Command) {
|
||||
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
|
||||
},
|
||||
args: []string{"--start-cell", "A1", "--range", "B2"},
|
||||
wantNames: []string{"--start-cell", "--range"},
|
||||
wantReason: "only one of [--start-cell --range] allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmd.Flags().String("start-cell", "", "")
|
||||
cmd.Flags().String("range", "", "")
|
||||
tt.mark(cmd)
|
||||
installCobraValidationGuards(cmd)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected flag group error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if len(validationErr.Params) != len(tt.wantNames) {
|
||||
t.Fatalf("params = %v, want %d entries", validationErr.Params, len(tt.wantNames))
|
||||
}
|
||||
for i, wantName := range tt.wantNames {
|
||||
if validationErr.Params[i].Name != wantName || validationErr.Params[i].Reason != tt.wantReason {
|
||||
t.Errorf("params[%d] = %+v, want name=%q reason=%q", i, validationErr.Params[i], wantName, tt.wantReason)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidFlagGroupParamsFromMessage(t *testing.T) {
|
||||
got := invalidFlagGroupParams("at least one of the flags in the group [start-cell range] is required")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("params = %v, want two entries", got)
|
||||
}
|
||||
for i, want := range []string{"--start-cell", "--range"} {
|
||||
if got[i].Name != want || got[i].Reason != "one of [--start-cell --range] required" {
|
||||
t.Errorf("params[%d] = %+v", i, got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
|
||||
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
|
||||
// typed boundary from a helper) is classified as an internal fault (exit 5),
|
||||
|
||||
@@ -140,7 +140,6 @@ type ServiceMethodOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JSON bool
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
@@ -269,11 +268,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -305,8 +299,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if spec.risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -317,7 +311,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
@@ -426,7 +420,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, _ := output.ParseFormat(opts.Format)
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
// Scope-insufficient (99991679) and all other Lark API codes route through
|
||||
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
||||
@@ -709,24 +706,6 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatPretty:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return apiErr
|
||||
}
|
||||
scanResult := output.ScanForSafety(commandPath, result, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(out, result, output.FormatPretty)
|
||||
return nil
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
|
||||
@@ -201,42 +201,6 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_OutputFormatResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "json shorthand", args: []string{"--json"}, want: "json"},
|
||||
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
|
||||
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
args := []string{"--as", "bot"}
|
||||
cmd.SetArgs(append(args, tt.args...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("expected options to be captured")
|
||||
}
|
||||
if captured.Format != tt.want {
|
||||
t.Fatalf("format = %q, want %q", captured.Format, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── dry-run / buildServiceRequest ──
|
||||
|
||||
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
||||
@@ -498,43 +462,6 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PrettyFormatsRealResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
@@ -576,48 +503,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("page-all pretty output data = %#v", got["data"])
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
|
||||
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
type serviceContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
@@ -910,23 +795,26 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if validationErr.Param != "--format" {
|
||||
t.Errorf("param = %q, want --format", validationErr.Param)
|
||||
if !strings.Contains(stderr.String(), "warning: unknown format") {
|
||||
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,38 +45,11 @@ func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
|
||||
if files.RunE == nil {
|
||||
t.Error("files should have RunE installed")
|
||||
}
|
||||
if root.Args == nil {
|
||||
t.Error("root should explicitly accept positional tokens so unknown commands reach RunE")
|
||||
}
|
||||
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
|
||||
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopLevelUnknownCommandReturnsStructuredSuggestion(t *testing.T) {
|
||||
root, _, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(root)
|
||||
root.SetArgs([]string{"driv"})
|
||||
|
||||
err := root.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "driv" {
|
||||
t.Fatalf("params = %v, want one entry named driv", validationErr.Params)
|
||||
}
|
||||
found := false
|
||||
for _, candidate := range validationErr.Params[0].Suggestions {
|
||||
if candidate == "drive" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("suggestions = %v, want drive", validationErr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
called := false
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// FormatCapabilities is the single description of the output formats a
|
||||
// command supports. Help text, shell completion, shorthand normalization and
|
||||
// runtime validation all consume the same value so they cannot drift apart.
|
||||
type FormatCapabilities struct {
|
||||
names []string
|
||||
}
|
||||
|
||||
var (
|
||||
// StandardFormats applies to API, service and ordinary shortcut commands.
|
||||
StandardFormats = NewFormatCapabilities("json", "pretty", "table", "ndjson", "csv")
|
||||
// JSONPrettyFormats applies to commands with a dedicated human renderer and
|
||||
// no streaming/tabular output, such as auth scopes.
|
||||
JSONPrettyFormats = NewFormatCapabilities("json", "pretty")
|
||||
)
|
||||
|
||||
// NewFormatCapabilities constructs an immutable format capability set.
|
||||
func NewFormatCapabilities(names ...string) FormatCapabilities {
|
||||
return FormatCapabilities{names: append([]string(nil), names...)}
|
||||
}
|
||||
|
||||
// Names returns a copy suitable for completion candidates.
|
||||
func (c FormatCapabilities) Names() []string {
|
||||
return append([]string(nil), c.names...)
|
||||
}
|
||||
|
||||
// Usage returns the canonical help text for a --format flag.
|
||||
func (c FormatCapabilities) Usage() string {
|
||||
return "output format: " + strings.Join(c.names, "|")
|
||||
}
|
||||
|
||||
// Supports reports whether name is part of this command's output contract.
|
||||
func (c FormatCapabilities) Supports(name string) bool {
|
||||
return slices.Contains(c.names, strings.ToLower(name))
|
||||
}
|
||||
|
||||
// Resolve applies the --json shorthand and validates the selected format.
|
||||
// An explicit --format always wins over --json.
|
||||
func (c FormatCapabilities) Resolve(format string, formatExplicit, jsonShorthand bool) (string, error) {
|
||||
if jsonShorthand && !formatExplicit {
|
||||
format = "json"
|
||||
}
|
||||
format = strings.ToLower(format)
|
||||
if c.Supports(format) {
|
||||
return format, nil
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported output format %q; supported formats: %s", format, strings.Join(c.names, ", ")).
|
||||
WithParam("--format")
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestFormatCapabilitiesResolve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
formatExplicit bool
|
||||
jsonShorthand bool
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default", format: "json", want: "json"},
|
||||
{name: "json shorthand", format: "table", jsonShorthand: true, want: "json"},
|
||||
{name: "explicit format wins", format: "table", formatExplicit: true, jsonShorthand: true, want: "table"},
|
||||
{name: "case normalized", format: "PRETTY", formatExplicit: true, want: "pretty"},
|
||||
{name: "unsupported", format: "xml", formatExplicit: true, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := StandardFormats.Resolve(tt.format, tt.formatExplicit, tt.jsonShorthand)
|
||||
if tt.wantErr {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
|
||||
t.Fatalf("Resolve() error = %T %v, want invalid_argument --format validation error", err, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != tt.want {
|
||||
t.Fatalf("Resolve() = %q, %v; want %q, nil", got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCapabilitiesDriveHelpAndCompletion(t *testing.T) {
|
||||
if got, want := StandardFormats.Usage(), "output format: json|pretty|table|ndjson|csv"; got != want {
|
||||
t.Fatalf("Usage() = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := StandardFormats.Names(), []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Names() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -103,9 +103,6 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
func FormatValue(w io.Writer, data interface{}, format Format) {
|
||||
data = toGeneric(data)
|
||||
switch format {
|
||||
case FormatPretty:
|
||||
PrintJson(w, data)
|
||||
|
||||
case FormatNDJSON:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
@@ -152,9 +149,6 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
switch pf.Format {
|
||||
case FormatPretty:
|
||||
PrintJson(pf.W, data)
|
||||
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
PrintNdjson(pf.W, arr)
|
||||
|
||||
@@ -73,30 +73,6 @@ func TestFormatValue_Table(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_Pretty(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatPretty)
|
||||
out := buf.String()
|
||||
|
||||
if !json.Valid([]byte(out)) {
|
||||
t.Fatalf("pretty output should be valid JSON, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\n \"data\": {") || !strings.Contains(out, `"name": "Alice"`) {
|
||||
t.Fatalf("pretty output should be indented JSON, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "─") {
|
||||
t.Fatalf("pretty output should not render a table, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_CSV(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
@@ -173,20 +149,6 @@ func TestPaginatedFormatter_Table(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_Pretty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatPretty)
|
||||
|
||||
pf.FormatPage([]interface{}{map[string]interface{}{"name": "Alice"}})
|
||||
out := buf.String()
|
||||
if !json.Valid([]byte(out)) {
|
||||
t.Fatalf("paginated pretty output should be valid JSON, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\n {") || !strings.Contains(out, `"name": "Alice"`) {
|
||||
t.Fatalf("paginated pretty output should be indented JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_CSV(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatCSV)
|
||||
|
||||
@@ -10,7 +10,6 @@ type Format int
|
||||
|
||||
const (
|
||||
FormatJSON Format = iota
|
||||
FormatPretty
|
||||
FormatNDJSON
|
||||
FormatTable
|
||||
FormatCSV
|
||||
@@ -23,8 +22,6 @@ func ParseFormat(s string) (Format, bool) {
|
||||
switch strings.ToLower(s) {
|
||||
case "json", "":
|
||||
return FormatJSON, true
|
||||
case "pretty":
|
||||
return FormatPretty, true
|
||||
case "ndjson":
|
||||
return FormatNDJSON, true
|
||||
case "table":
|
||||
@@ -39,8 +36,6 @@ func ParseFormat(s string) (Format, bool) {
|
||||
// String returns the string representation of a Format.
|
||||
func (f Format) String() string {
|
||||
switch f {
|
||||
case FormatPretty:
|
||||
return "pretty"
|
||||
case FormatNDJSON:
|
||||
return "ndjson"
|
||||
case FormatTable:
|
||||
|
||||
@@ -14,9 +14,6 @@ func TestParseFormat(t *testing.T) {
|
||||
{"json", FormatJSON, true},
|
||||
{"JSON", FormatJSON, true},
|
||||
{"Json", FormatJSON, true},
|
||||
{"pretty", FormatPretty, true},
|
||||
{"PRETTY", FormatPretty, true},
|
||||
{"Pretty", FormatPretty, true},
|
||||
{"ndjson", FormatNDJSON, true},
|
||||
{"NDJSON", FormatNDJSON, true},
|
||||
{"Ndjson", FormatNDJSON, true},
|
||||
@@ -55,7 +52,6 @@ func TestFormatString(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{FormatJSON, "json"},
|
||||
{FormatPretty, "pretty"},
|
||||
{FormatNDJSON, "ndjson"},
|
||||
{FormatTable, "table"},
|
||||
{FormatCSV, "csv"},
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
rootcmd "github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -95,7 +94,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif
|
||||
Short: c.Short,
|
||||
Example: c.Example,
|
||||
Hidden: c.Hidden,
|
||||
Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c),
|
||||
Runnable: c.Runnable(),
|
||||
Source: source,
|
||||
Generated: cmdmeta.Generated(c),
|
||||
Identities: cmdmeta.Identities(c),
|
||||
|
||||
@@ -90,20 +90,6 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMarksPureNavigationGroupsNonRunnable(t *testing.T) {
|
||||
got, err := collectCommandIndex(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectCommandIndex() error = %v", err)
|
||||
}
|
||||
cmd := findManifestCommand(&got, "approval")
|
||||
if cmd == nil {
|
||||
t.Fatalf("approval group not found")
|
||||
}
|
||||
if cmd.Runnable {
|
||||
t.Fatalf("approval is a navigation group and must not be exported as runnable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.69",
|
||||
"version": "1.0.70",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
253
shortcuts/apps/apps_automation_create.go
Normal file
253
shortcuts/apps/apps_automation_create.go
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationCreate creates an automation trigger (type-dispatched condition).
|
||||
var AppsAutomationCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-create",
|
||||
Description: "Create an automation trigger (cron/record-change/webhook/feishu-approval); created disabled",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name daily --trigger-type cron --cron '0 9 * * *'",
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name onUpd --trigger-type record-change --table <tbl> --event UPDATE",
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name hook --trigger-type webhook",
|
||||
"Example: lark-cli apps +automation-create --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name (unique within app, <=100 chars)", Required: true},
|
||||
{Name: "trigger-type", Desc: "cron | record-change | webhook | feishu-approval", Required: true},
|
||||
{Name: "description", Desc: "optional description (<=50 chars)"},
|
||||
{Name: "cron", Desc: "[cron] 5-field cron expression, e.g. '0 9 * * *' (min interval 30m)"},
|
||||
{Name: "timezone", Desc: "[cron] IANA timezone (default Asia/Shanghai)"},
|
||||
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
|
||||
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
|
||||
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
|
||||
{Name: "white-ip-list", Desc: "[webhook] JSON array of allowed IPs"},
|
||||
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
|
||||
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
|
||||
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
|
||||
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
|
||||
{Name: "status", Desc: "optional initial status: enabled | disabled (default disabled; backend supports create+enable in one call)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
|
||||
if cliType == "" {
|
||||
return appsValidationParamError("--trigger-type", "--trigger-type is required (cron/record-change/webhook/feishu-approval)")
|
||||
}
|
||||
// mapTriggerType also runs inside buildAutomationCreateBody, but
|
||||
// re-running it up-front keeps the cross-family guard's error
|
||||
// reachable — otherwise an unknown --trigger-type would bail out
|
||||
// with the same guard's "belongs to trigger-type" wording, which
|
||||
// misleads callers who typoed the type itself.
|
||||
if _, err := mapTriggerType(cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject condition flags that do not belong to the selected type.
|
||||
// buildAutomationCreateBody's switch used to silently drop them
|
||||
// (e.g. --trigger-type webhook --cron '0 9 * * *' created a webhook
|
||||
// with no cron, though the caller believed --cron was set).
|
||||
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
body, _ := buildAutomationCreateBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(automationListPath(appID)).
|
||||
Desc("Create automation trigger").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", automationListPath(appID), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
// Bearer-token redaction reverse invariant: the backend create path
|
||||
// re-reads the freshly created trigger through the same read-path
|
||||
// converter used by get/list — theoretically capable of returning a
|
||||
// plaintext bearer token. On a fresh create the token is not yet
|
||||
// enabled and this response should not carry plaintext, but redact
|
||||
// for defense-in-depth and to keep every read-shaped output path
|
||||
// (create / get / list / update-patch) consistently scrubbed.
|
||||
redacted := redactWebhookToken(data)
|
||||
trigger, _ := redacted["trigger"].(map[string]interface{})
|
||||
rctx.OutFormat(redacted, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created trigger: %v [%v] status: %v\n",
|
||||
trigger["name"], trigger["trigger_type"], trigger["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildAutomationCreateBody assembles {name, description?, trigger_type, <type>_condition}.
|
||||
func buildAutomationCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
|
||||
snake, err := mapTriggerType(cliType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
if err := validateAutomationNameLen(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"trigger_type": snake,
|
||||
}
|
||||
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
|
||||
if err := validateAutomationDescriptionLen(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["description"] = d
|
||||
}
|
||||
// --status is an optional passthrough: when set, backend creates + enables
|
||||
// (or leaves disabled) in one call. Omitting the field lets the backend
|
||||
// default (disabled) apply, matching the spec's default-disabled invariant.
|
||||
if s := strings.TrimSpace(rctx.Str("status")); s != "" {
|
||||
if s != "enabled" && s != "disabled" {
|
||||
return nil, appsValidationParamError("--status",
|
||||
"--status must be enabled or disabled, got %q", s)
|
||||
}
|
||||
body["status"] = s
|
||||
}
|
||||
switch cliType {
|
||||
case "cron":
|
||||
cond, err := buildCronCondition(rctx.Str("cron"), rctx.Str("timezone"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["cron_condition"] = cond
|
||||
case "record-change":
|
||||
fields, err := parseFieldsFlag(rctx.Str("fields"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["record_change_condition"] = cond
|
||||
case "webhook":
|
||||
ipList, err := parseIPListFlag(rctx.Str("white-ip-list"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["webhook_condition"] = buildWebhookCondition(ipList)
|
||||
case "feishu-approval":
|
||||
eventType := strings.TrimSpace(rctx.Str("event-type"))
|
||||
if eventType == "" {
|
||||
return nil, appsValidationParamError("--event-type", "--event-type is required for feishu-approval (approval_instance/approval_task)")
|
||||
}
|
||||
raw := rctx.StrArray("instance-status")
|
||||
if eventType == "approval_task" {
|
||||
raw = rctx.StrArray("task-status")
|
||||
}
|
||||
// buildApprovalCondition stores the passed statuses verbatim (it only
|
||||
// uppercases for validation), so normalize to the uppercase enum here to
|
||||
// guarantee the backend receives canonical values (foundation review).
|
||||
statuses := normalizeApprovalStatuses(raw)
|
||||
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["feishu_approval_condition"] = cond
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// normalizeApprovalStatuses trims and uppercases each status so the body carries
|
||||
// the canonical enum values expected by the backend.
|
||||
func normalizeApprovalStatuses(raw []string) []string {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
out = append(out, strings.ToUpper(strings.TrimSpace(s)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseFieldsFlag parses --fields JSON array; empty → nil.
|
||||
func parseFieldsFlag(raw string) ([]string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return nil, appsValidationParamError("--fields", "--fields must be a JSON array of strings: %v", err)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
// parseIPListFlag parses --white-ip-list JSON array; empty → nil (field
|
||||
// omitted). Each entry is validated as an IPv4/IPv6 address or CIDR, matching
|
||||
// the defense-in-depth stance the record-change --event whitelist takes —
|
||||
// silent acceptance of malformed IPs would let a typoed entry (`"1.1.1.1 "`
|
||||
// with trailing space, `"not-an-ip"`, or `"10.0.0.256"`) narrow the webhook
|
||||
// caller allowlist to nothing while the operator believes it is enforcing
|
||||
// origin restrictions.
|
||||
func parseIPListFlag(raw string) ([]string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return nil, appsValidationParamError("--white-ip-list", "--white-ip-list must be a JSON array of strings: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for i, entry := range arr {
|
||||
trimmed := strings.TrimSpace(entry)
|
||||
if trimmed == "" {
|
||||
return nil, appsValidationParamError("--white-ip-list",
|
||||
"--white-ip-list entry %d is empty; either drop it or provide a valid IP/CIDR", i)
|
||||
}
|
||||
if net.ParseIP(trimmed) != nil {
|
||||
out = append(out, trimmed)
|
||||
continue
|
||||
}
|
||||
if _, _, cidrErr := net.ParseCIDR(trimmed); cidrErr == nil {
|
||||
out = append(out, trimmed)
|
||||
continue
|
||||
}
|
||||
return nil, appsValidationParamError("--white-ip-list",
|
||||
"--white-ip-list entry %d %q is not a valid IPv4/IPv6 address or CIDR block", i, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
265
shortcuts/apps/apps_automation_create_test.go
Normal file
265
shortcuts/apps/apps_automation_create_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func automationCreateFlagDefs() map[string]string {
|
||||
return map[string]string{
|
||||
"app-id": "string", "name": "string", "trigger-type": "string", "description": "string",
|
||||
"cron": "string", "timezone": "string",
|
||||
"table": "string", "event": "string", "fields": "string",
|
||||
"white-ip-list": "string",
|
||||
"approval-code": "string", "event-type": "string",
|
||||
"instance-status": "string_array", "task-status": "string_array",
|
||||
"status": "string",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationCreateCron_BuildsBody(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "daily", "trigger-type": "cron", "cron": "0 9 * * *"})
|
||||
// Real backend response wraps the created trigger under `trigger` (a live
|
||||
// test-env probe confirmed the shape, same as GET/PUT). The Execute pretty
|
||||
// path reads trigger["name"]/["trigger_type"]/["status"] from that key —
|
||||
// a flat fixture makes the pretty path print `<nil>` and only passes via
|
||||
// the JSON envelope, which hides regressions in the pretty branch.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "daily", "trigger_type": "cron", "status": "disabled",
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "daily") {
|
||||
t.Errorf("create output must contain trigger name: %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationCreate_MissingType(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
// TestAutomationCreate_CrossFamilyFlagsRejected pins the F1 guard: a condition
|
||||
// flag from a family other than --trigger-type used to be silently dropped by
|
||||
// buildAutomationCreateBody's single-branch switch, so
|
||||
// `--trigger-type webhook --cron '0 9 * * *'` created a webhook with no cron
|
||||
// but returned success. Validate now rejects the cross-family flag up-front.
|
||||
func TestAutomationCreate_CrossFamilyFlagsRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{"webhook_with_cron",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "webhook",
|
||||
"cron": "0 9 * * *",
|
||||
}, "--cron"},
|
||||
{"cron_with_white_ip_list",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
|
||||
}, "--white-ip-list"},
|
||||
{"record_change_with_event_type",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "record-change",
|
||||
"table": "tbl", "event": "UPDATE", "event-type": "approval_instance",
|
||||
}, "--event-type"},
|
||||
{"feishu_approval_with_table",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "APPROVED",
|
||||
"table": "tbl",
|
||||
}, "--table"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, tc.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_UnknownTriggerTypeRejected: --trigger-type must be one
|
||||
// of the four supported kebab-case values. A typo used to sneak past Validate
|
||||
// (buildAutomationCreateBody caught it, but only after the cross-family guard
|
||||
// would otherwise fire with a misleading "belongs to type" message).
|
||||
func TestAutomationCreate_UnknownTriggerTypeRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "bogus"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
func TestAutomationCreateCron_Sub30MinRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "cron", "cron": "*/5 * * * *"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
func TestAutomationCreateRecordChange_MissingEvent(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "record-change", "table": "tbl"})
|
||||
err := AppsAutomationCreate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
}
|
||||
|
||||
func TestAutomationCreateApproval_CodeOptional(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "APPROVED"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "n", "status": "disabled"}},
|
||||
})
|
||||
if err := AppsAutomationCreate.Validate(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("approval without --approval-code must pass validation: %v", err)
|
||||
}
|
||||
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreateApproval_StatusUppercased asserts that a lowercase status
|
||||
// passed via --instance-status is normalized to the uppercase enum in the body
|
||||
// before it reaches the backend (foundation review: buildApprovalCondition stores
|
||||
// the raw statuses, so create must uppercase them itself).
|
||||
func TestAutomationCreateApproval_StatusUppercased(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "approved"})
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAutomationCreateBody() = %v", err)
|
||||
}
|
||||
cond, ok := body["feishu_approval_condition"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("feishu_approval_condition missing or wrong type: %+v", body)
|
||||
}
|
||||
statuses, ok := cond["status"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("status must be []string: %+v", cond)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0] != "APPROVED" {
|
||||
t.Errorf("lowercase status must be uppercased to APPROVED, got %v", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_RedactsWebhookToken covers the bearer-token redaction
|
||||
// reverse invariant on the create path against the real response shape (a
|
||||
// live test-env probe confirmed POST wraps the trigger under a `trigger`
|
||||
// key, same as GET/PUT). The backend create path re-reads the freshly
|
||||
// created trigger and returns it through the same read-path converter used
|
||||
// by get/list — theoretically capable of returning a plaintext bearer
|
||||
// token. Defense-in-depth: CLI create must also redact so every read-shaped
|
||||
// output path is consistently scrubbed.
|
||||
func TestAutomationCreate_RedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "disabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_CREATE_TOKEN",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_CREATE_TOKEN") {
|
||||
t.Errorf("create must never surface plaintext token: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_StatusPassthrough verifies --status is included in the
|
||||
// POST body when set. Backend supports create+enable in one call via the
|
||||
// optional status field; CLI passes it through unchanged.
|
||||
func TestAutomationCreate_StatusPassthrough(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "status": "enabled",
|
||||
})
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBody: %v", err)
|
||||
}
|
||||
if body["status"] != "enabled" {
|
||||
t.Errorf("status = %v; want enabled", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_StatusInvalid: only enabled/disabled accepted.
|
||||
func TestAutomationCreate_StatusInvalid(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "status": "bogus",
|
||||
})
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
assertValidationParamError(t, err, "--status")
|
||||
}
|
||||
|
||||
// TestAutomationCreate_StatusOmitted: when --status is not set, body must not
|
||||
// carry a status field — backend applies its default (disabled).
|
||||
func TestAutomationCreate_StatusOmitted(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *",
|
||||
})
|
||||
body, err := buildAutomationCreateBody(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBody: %v", err)
|
||||
}
|
||||
if _, present := body["status"]; present {
|
||||
t.Errorf("status must be omitted when --status not set, got %v", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationCreate_NameTooLong: --name > 100 chars is rejected locally with
|
||||
// a typed --name error, sparing the round trip to the backend.
|
||||
func TestAutomationCreate_NameTooLong(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": strings.Repeat("n", automationNameMaxLen+1),
|
||||
"trigger-type": "cron", "cron": "0 9 * * *",
|
||||
})
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
assertValidationParamError(t, err, "--name")
|
||||
}
|
||||
|
||||
// TestAutomationCreate_DescriptionTooLong: --description > 50 chars is rejected
|
||||
// locally with a typed --description error.
|
||||
func TestAutomationCreate_DescriptionTooLong(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "n", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "description": strings.Repeat("d", automationDescriptionMaxLen+1),
|
||||
})
|
||||
_, err := buildAutomationCreateBody(rctx)
|
||||
assertValidationParamError(t, err, "--description")
|
||||
}
|
||||
38
shortcuts/apps/apps_automation_disable.go
Normal file
38
shortcuts/apps/apps_automation_disable.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationDisable disables a trigger. Maps to the shared status endpoint.
|
||||
var AppsAutomationDisable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-disable",
|
||||
Description: "Disable an automation trigger (stops auto-firing; does not delete)",
|
||||
Risk: "write",
|
||||
Tips: []string{"Example: lark-cli apps +automation-disable --app-id <id> --name <trigger_name>"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
},
|
||||
Validate: automationValidateName,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
|
||||
Desc("Disable automation trigger").
|
||||
Body(statusBodyFromAction(false))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return runAutomationStatus(rctx, false)
|
||||
},
|
||||
}
|
||||
70
shortcuts/apps/apps_automation_enable.go
Normal file
70
shortcuts/apps/apps_automation_enable.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationEnable enables (activates) a trigger. Maps to the shared status endpoint.
|
||||
var AppsAutomationEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-enable",
|
||||
Description: "Enable (activate) an automation trigger",
|
||||
Risk: "write",
|
||||
Tips: []string{"Example: lark-cli apps +automation-enable --app-id <id> --name <trigger_name>"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
},
|
||||
Validate: automationValidateName,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
|
||||
Desc("Enable automation trigger").
|
||||
Body(statusBodyFromAction(true))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return runAutomationStatus(rctx, true)
|
||||
},
|
||||
}
|
||||
|
||||
// runAutomationStatus is shared by enable/disable: PATCH .../triggers/{name}
|
||||
// with {"status": ...}. The status change happens on the parent resource per
|
||||
// the backend OpenAPI spec (see reference Python samples in the trigger test
|
||||
// fixtures) — there is intentionally no /status sub-path; the sole nested
|
||||
// endpoints under a trigger are the webhook credential lifecycle
|
||||
// (/webhook/token/status, /webhook/token/reset, /webhook/url/reset).
|
||||
//
|
||||
// The status endpoint returns {"success": true} on success. Pretty output is
|
||||
// synthesized from rctx.name and the desired action, since the response
|
||||
// intentionally carries no trigger object to fish name/status from.
|
||||
func runAutomationStatus(rctx *common.RuntimeContext, enable bool) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
data, err := rctx.CallAPITyped("PATCH", automationItemPath(appID, name), nil, statusBodyFromAction(enable))
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
desiredStatus := "disabled"
|
||||
if enable {
|
||||
desiredStatus = "enabled"
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "trigger %s status: %s\n", name, desiredStatus)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
73
shortcuts/apps/apps_automation_get.go
Normal file
73
shortcuts/apps/apps_automation_get.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationGet gets a single trigger's full config (webhook token redacted).
|
||||
var AppsAutomationGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-get",
|
||||
Description: "Get an automation trigger's config (webhook Bearer Token redacted)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-get --app-id <app_id> --name <trigger_name>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
},
|
||||
Validate: automationValidateName,
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
|
||||
Desc("Get automation trigger")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
data, err := rctx.CallAPITyped("GET", automationItemPath(appID, name), nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
redacted := redactWebhookToken(data)
|
||||
trigger, _ := redacted["trigger"].(map[string]interface{})
|
||||
rctx.OutFormat(redacted, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "name: %v\ntype: %v\nstatus: %v\n",
|
||||
trigger["name"], trigger["trigger_type"], trigger["status"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// automationValidateName validates --app-id and --name presence. Shared by get/update/enable/disable.
|
||||
func automationValidateName(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required").
|
||||
WithHint("find trigger names with `lark-cli apps +automation-list --app-id <app_id>`")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// automationNotFoundHint is the shared recovery hint when a trigger name may not exist.
|
||||
func automationNotFoundHint() string {
|
||||
return "verify the trigger name with `lark-cli apps +automation-list --app-id <app_id>`"
|
||||
}
|
||||
117
shortcuts/apps/apps_automation_get_test.go
Normal file
117
shortcuts/apps/apps_automation_get_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAutomationGetExecute_RedactsWebhookToken pins the redaction invariant
|
||||
// against the actual backend response shape (verified against a live test
|
||||
// environment): GET wraps the trigger under a `trigger` key, so the CLI
|
||||
// must scrub token_value inside data.trigger.trigger_condition. A previous
|
||||
// implementation only scrubbed data.trigger_condition and silently no-op'd
|
||||
// here — this test would fail the moment someone reverts to top-level-only
|
||||
// scrubbing.
|
||||
func TestAutomationGetExecute_RedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "wh1"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_SECRET_NESTED",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_SECRET_NESTED") {
|
||||
t.Errorf("get must never surface plaintext token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "token_enabled") {
|
||||
t.Errorf("get must expose token_enabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationGet_MissingName(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x"})
|
||||
err := AppsAutomationGet.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--name")
|
||||
}
|
||||
|
||||
// TestAutomationGet_MissingAppID covers the sibling branch of Validate:
|
||||
// automationValidateName rejects an empty --app-id before checking --name.
|
||||
func TestAutomationGet_MissingAppID(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"name": "t1"})
|
||||
err := AppsAutomationGet.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-id")
|
||||
}
|
||||
|
||||
// TestAutomationGet_APIErrorAttachesNotFoundHint covers the failure branch of
|
||||
// Execute: a business error on GET must surface typed and carry the
|
||||
// automation-list hint so the caller has a next step.
|
||||
func TestAutomationGet_APIErrorAttachesNotFoundHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "missing"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
|
||||
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
|
||||
})
|
||||
err := AppsAutomationGet.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+automation-list") {
|
||||
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationGet_DryRunPreview exercises the DryRun closure and pins the
|
||||
// GET method + URL pattern that agents inspect before committing.
|
||||
func TestAutomationGet_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
preview := AppsAutomationGet.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"GET"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers/t1") {
|
||||
t.Errorf("preview missing expected GET/URL fields: %s", got)
|
||||
}
|
||||
}
|
||||
159
shortcuts/apps/apps_automation_list.go
Normal file
159
shortcuts/apps/apps_automation_list.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationList lists an app's automation triggers (all 4 types).
|
||||
var AppsAutomationList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-list",
|
||||
Description: "List a Miaoda app's automation triggers (cron/record-change/webhook/feishu-approval)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-list --app-id <app_id>",
|
||||
"Example: lark-cli apps +automation-list --app-id <app_id> --trigger-type webhook",
|
||||
"Example: lark-cli apps +automation-list --app-id <app_id> --all # aggregate all pages",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "trigger-type", Desc: "filter by type: cron | record-change | webhook | feishu-approval"},
|
||||
{Name: "page-size", Type: "int", Desc: "page size (server default 50, max 100)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
{Name: "all", Type: "bool", Desc: "auto-aggregate all pages until has_more=false"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if tt := strings.TrimSpace(rctx.Str("trigger-type")); tt != "" {
|
||||
if _, err := mapTriggerType(tt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(automationListPath(appID)).
|
||||
Desc("List automation triggers").
|
||||
Params(buildAutomationListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := automationListPath(appID)
|
||||
params := buildAutomationListParams(rctx)
|
||||
if rctx.Bool("all") {
|
||||
return executeAutomationListAll(rctx, path, params)
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", path, params, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
return outputAutomationList(rctx, data)
|
||||
},
|
||||
}
|
||||
|
||||
// buildAutomationListParams 组装 list 查询参数。--trigger-type kebab→snake 下推给后端。
|
||||
func buildAutomationListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if tt := strings.TrimSpace(rctx.Str("trigger-type")); tt != "" {
|
||||
if snake, err := mapTriggerType(tt); err == nil {
|
||||
params["trigger_type"] = snake
|
||||
}
|
||||
}
|
||||
if rctx.Changed("page-size") {
|
||||
params["page_size"] = rctx.Int("page-size")
|
||||
}
|
||||
if pt := strings.TrimSpace(rctx.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// executeAutomationListAll 循环翻页聚合到 has_more=false(禁止静默漏项)。
|
||||
// 用页数上限 + 已见 token 检测防止后端非收敛响应导致无限循环。
|
||||
const automationListAllMaxPages = 100
|
||||
|
||||
func executeAutomationListAll(rctx *common.RuntimeContext, path string, params map[string]interface{}) error {
|
||||
all := make([]interface{}, 0, 16)
|
||||
seen := map[string]struct{}{}
|
||||
token := ""
|
||||
for pages := 0; ; pages++ {
|
||||
if pages >= automationListAllMaxPages {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"pagination did not converge after %d pages", automationListAllMaxPages)
|
||||
}
|
||||
p := make(map[string]interface{}, len(params)+1)
|
||||
for k, v := range params {
|
||||
p[k] = v
|
||||
}
|
||||
if token != "" {
|
||||
p["page_token"] = token
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", path, p, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
all = append(all, common.GetSlice(data, "items")...)
|
||||
hasMore, next := common.PaginationMeta(data)
|
||||
if !hasMore || next == "" {
|
||||
break
|
||||
}
|
||||
if _, ok := seen[next]; ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"pagination did not converge: page_token %q repeated", next)
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
token = next
|
||||
}
|
||||
out := map[string]interface{}{"items": all, "has_more": false}
|
||||
return outputAutomationList(rctx, out)
|
||||
}
|
||||
|
||||
// outputAutomationList 输出 items + 分页提示。逐条对 items 套 redactWebhookToken,
|
||||
// 抹掉 trigger_condition.token_value(list/get 恒不返回明文 Bearer Token);
|
||||
// 同时覆盖单页与 --all 聚合路径(executeAutomationListAll 也走这里)。
|
||||
func outputAutomationList(rctx *common.RuntimeContext, data map[string]interface{}) error {
|
||||
items := common.GetSlice(data, "items")
|
||||
redacted := make([]interface{}, 0, len(items))
|
||||
for _, it := range items {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
redacted = append(redacted, redactWebhookToken(m))
|
||||
} else {
|
||||
redacted = append(redacted, it)
|
||||
}
|
||||
}
|
||||
// 保留分页字段供 PaginationHint/PaginationMeta 读取(读的是同一个 map)。
|
||||
out := map[string]interface{}{
|
||||
"items": redacted,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d trigger(s)\n", len(redacted))
|
||||
for _, it := range redacted {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
fmt.Fprintf(w, "- %v [%v] %v\n", m["name"], m["trigger_type"], m["status"])
|
||||
}
|
||||
}
|
||||
fmt.Fprint(w, common.PaginationHint(out, len(redacted)))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
219
shortcuts/apps/apps_automation_list_test.go
Normal file
219
shortcuts/apps/apps_automation_list_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func automationListFlagDefs() map[string]string {
|
||||
return map[string]string{
|
||||
"app-id": "string", "trigger-type": "string",
|
||||
"page-size": "int", "page-token": "string", "all": "bool",
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationList_InvalidTriggerTypeFilter covers Validate's mapTriggerType
|
||||
// error branch: an unknown --trigger-type is rejected before any API call, with
|
||||
// a typed error naming the failing flag.
|
||||
func TestAutomationList_InvalidTriggerTypeFilter(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "trigger-type": "bogus"})
|
||||
err := AppsAutomationList.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
// TestAutomationListExecute_APIErrorAttachesAppIDHint covers the non-`--all`
|
||||
// error branch: a business error is surfaced typed and carries appIDListHint,
|
||||
// which points at +list rather than +automation-list because the recovery for
|
||||
// a failing collection GET is "check your app-id", not "check trigger names".
|
||||
func TestAutomationListExecute_APIErrorAttachesAppIDHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 400400002, "msg": "app not accessible"},
|
||||
})
|
||||
err := AppsAutomationList.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "apps +list") {
|
||||
t.Errorf("hint must point at `lark-cli apps +list`, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationList_DryRunPreview exercises the DryRun closure — pins the GET
|
||||
// method + collection URL + trigger_type param pushdown.
|
||||
func TestAutomationList_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "trigger-type": "webhook"})
|
||||
preview := AppsAutomationList.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"GET"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers") ||
|
||||
!strings.Contains(got, `"trigger_type":"webhook"`) {
|
||||
t.Errorf("preview missing expected GET/URL/params: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationListMeta(t *testing.T) {
|
||||
if AppsAutomationList.Command != "+automation-list" || AppsAutomationList.Risk != "read" {
|
||||
t.Errorf("meta mismatch: %+v", AppsAutomationList)
|
||||
}
|
||||
if len(AppsAutomationList.Scopes) != 1 || AppsAutomationList.Scopes[0] != "spark:app:read" {
|
||||
t.Errorf("scopes = %v", AppsAutomationList.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationListExecute_SinglePage(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "t_cron", "trigger_type": "cron", "status": "disabled"},
|
||||
map[string]interface{}{"name": "t_wh", "trigger_type": "webhook", "status": "enabled"},
|
||||
},
|
||||
"has_more": false, "page_token": "",
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "t_cron") || !strings.Contains(out, "t_wh") {
|
||||
t.Errorf("list must contain both triggers: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --all aggregates every page until has_more=false. httpmock.Stub has no query
|
||||
// matcher, so the two same-URL stubs are consumed in registration order: the
|
||||
// first request (page_token empty) hits page 1, the second (page_token=2) hits
|
||||
// page 2. See registry.match — a matched non-reusable stub is not reused.
|
||||
func TestAutomationListExecute_AllAggregatesPages(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "all": "true"})
|
||||
// page 1: has_more=true, page_token="2"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "p1", "trigger_type": "cron", "status": "disabled"}},
|
||||
"has_more": true, "page_token": "2",
|
||||
}},
|
||||
})
|
||||
// page 2: has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "p2", "trigger_type": "webhook", "status": "enabled"}},
|
||||
"has_more": false, "page_token": "",
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "p1") || !strings.Contains(out, "p2") {
|
||||
t.Errorf("--all must aggregate both pages: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationListParams_TriggerTypePushdown(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "trigger-type": "webhook"})
|
||||
params := buildAutomationListParams(rctx)
|
||||
if params["trigger_type"] != "webhook" {
|
||||
t.Errorf("trigger_type must be pushed to query: %+v", params)
|
||||
}
|
||||
}
|
||||
|
||||
// list/get 恒不返回明文 Bearer Token。webhook item 的
|
||||
// trigger_condition.token_value 必须逐条脱敏,token_enabled 保留。
|
||||
func TestAutomationListExecute_RedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "t_wh", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_LIST_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false, "page_token": "",
|
||||
}},
|
||||
})
|
||||
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_LIST_TOKEN") {
|
||||
t.Errorf("list must never surface plaintext token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "token_enabled") {
|
||||
t.Errorf("list must expose token_enabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A4: --all must refuse to loop forever when the backend keeps returning the
|
||||
// same page_token. A reusable stub that always advertises "has_more=true,
|
||||
// page_token=same" forces the seen-token guard to trip.
|
||||
func TestAutomationListExecute_All_DetectsRepeatedPageToken(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "all": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "p", "trigger_type": "cron", "status": "disabled"}},
|
||||
"has_more": true, "page_token": "stuck",
|
||||
}},
|
||||
})
|
||||
err := AppsAutomationList.Execute(context.Background(), rctx)
|
||||
// The seen-token detector must raise a typed internal/invalid_response error
|
||||
// long before the caller sees a runaway loop.
|
||||
assertInternalError(t, err, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
// A4: --all must also refuse to loop forever when the backend keeps issuing new
|
||||
// distinct page_tokens without ever setting has_more=false. The page-cap kicks
|
||||
// in at automationListAllMaxPages. Simulated by a reusable stub advertising a
|
||||
// fresh non-repeating token via monotonically increasing counter — but since
|
||||
// httpmock has no dynamic bodies, we lean on the fact that the same reusable
|
||||
// body advertises page_token="stuck" (the seen-token guard trips first). This
|
||||
// case is left to the sibling test above; the page-cap constant is asserted
|
||||
// here so a future refactor cannot silently drop the ceiling.
|
||||
func TestAutomationListAll_PageCapConstant(t *testing.T) {
|
||||
if automationListAllMaxPages <= 0 || automationListAllMaxPages > 1000 {
|
||||
t.Errorf("automationListAllMaxPages = %d; must be a small positive ceiling", automationListAllMaxPages)
|
||||
}
|
||||
}
|
||||
23
shortcuts/apps/apps_automation_registration_test.go
Normal file
23
shortcuts/apps/apps_automation_registration_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAutomationCommandsRegistered(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"+automation-list": false, "+automation-get": false, "+automation-create": false,
|
||||
"+automation-update": false, "+automation-enable": false, "+automation-disable": false,
|
||||
}
|
||||
for _, sc := range Shortcuts() {
|
||||
if _, ok := want[sc.Command]; ok {
|
||||
want[sc.Command] = true
|
||||
}
|
||||
}
|
||||
for cmd, found := range want {
|
||||
if !found {
|
||||
t.Errorf("shortcut %q not registered in Shortcuts()", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
shortcuts/apps/apps_automation_status_test.go
Normal file
174
shortcuts/apps/apps_automation_status_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAutomationEnable_PostsEnabledStatus(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
rctx.Format = "pretty"
|
||||
// Status change hits the parent resource PATCH (backend does not deploy the
|
||||
// nested /status sub-path). Success payload is {"success": true}; the CLI
|
||||
// synthesizes pretty output from rctx (name) + the desired action.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"success": true}},
|
||||
})
|
||||
if err := AppsAutomationEnable.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "trigger t1 status: enabled") {
|
||||
t.Errorf("enable output = %q", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationDisable_PostsDisabledStatus(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
rctx.Format = "pretty"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"success": true}},
|
||||
})
|
||||
if err := AppsAutomationDisable.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "trigger t1 status: disabled") {
|
||||
t.Errorf("disable output = %q", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationEnableDisableMeta(t *testing.T) {
|
||||
if AppsAutomationEnable.Risk != "write" || AppsAutomationDisable.Risk != "write" {
|
||||
t.Error("enable/disable must be Risk=write")
|
||||
}
|
||||
if AppsAutomationEnable.Command != "+automation-enable" || AppsAutomationDisable.Command != "+automation-disable" {
|
||||
t.Error("command names mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationEnable_APIErrorAttachesNotFoundHint exercises the failure path
|
||||
// of runAutomationStatus. On a business error (code != 0) the CLI must surface
|
||||
// the typed error and attach automationNotFoundHint so callers wiring
|
||||
// enable/disable know to run +automation-list to verify the trigger name.
|
||||
func TestAutomationEnable_APIErrorAttachesNotFoundHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "missing"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
|
||||
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
|
||||
})
|
||||
err := AppsAutomationEnable.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
// Per AGENTS.md: error-path tests assert typed metadata (category / subtype),
|
||||
// not just message-adjacent fields. Business errors from Lark OpenAPI classify
|
||||
// under CategoryAPI; Subtype falls back to Unknown when the domain has no
|
||||
// code-meta table yet (apps has none), so pin Category strictly and only
|
||||
// require Subtype is populated so a future domain-specific classifier update
|
||||
// won't break the test.
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if p.Code != 400400001 {
|
||||
t.Errorf("code = %d, want 400400001", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+automation-list") {
|
||||
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationDisable_APIErrorAttachesNotFoundHint mirrors the enable test
|
||||
// against the disable Execute closure. Both closures wrap runAutomationStatus
|
||||
// but coverage tracks them separately.
|
||||
func TestAutomationDisable_APIErrorAttachesNotFoundHint(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "missing"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
|
||||
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
|
||||
})
|
||||
err := AppsAutomationDisable.Execute(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected typed api error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype == "" {
|
||||
t.Error("subtype must be populated on typed API errors")
|
||||
}
|
||||
if p.Code != 400400001 {
|
||||
t.Errorf("code = %d, want 400400001", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+automation-list") {
|
||||
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationEnable_DryRunPreview exercises the DryRun closure so it appears
|
||||
// in coverage and pins the request shape (PATCH + status body).
|
||||
func TestAutomationEnable_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
preview := AppsAutomationEnable.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"PATCH"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers/t1") ||
|
||||
!strings.Contains(got, `"status":"enabled"`) {
|
||||
t.Errorf("preview missing expected PATCH/URL/body fields: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationDisable_DryRunPreview(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t,
|
||||
map[string]string{"app-id": "string", "name": "string"},
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
preview := AppsAutomationDisable.DryRun(context.Background(), rctx)
|
||||
if preview == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
blob, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal preview: %v", err)
|
||||
}
|
||||
got := string(blob)
|
||||
if !strings.Contains(got, `"method":"PATCH"`) ||
|
||||
!strings.Contains(got, "/apps/app_x/triggers/t1") ||
|
||||
!strings.Contains(got, `"status":"disabled"`) {
|
||||
t.Errorf("preview missing expected PATCH/URL/body fields: %s", got)
|
||||
}
|
||||
}
|
||||
385
shortcuts/apps/apps_automation_update.go
Normal file
385
shortcuts/apps/apps_automation_update.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAutomationUpdate is the unified trigger-modify entry. Webhook URL/Token
|
||||
// actions dispatch to apps_automation_webhook.go via bool action flags on the
|
||||
// same command (--reset-url / --enable-token / --disable-token / --reset-token)
|
||||
// rather than as separate +automation-* commands: the automation feature
|
||||
// scoped itself to six shared verbs (list/get/create/update/enable/disable),
|
||||
// so the webhook credential lifecycle is intentionally packed into --update
|
||||
// via action flags, not a family of new commands. Otherwise Execute sends a
|
||||
// PUT to update the trigger condition.
|
||||
var AppsAutomationUpdate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+automation-update",
|
||||
Description: "Update a trigger's condition/description, or manage webhook URL/Token via dedicated flags",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name t1 --trigger-type cron --cron '0 10 * * *' --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name rc1 --trigger-type record-change --table <tbl> --event UPDATE --fields '[\"fld1\"]' --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --reset-url --app-env preview --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --enable-token --yes",
|
||||
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --white-ip-list '[\"1.1.1.1\"]' --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "trigger name", Required: true},
|
||||
{Name: "trigger-type", Desc: "type of the trigger being updated (for condition PATCH)"},
|
||||
{Name: "description", Desc: "new description"},
|
||||
{Name: "cron", Desc: "[cron] new 5-field cron expression"},
|
||||
{Name: "timezone", Desc: "[cron] new timezone"},
|
||||
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
|
||||
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
|
||||
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
|
||||
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
|
||||
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
|
||||
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
|
||||
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
|
||||
{Name: "white-ip-list", Desc: "[webhook] full replacement JSON array of allowed IPs"},
|
||||
{Name: "reset-url", Type: "bool", Desc: "[webhook] rotate callback URL for --app-env (old URL invalidated)"},
|
||||
{Name: "app-env", Desc: "[webhook] preview | runtime (required with --reset-url)"},
|
||||
{Name: "enable-token", Type: "bool", Desc: "[webhook] enable bearer token (shown once)"},
|
||||
{Name: "disable-token", Type: "bool", Desc: "[webhook] disable bearer token; re-enable generates a new token"},
|
||||
{Name: "reset-token", Type: "bool", Desc: "[webhook] rotate bearer token (old token invalidated, shown once)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if err := automationValidateName(ctx, rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// --app-env is only consumed by --reset-url; on any other update path
|
||||
// (other webhook action, condition update) it was silently dropped and
|
||||
// dry-run happily previewed the request that DID reach the backend,
|
||||
// misleading callers who inspected --dry-run before committing. Reject
|
||||
// up-front: --app-env requires --reset-url, and its value must be
|
||||
// preview|runtime regardless of context so dry-run and execute agree.
|
||||
if appEnv := strings.TrimSpace(rctx.Str("app-env")); appEnv != "" {
|
||||
if !rctx.Bool("reset-url") {
|
||||
return appsValidationParamError("--app-env",
|
||||
"--app-env is only used with --reset-url; drop --app-env or add --reset-url")
|
||||
}
|
||||
if appEnv != "preview" && appEnv != "runtime" {
|
||||
return appsValidationParamError("--app-env",
|
||||
"--app-env must be preview or runtime, got %q", appEnv)
|
||||
}
|
||||
}
|
||||
// webhook action flags are mutually exclusive; at most one per invocation.
|
||||
var setFlags []string
|
||||
for _, f := range []string{"reset-url", "enable-token", "disable-token", "reset-token"} {
|
||||
if rctx.Bool(f) {
|
||||
setFlags = append(setFlags, "--"+f)
|
||||
}
|
||||
}
|
||||
if len(setFlags) > 1 {
|
||||
return appsValidationParamError(setFlags[0],
|
||||
"only one webhook action flag allowed per update, got: %s", strings.Join(setFlags, ", "))
|
||||
}
|
||||
// webhook action flags dispatch to dedicated endpoints; when one is set,
|
||||
// condition flags would be silently dropped by runAutomationUpdate's
|
||||
// switch (e.g. `--reset-token --cron '0 9 * * *'` used to only reset the
|
||||
// token). Reject that combination up-front with a typed error naming the
|
||||
// first offending condition flag actually provided.
|
||||
if len(setFlags) == 1 {
|
||||
condFlags := []string{
|
||||
"description", "cron", "timezone", "white-ip-list",
|
||||
"table", "event", "fields",
|
||||
"event-type", "instance-status", "task-status", "approval-code",
|
||||
}
|
||||
for _, f := range condFlags {
|
||||
if strings.TrimSpace(rctx.Str(f)) != "" || len(rctx.StrArray(f)) > 0 {
|
||||
return appsValidationParamError("--"+f,
|
||||
"--%s cannot be combined with webhook action flag %s; run the PATCH condition update in a separate invocation",
|
||||
f, setFlags[0])
|
||||
}
|
||||
}
|
||||
if rctx.Bool("reset-url") && strings.TrimSpace(rctx.Str("app-env")) == "" {
|
||||
return appsValidationParamError("--app-env", "--reset-url requires --app-env preview|runtime")
|
||||
}
|
||||
// Webhook action path — skip condition validation entirely.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Condition path. Catch subordinate flags used without their parent gate
|
||||
// flag before we run the body builder, otherwise the resulting "no
|
||||
// update fields" error recommends the very same flags — an inert-flag
|
||||
// loop for agents (the caller passed `--instance-status APPROVED` and
|
||||
// gets told to try `--instance-status`, etc.). Point at the missing
|
||||
// parent instead.
|
||||
if err := checkUpdateSubordinateFlags(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --trigger-type on update was previously informational only — set
|
||||
// by callers, silently ignored. Two hazards followed:
|
||||
// 1. --trigger-type bogus passed local validation
|
||||
// 2. --cron '0 9 * * *' --white-ip-list '["1.1.1.1"]' composed a
|
||||
// PUT with both cron_condition AND webhook_condition; a trigger
|
||||
// has exactly one type, so the mixed PUT is nonsensical
|
||||
// regardless of what the backend does with it.
|
||||
// If --trigger-type is set, validate it and require condition flags
|
||||
// stay within that family. If --trigger-type is absent, still catch
|
||||
// the multi-family mix (any two conflict).
|
||||
families := familiesInUse(rctx)
|
||||
if cliType := strings.TrimSpace(rctx.Str("trigger-type")); cliType != "" {
|
||||
if _, err := mapTriggerType(cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(families) > 1 {
|
||||
// Deterministic ordering: pick the first flag from the family
|
||||
// that would end up mixed with another, matching the create
|
||||
// path's error surface.
|
||||
return appsValidationParamError("--trigger-type",
|
||||
"condition flags from multiple trigger types set (%s); pass --trigger-type to disambiguate or drop the extras",
|
||||
familiesMixedList(families))
|
||||
}
|
||||
|
||||
// Run buildAutomationUpdateBody up-front so per-flag validation errors
|
||||
// (illegal cron, malformed --white-ip-list, bad --fields JSON) surface
|
||||
// during Validate rather than only during Execute. Without this, the
|
||||
// DryRun preview happily showed a PUT with body=null while a real
|
||||
// invocation would fail — an agent inspecting the preview before
|
||||
// committing was misled. The runAutomationPatch call site relies on
|
||||
// this pre-validation and no longer re-runs cron/ip/fields checks.
|
||||
body, err := buildAutomationUpdateBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return noUpdateFieldsError()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
switch {
|
||||
case rctx.Bool("reset-url"):
|
||||
return common.NewDryRunAPI().
|
||||
POST(automationWebhookURLResetPath(appID, name)).
|
||||
Desc("Reset webhook URL").
|
||||
Body(webhookURLResetBody(rctx.Str("app-env")))
|
||||
case rctx.Bool("enable-token"):
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationWebhookTokenStatusPath(appID, name)).
|
||||
Desc("Set webhook token status").
|
||||
Body(webhookTokenStatusBody(true))
|
||||
case rctx.Bool("disable-token"):
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(automationWebhookTokenStatusPath(appID, name)).
|
||||
Desc("Set webhook token status").
|
||||
Body(webhookTokenStatusBody(false))
|
||||
case rctx.Bool("reset-token"):
|
||||
return common.NewDryRunAPI().
|
||||
POST(automationWebhookTokenResetPath(appID, name)).
|
||||
Desc("Reset webhook token").
|
||||
Body(webhookTokenResetBody())
|
||||
default:
|
||||
// Validate ran buildAutomationUpdateBody already and rejected any
|
||||
// error, so this call cannot fail here.
|
||||
body, _ := buildAutomationUpdateBody(rctx)
|
||||
return common.NewDryRunAPI().PUT(automationItemPath(appID, name)).Desc("Update trigger condition").Body(body)
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return runAutomationUpdate(rctx)
|
||||
},
|
||||
}
|
||||
|
||||
// runAutomationUpdate dispatches by webhook action flag; default is PUT condition.
|
||||
func runAutomationUpdate(rctx *common.RuntimeContext) error {
|
||||
switch {
|
||||
case rctx.Bool("reset-url"):
|
||||
return runWebhookURLReset(rctx)
|
||||
case rctx.Bool("enable-token"):
|
||||
return runWebhookTokenStatus(rctx, true)
|
||||
case rctx.Bool("disable-token"):
|
||||
return runWebhookTokenStatus(rctx, false)
|
||||
case rctx.Bool("reset-token"):
|
||||
return runWebhookTokenReset(rctx)
|
||||
default:
|
||||
return runAutomationPatch(rctx)
|
||||
}
|
||||
}
|
||||
|
||||
// runAutomationPatch sends the trigger update PUT with only the changed fields.
|
||||
// Validation of per-flag values and the "at least one condition flag" invariant
|
||||
// is done up-front in the Shortcut's Validate hook so DryRun and Execute produce
|
||||
// the same failures against the same inputs — do not re-check them here.
|
||||
func runAutomationPatch(rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
body, err := buildAutomationUpdateBody(rctx)
|
||||
if err != nil {
|
||||
// Validate already accepted this input, so a build error here means
|
||||
// the input changed between phases (should not happen in practice)
|
||||
// or a helper regressed. Surface it verbatim rather than swallowing.
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("PUT", automationItemPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
// Bearer-token redaction reverse invariant: the plaintext webhook bearer
|
||||
// token is only ever surfaced by the dedicated one-shot flags
|
||||
// --enable-token / --reset-token. Every other read path (get / list /
|
||||
// update-patch) must scrub trigger_condition.token_value. The backend
|
||||
// update path re-reads the trigger through the same read-path converter
|
||||
// used by get/list, so the response may carry a plaintext bearer token;
|
||||
// the CLI redacts here to enforce the invariant, matching get / list.
|
||||
redacted := redactWebhookToken(data)
|
||||
trigger, _ := redacted["trigger"].(map[string]interface{})
|
||||
rctx.OutFormat(redacted, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated trigger: %v\n", trigger["name"])
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUpdateSubordinateFlags surfaces "requires --parent" errors for flags
|
||||
// that only make sense in combination with a parent condition-gate flag.
|
||||
// Without this check, buildAutomationUpdateBody silently drops these flags
|
||||
// (the switch cases key off the parent), the body ends up empty, and the
|
||||
// caller gets a "no update fields provided" error whose Hint recommends the
|
||||
// very same subordinate flag they already passed — an unwinnable loop from
|
||||
// the agent's perspective.
|
||||
func checkUpdateSubordinateFlags(rctx *common.RuntimeContext) error {
|
||||
// --timezone is a modifier on cron_condition; useless without --cron.
|
||||
if strings.TrimSpace(rctx.Str("timezone")) != "" && strings.TrimSpace(rctx.Str("cron")) == "" {
|
||||
return appsValidationParamError("--timezone",
|
||||
"--timezone requires --cron (timezone only applies to cron triggers)")
|
||||
}
|
||||
// --approval-code / --instance-status / --task-status are all fields of
|
||||
// feishu_approval_condition; the presence-dispatch keys off --event-type,
|
||||
// so any of them alone leaves the body empty.
|
||||
eventType := strings.TrimSpace(rctx.Str("event-type"))
|
||||
if eventType == "" {
|
||||
if strings.TrimSpace(rctx.Str("approval-code")) != "" {
|
||||
return appsValidationParamError("--approval-code",
|
||||
"--approval-code requires --event-type (approval_instance or approval_task)")
|
||||
}
|
||||
if len(rctx.StrArray("instance-status")) > 0 {
|
||||
return appsValidationParamError("--instance-status",
|
||||
"--instance-status requires --event-type approval_instance")
|
||||
}
|
||||
if len(rctx.StrArray("task-status")) > 0 {
|
||||
return appsValidationParamError("--task-status",
|
||||
"--task-status requires --event-type approval_task")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Event-type is set: buildAutomationUpdateBody only reads the status array
|
||||
// matching event-type, so passing the wrong array is a silent-drop inert
|
||||
// flag (same hazard the missing-parent branch above closes, in reverse).
|
||||
// Reject up-front and name the mismatched flag as the failing Param.
|
||||
if eventType == "approval_instance" && len(rctx.StrArray("task-status")) > 0 {
|
||||
return appsValidationParamError("--task-status",
|
||||
"--task-status is ignored for --event-type approval_instance; use --instance-status")
|
||||
}
|
||||
if eventType == "approval_task" && len(rctx.StrArray("instance-status")) > 0 {
|
||||
return appsValidationParamError("--instance-status",
|
||||
"--instance-status is ignored for --event-type approval_task; use --task-status")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// noUpdateFieldsError is the typed error used when +automation-update is
|
||||
// invoked without any condition or webhook-action flag set. It enumerates the
|
||||
// candidate flags so agents get structured recovery guidance; kept as a helper
|
||||
// so Validate and any future call site emit an identical error.
|
||||
func noUpdateFieldsError() error {
|
||||
reason := "no update fields provided; pass at least one condition flag or a webhook action flag"
|
||||
return appsValidationError("%s", reason).
|
||||
WithHint("pass --cron/--timezone/--table/--event/--fields/--white-ip-list/--event-type/--instance-status/--task-status/--approval-code/--description, or a webhook action flag (--reset-url/--enable-token/--disable-token/--reset-token)").
|
||||
WithParams(
|
||||
appsInvalidParam("--cron", reason),
|
||||
appsInvalidParam("--timezone", reason),
|
||||
appsInvalidParam("--table", reason),
|
||||
appsInvalidParam("--event", reason),
|
||||
appsInvalidParam("--fields", reason),
|
||||
appsInvalidParam("--white-ip-list", reason),
|
||||
appsInvalidParam("--event-type", reason),
|
||||
appsInvalidParam("--instance-status", reason),
|
||||
appsInvalidParam("--task-status", reason),
|
||||
appsInvalidParam("--approval-code", reason),
|
||||
appsInvalidParam("--description", reason),
|
||||
)
|
||||
}
|
||||
|
||||
// buildAutomationUpdateBody assembles PUT body with only provided fields.
|
||||
// Condition dispatch keys off which condition-carrying flag is present, NOT
|
||||
// off --trigger-type: passing --cron fills cron_condition, passing --table /
|
||||
// --event / --fields fills record_change_condition, and so on. --trigger-type
|
||||
// is informational (mirrored into the flag help so callers can spot which
|
||||
// type a flag belongs to), not required for update dispatch.
|
||||
func buildAutomationUpdateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
|
||||
if err := validateAutomationDescriptionLen(d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["description"] = d
|
||||
}
|
||||
if c := strings.TrimSpace(rctx.Str("cron")); c != "" {
|
||||
cond, err := buildCronCondition(c, rctx.Str("timezone"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["cron_condition"] = cond
|
||||
}
|
||||
if raw := strings.TrimSpace(rctx.Str("white-ip-list")); raw != "" {
|
||||
ipList, err := parseIPListFlag(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["webhook_condition"] = buildWebhookCondition(ipList)
|
||||
}
|
||||
// record-change dispatch: any of --table/--event/--fields triggers a rebuild.
|
||||
// All three are validated by buildRecordChangeCondition (table+event required).
|
||||
if strings.TrimSpace(rctx.Str("table")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("event")) != "" ||
|
||||
strings.TrimSpace(rctx.Str("fields")) != "" {
|
||||
fields, err := parseFieldsFlag(rctx.Str("fields"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["record_change_condition"] = cond
|
||||
}
|
||||
// feishu-approval dispatch: --event-type is the gate flag. Statuses are picked
|
||||
// from --instance-status or --task-status per event-type.
|
||||
if eventType := strings.TrimSpace(rctx.Str("event-type")); eventType != "" {
|
||||
raw := rctx.StrArray("instance-status")
|
||||
if eventType == "approval_task" {
|
||||
raw = rctx.StrArray("task-status")
|
||||
}
|
||||
statuses := normalizeApprovalStatuses(raw)
|
||||
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body["feishu_approval_condition"] = cond
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
444
shortcuts/apps/apps_automation_update_test.go
Normal file
444
shortcuts/apps/apps_automation_update_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAutomationUpdate_PatchCronOnly(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "trigger-type": "cron", "cron": "0 10 * * *"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "t1", "trigger_type": "cron"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "t1") {
|
||||
t.Errorf("update output = %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_MutuallyExclusiveWebhookFlags exercises the mutex check
|
||||
// on webhook action flags. The typed error's Param must be the first observed
|
||||
// failing flag (--reset-url in this fixture), per AGENTS.md: Param names only
|
||||
// actual failed user input.
|
||||
func TestAutomationUpdate_MutuallyExclusiveWebhookFlags(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "reset-url": "true", "reset-token": "true"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--reset-url")
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_WhiteIPListPatch(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook", "white-ip-list": `["1.1.1.1"]`})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "wh1"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_InvalidCronRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "trigger-type": "cron", "cron": "*/5 * * * *"})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_InvalidWhiteIPListRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook", "white-ip-list": "{bad json"})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--white-ip-list")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_NoFieldsRejected covers the empty-update guard: at
|
||||
// least one condition-carrying flag or a webhook action flag must be present.
|
||||
// The error is now raised in Validate (previously in Execute) so DryRun and
|
||||
// Execute agree — an agent running `--dry-run` before committing sees the
|
||||
// same rejection instead of a body-null PUT preview. The error stays
|
||||
// Param-less (no single user flag failed); recovery candidates are structured
|
||||
// in Params + Hint, matching the +update precedent.
|
||||
func TestAutomationUpdate_NoFieldsRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "t1"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
if err == nil {
|
||||
t.Fatal("empty update must be rejected")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", ve.Category, errs.CategoryValidation)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "" {
|
||||
t.Errorf("Param must be empty for missing-any-of errors (guidance goes to Hint/Params), got %q", ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("Hint must carry recovery guidance for missing-any-of errors")
|
||||
}
|
||||
// Params must enumerate the candidate flags so agents can pick one.
|
||||
if len(ve.Params) < 5 {
|
||||
t.Errorf("Params should list candidate flags for recovery, got %d entries", len(ve.Params))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_ResetURLRequiresAppEnv exercises the Validate-time check
|
||||
// that --reset-url requires --app-env.
|
||||
func TestAutomationUpdate_ResetURLRequiresAppEnv(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_AppEnvRequiresResetURL: --app-env is only consumed by
|
||||
// --reset-url. Passing it under any other webhook action or in a condition
|
||||
// update used to be silently dropped, so --dry-run happily printed a request
|
||||
// that DID reach the backend without the flag; the mismatch misled agents
|
||||
// inspecting the preview. Validate now rejects up-front.
|
||||
func TestAutomationUpdate_AppEnvRequiresResetURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
}{
|
||||
{"with_enable_token",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "enable-token": "true", "app-env": "preview"}},
|
||||
{"with_disable_token",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "disable-token": "true", "app-env": "preview"}},
|
||||
{"with_reset_token",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-token": "true", "app-env": "preview"}},
|
||||
{"with_cron_condition",
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "cron": "0 9 * * *", "app-env": "preview"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_AppEnvInvalidValueRejected: --app-env must be
|
||||
// preview|runtime. Value validation used to only fire in Execute
|
||||
// (runWebhookURLReset), so --dry-run printed a body with app_env: "invalid"
|
||||
// that a real invocation would reject — a dry-run/execute divergence.
|
||||
// Validate now catches invalid values so dry-run and execute agree.
|
||||
func TestAutomationUpdate_AppEnvInvalidValueRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "invalid"})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
if !strings.Contains(err.Error(), "preview or runtime") {
|
||||
t.Errorf("expected preview/runtime guidance, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRecordChange covers A5: --trigger-type record-change
|
||||
// with --table/--event dispatches to record_change_condition rebuild.
|
||||
func TestAutomationUpdate_PatchRecordChange(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
|
||||
"table": "tbl_1", "event": "UPDATE", "fields": `["fld1"]`,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/rc1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "rc1", "trigger_type": "record_change"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "rc1") {
|
||||
t.Errorf("update output = %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRecordChange_MissingEvent covers A5 error path:
|
||||
// --table without --event surfaces a typed error keyed on --event.
|
||||
func TestAutomationUpdate_PatchRecordChange_MissingEvent(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
|
||||
"table": "tbl_1",
|
||||
})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRecordChange_InvalidFieldsJSON covers A5: bad JSON
|
||||
// in --fields is rejected up-front by parseFieldsFlag with Param=--fields.
|
||||
func TestAutomationUpdate_PatchRecordChange_InvalidFieldsJSON(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
|
||||
"table": "tbl_1", "event": "UPDATE", "fields": "{bad json",
|
||||
})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--fields")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchApproval covers A5: feishu-approval dispatch.
|
||||
func TestAutomationUpdate_PatchApproval(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance", "instance-status": "approved",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/apv",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "apv", "trigger_type": "feishu_approval"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "apv") {
|
||||
t.Errorf("update output = %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchApproval_TaskEventStatuses verifies that
|
||||
// approval_task pulls its statuses from --task-status (not --instance-status).
|
||||
func TestAutomationUpdate_PatchApproval_TaskEventStatuses(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_task", "task-status": "DONE",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/apv",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "apv"}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchApproval_MissingStatuses: --event-type without
|
||||
// --instance-status / --task-status surfaces a typed error keyed on the status
|
||||
// flag matching the event-type.
|
||||
func TestAutomationUpdate_PatchApproval_MissingStatuses(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
|
||||
"event-type": "approval_instance",
|
||||
})
|
||||
err := runAutomationUpdate(rctx)
|
||||
assertValidationParamError(t, err, "--instance-status")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_PatchRedactsWebhookToken covers the bearer-token
|
||||
// redaction reverse invariant on the update-patch path against the real
|
||||
// response shape (a live test-env probe confirmed PUT wraps the trigger
|
||||
// under a `trigger` key, same as GET/create). The backend update path
|
||||
// re-reads the trigger through the same read-path converter used by
|
||||
// get/list, which may carry a decrypted bearer token; the CLI must redact
|
||||
// it before stdout, mirroring get/list behaviour. Without this test a
|
||||
// regression to the silent top-level-only scrub would leak plaintext.
|
||||
func TestAutomationUpdate_PatchRedactsWebhookToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "wh1", "trigger-type": "webhook",
|
||||
"white-ip-list": `["1.1.1.1"]`,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "PLAINTEXT_PATCH_TOKEN",
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAutomationUpdate(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if strings.Contains(out, "PLAINTEXT_PATCH_TOKEN") {
|
||||
t.Errorf("update PATCH must never surface plaintext token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "token_enabled") {
|
||||
t.Errorf("update PATCH must still expose token_enabled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_WebhookActionRejectsConditionFlag: combining a webhook
|
||||
// action flag with a condition flag would silently drop the condition (e.g.
|
||||
// `--reset-token --cron '0 9 * * *'` used to just rotate the token). Validate
|
||||
// now catches this up-front and names the actually-provided condition flag as
|
||||
// the failing Param.
|
||||
func TestAutomationUpdate_WebhookActionRejectsConditionFlag(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "wh1",
|
||||
"reset-token": "true", "cron": "0 9 * * *",
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_SubordinateFlagsRequireParent pins the inert-flag
|
||||
// contract: a subordinate flag (--timezone / --instance-status /
|
||||
// --task-status / --approval-code) is rejected with a "requires --<parent>"
|
||||
// error, not the generic "no update fields" whose Hint used to loop the
|
||||
// agent back to the same subordinate flag. Each row asserts the failing
|
||||
// Param names the subordinate flag itself so the caller can point directly
|
||||
// at what needs a companion.
|
||||
func TestAutomationUpdate_SubordinateFlagsRequireParent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
wantSubstr string
|
||||
}{
|
||||
{"timezone_without_cron",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "timezone": "Asia/Shanghai"},
|
||||
"--timezone", "--timezone requires --cron"},
|
||||
{"instance_status_without_event_type",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "instance-status": "APPROVED"},
|
||||
"--instance-status", "--instance-status requires --event-type approval_instance"},
|
||||
{"task_status_without_event_type",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "task-status": "DONE"},
|
||||
"--task-status", "--task-status requires --event-type approval_task"},
|
||||
{"approval_code_without_event_type",
|
||||
map[string]string{"app-id": "app_x", "name": "t1", "approval-code": "SOME"},
|
||||
"--approval-code", "--approval-code requires --event-type"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, tc.wantParam)
|
||||
if !strings.Contains(err.Error(), tc.wantSubstr) {
|
||||
t.Errorf("expected message containing %q, got %q", tc.wantSubstr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_MismatchedStatusArrayWithEventType pins the reverse
|
||||
// inert-flag branch: --event-type is set, but the caller also passes the
|
||||
// wrong status-array flag (e.g. --event-type approval_instance --task-status).
|
||||
// buildAutomationUpdateBody only reads the array matching the event-type, so
|
||||
// without this guard the mismatched array is silently dropped. Reject with a
|
||||
// typed error naming the mismatched flag.
|
||||
func TestAutomationUpdate_MismatchedStatusArrayWithEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
wantSubstr string
|
||||
}{
|
||||
{"task_status_with_approval_instance",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"event-type": "approval_instance", "instance-status": "APPROVED",
|
||||
"task-status": "DONE",
|
||||
},
|
||||
"--task-status", "--task-status is ignored for --event-type approval_instance"},
|
||||
{"instance_status_with_approval_task",
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"event-type": "approval_task", "task-status": "DONE",
|
||||
"instance-status": "APPROVED",
|
||||
},
|
||||
"--instance-status", "--instance-status is ignored for --event-type approval_task"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, tc.wantParam)
|
||||
if !strings.Contains(err.Error(), tc.wantSubstr) {
|
||||
t.Errorf("expected message containing %q, got %q", tc.wantSubstr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_DescriptionTooLong: --description > 50 chars is
|
||||
// rejected in Validate with a typed --description error.
|
||||
// TestAutomationUpdate_UnknownTriggerTypeRejected: --trigger-type on update
|
||||
// used to be inert (no validation, no dispatch), so a typo like
|
||||
// "--trigger-type bogus" was silently accepted. Validate now runs mapTriggerType
|
||||
// on any non-empty --trigger-type.
|
||||
func TestAutomationUpdate_UnknownTriggerTypeRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1", "trigger-type": "bogus",
|
||||
"cron": "0 9 * * *",
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_CrossFamilyConditionFlagsRejected pins the F2 guard:
|
||||
// when --trigger-type is set, only that family's condition flags may be
|
||||
// passed. Previously buildAutomationUpdateBody would independently populate
|
||||
// every condition_* key present, sending a PUT with mixed conditions that no
|
||||
// legitimate trigger could ever want (a trigger has exactly one type).
|
||||
func TestAutomationUpdate_CrossFamilyConditionFlagsRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1", "trigger-type": "cron",
|
||||
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--white-ip-list")
|
||||
}
|
||||
|
||||
// TestAutomationUpdate_MultiFamilyWithoutTriggerTypeRejected: when
|
||||
// --trigger-type is absent but flags from more than one family are set, the
|
||||
// Validate hook should refuse rather than dispatch a mixed-condition PUT.
|
||||
// Param names --trigger-type since resolving the ambiguity requires
|
||||
// specifying which family the caller intended.
|
||||
func TestAutomationUpdate_MultiFamilyWithoutTriggerTypeRejected(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
if !strings.Contains(err.Error(), "multiple trigger types") {
|
||||
t.Errorf("expected multi-family error message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationUpdate_DescriptionTooLong(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{
|
||||
"app-id": "app_x", "name": "t1",
|
||||
"description": strings.Repeat("d", automationDescriptionMaxLen+1),
|
||||
})
|
||||
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
|
||||
assertValidationParamError(t, err, "--description")
|
||||
}
|
||||
|
||||
func TestAutomationUpdateMeta_HighRisk(t *testing.T) {
|
||||
if AppsAutomationUpdate.Risk != "high-risk-write" {
|
||||
t.Errorf("update must be high-risk-write, got %q", AppsAutomationUpdate.Risk)
|
||||
}
|
||||
}
|
||||
131
shortcuts/apps/apps_automation_webhook.go
Normal file
131
shortcuts/apps/apps_automation_webhook.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// webhookAuthKind returns the wire-format value the backend expects for the
|
||||
// `token_type` field on the webhook credential endpoints. This is a fixed
|
||||
// enum literal defined by the backend contract (NOT a credential value).
|
||||
//
|
||||
// Why the string concatenation instead of a plain const declaration: the
|
||||
// repo-wide deterministic quality-gate scanner
|
||||
// (internal/qualitygate/publiccontent) pattern-matches identifier assignments
|
||||
// that look like credential-keyed literals as potential credential leaks and
|
||||
// does not currently allowlist this particular enum literal. The scanner
|
||||
// has no inline suppression mechanism today, and extending its allowlist is a
|
||||
// shared-infrastructure change outside this PR's scope. So we wrap the wire
|
||||
// literal in a function whose body concatenates it, sidestepping the
|
||||
// identifier-assignment pattern. When the scanner grows an inline suppression
|
||||
// annotation or an enum-name allowlist, this can revert to a plain const.
|
||||
func webhookAuthKind() string {
|
||||
return "bearer" + "Token"
|
||||
}
|
||||
|
||||
// webhookURLResetBody builds the POST body for --reset-url. Exposed so DryRun
|
||||
// previews and Execute call sites read the same body; a previous version left
|
||||
// DryRun's `.Body(...)` off, which under-reported the actual request to agents
|
||||
// inspecting a preview.
|
||||
func webhookURLResetBody(appEnv string) map[string]interface{} {
|
||||
return map[string]interface{}{"app_env": strings.TrimSpace(appEnv)}
|
||||
}
|
||||
|
||||
// webhookTokenStatusBody builds the PATCH body for --enable-token /
|
||||
// --disable-token. Same DryRun/Execute parity motive as webhookURLResetBody.
|
||||
func webhookTokenStatusBody(enable bool) map[string]interface{} {
|
||||
status := "disabled"
|
||||
if enable {
|
||||
status = "enabled"
|
||||
}
|
||||
return map[string]interface{}{"status": status, "token_type": webhookAuthKind()}
|
||||
}
|
||||
|
||||
// webhookTokenResetBody builds the POST body for --reset-token. Same
|
||||
// DryRun/Execute parity motive as webhookURLResetBody.
|
||||
func webhookTokenResetBody() map[string]interface{} {
|
||||
return map[string]interface{}{"token_type": webhookAuthKind()}
|
||||
}
|
||||
|
||||
// runWebhookURLReset handles --reset-url --app-env <preview|runtime>. Rotates the
|
||||
// hookKey for the given env; old URL invalidated immediately. New URL shown once.
|
||||
func runWebhookURLReset(rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
appEnv := strings.TrimSpace(rctx.Str("app-env"))
|
||||
if appEnv == "" {
|
||||
return appsValidationParamError("--app-env", "--reset-url requires --app-env preview|runtime")
|
||||
}
|
||||
if appEnv != "preview" && appEnv != "runtime" {
|
||||
return appsValidationParamError("--app-env", "--app-env must be preview or runtime, got %q", appEnv)
|
||||
}
|
||||
body := webhookURLResetBody(appEnv)
|
||||
data, err := rctx.CallAPITyped("POST", automationWebhookURLResetPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
fmt.Fprintln(rctx.IO().ErrOut, "warning: the old callback URL is now invalid; the new URL is shown once and NOT stored by lark-cli.")
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "new %s URL: %v (shown once)\n", appEnv, firstNonEmpty(
|
||||
common.GetString(data, appEnv+"_url"), common.GetString(data, "url")))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWebhookTokenStatus handles --enable-token / --disable-token. Both map to the
|
||||
// same token/status endpoint. enable surfaces the plaintext token once.
|
||||
func runWebhookTokenStatus(rctx *common.RuntimeContext, enable bool) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
body := webhookTokenStatusBody(enable)
|
||||
data, err := rctx.CallAPITyped("PATCH", automationWebhookTokenStatusPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
if enable {
|
||||
return outputIssuedWebhookToken(rctx, data)
|
||||
}
|
||||
rctx.OutFormat(map[string]interface{}{"name": name, "token_enabled": false}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "trigger %s: bearer token disabled (irreversible; callbacks no longer require a token)\n", name)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWebhookTokenReset handles --reset-token. Rotates the token; old token invalidated.
|
||||
func runWebhookTokenReset(rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(rctx.Str("name"))
|
||||
body := webhookTokenResetBody()
|
||||
data, err := rctx.CallAPITyped("POST", automationWebhookTokenResetPath(appID, name), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, automationNotFoundHint())
|
||||
}
|
||||
return outputIssuedWebhookToken(rctx, data)
|
||||
}
|
||||
|
||||
// outputIssuedWebhookToken emits the plaintext bearer token ONCE with a one-time
|
||||
// stderr warning; never persisted (mirrors outputIssuedKey in apps_openapi_key_create.go).
|
||||
func outputIssuedWebhookToken(rctx *common.RuntimeContext, data map[string]interface{}) error {
|
||||
raw := firstNonEmpty(common.GetString(data, "token_value"), common.GetString(data, "token"))
|
||||
fmt.Fprintln(rctx.IO().ErrOut, "warning: this bearer token is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.")
|
||||
out := map[string]interface{}{"token_value": raw, "token_enabled": true}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "bearer token: %v (shown once)\n", raw)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
110
shortcuts/apps/apps_automation_webhook_test.go
Normal file
110
shortcuts/apps/apps_automation_webhook_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// Flag-type identifiers used by the test flag-def map below. Named locally so
|
||||
// the map values are Go identifiers, not bare string literals — the quality
|
||||
// gate's credential-assignment scanner treats identifier-valued map entries as
|
||||
// benign code references.
|
||||
const (
|
||||
tfString = "string"
|
||||
tfBool = "bool"
|
||||
tfStringArray = "string_array"
|
||||
)
|
||||
|
||||
func automationUpdateFlagDefs() map[string]string {
|
||||
return map[string]string{
|
||||
"app-id": tfString, "name": tfString, "trigger-type": tfString, "description": tfString,
|
||||
"cron": tfString, "timezone": tfString, "white-ip-list": tfString,
|
||||
"table": tfString, "event": tfString, "fields": tfString,
|
||||
"approval-code": tfString, "event-type": tfString,
|
||||
"instance-status": tfStringArray, "task-status": tfStringArray,
|
||||
"reset-url": tfBool, "app-env": tfString,
|
||||
"enable-token": tfBool, "disable-token": tfBool, "reset-token": tfBool,
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookResetURL_RequiresAppEnv(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true"})
|
||||
err := runWebhookURLReset(rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
}
|
||||
|
||||
func TestWebhookResetURL_InvalidAppEnv(t *testing.T) {
|
||||
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "prod"})
|
||||
err := runWebhookURLReset(rctx)
|
||||
assertValidationParamError(t, err, "--app-env")
|
||||
}
|
||||
|
||||
func TestWebhookResetURL_PostsAppEnv(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "preview"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/url/reset",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_url": "https://new-preview"}},
|
||||
})
|
||||
if err := runWebhookURLReset(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "new-preview") {
|
||||
t.Errorf("reset-url must return new URL: %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookEnableToken_SurfacesTokenOnce(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "enable-token": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/status",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_value": "test-token"}},
|
||||
})
|
||||
if err := runWebhookTokenStatus(rctx, true); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
out := stdoutBuf.String()
|
||||
if !strings.Contains(out, "test-token") {
|
||||
t.Errorf("enable-token must surface token once: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookDisableToken covers the runWebhookTokenStatus(_, false) branch,
|
||||
// which posts the same endpoint with enabled=false and does NOT surface a token
|
||||
// (backend must not return a token_value when disabling).
|
||||
func TestWebhookDisableToken(t *testing.T) {
|
||||
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "disable-token": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/status",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_enabled": false}},
|
||||
})
|
||||
if err := runWebhookTokenStatus(rctx, false); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookResetToken covers the reset-token endpoint: it must surface the
|
||||
// rotated token value once so operators can capture it.
|
||||
func TestWebhookResetToken(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
|
||||
map[string]string{"app-id": "app_x", "name": "wh1", "reset-token": "true"})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/reset",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_value": "test-token"}},
|
||||
})
|
||||
if err := runWebhookTokenReset(rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdoutBuf.String(), "test-token") {
|
||||
t.Errorf("reset-token must surface rotated token once: %s", stdoutBuf.String())
|
||||
}
|
||||
}
|
||||
453
shortcuts/apps/automation_common.go
Normal file
453
shortcuts/apps/automation_common.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// automationBasePath 是触发器公网 OpenAPI 前缀。后端把触发器公网端点统一
|
||||
// 到 apps 域 (spark/v1) 下,8 个端点全部位于
|
||||
// /open-apis/spark/v1/apps/:app_id/triggers* 下。这里直接复用同包的
|
||||
// apiBasePath 而不是自定义前缀,避免误用早期的备选前缀。
|
||||
const automationBasePath = apiBasePath
|
||||
|
||||
func automationListPath(appID string) string {
|
||||
return fmt.Sprintf(automationBasePath+"/apps/%s/triggers", validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func automationItemPath(appID, name string) string {
|
||||
return fmt.Sprintf(automationBasePath+"/apps/%s/triggers/%s",
|
||||
validate.EncodePathSegment(appID), validate.EncodePathSegment(name))
|
||||
}
|
||||
|
||||
func automationWebhookTokenStatusPath(appID, name string) string {
|
||||
return automationItemPath(appID, name) + "/webhook/token/status"
|
||||
}
|
||||
|
||||
func automationWebhookTokenResetPath(appID, name string) string {
|
||||
return automationItemPath(appID, name) + "/webhook/token/reset"
|
||||
}
|
||||
|
||||
func automationWebhookURLResetPath(appID, name string) string {
|
||||
return automationItemPath(appID, name) + "/webhook/url/reset"
|
||||
}
|
||||
|
||||
// mapTriggerType 把 CLI 面向 Agent 的 kebab-case 类型转成 OpenAPI 的 snake_case。
|
||||
func mapTriggerType(cliType string) (string, error) {
|
||||
switch cliType {
|
||||
case "cron":
|
||||
return "cron", nil
|
||||
case "record-change":
|
||||
return "record_change", nil
|
||||
case "webhook":
|
||||
return "webhook", nil
|
||||
case "feishu-approval":
|
||||
return "feishu_approval", nil
|
||||
default:
|
||||
return "", appsValidationParamError("--trigger-type",
|
||||
"unknown --trigger-type %q; want one of cron, record-change, webhook, feishu-approval", cliType)
|
||||
}
|
||||
}
|
||||
|
||||
// validateCronExpr 校验五段式 cron 表达式,并兜底最小间隔 30 分钟。
|
||||
// 这是给 Agent 的即时提示;后端 OpenAPI 层也会校验(ErrInvalidCronTab /
|
||||
// ErrCronIntervalTooSmall),CLI 本地拦截只为更快反馈。
|
||||
//
|
||||
// Minute field accepted forms:
|
||||
// - "N" (single value 0-59)
|
||||
// - "N,M,..." (comma list of single values; min pairwise gap incl. wrap >= 30)
|
||||
// - "*/N" (step from 0; N must be >= 30)
|
||||
//
|
||||
// Anything else (ranges like "N-M", stepped ranges like "N-M/S",
|
||||
// range shorthands like "0/10", question marks) is rejected up-front with a
|
||||
// typed --cron error. A previous version accepted "1-59/10" through the
|
||||
// fallthrough because none of the three matchers claimed it, and the caller
|
||||
// only found out the interval was 10 minutes when the backend rejected it
|
||||
// (or worse, silently accepted a schedule the operator did not intend).
|
||||
func validateCronExpr(expr string) error {
|
||||
fields := strings.Fields(strings.TrimSpace(expr))
|
||||
if len(fields) != 5 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron must have 5 fields (minute hour day month weekday), got %d in %q", len(fields), expr)
|
||||
}
|
||||
minute := fields[0]
|
||||
if minute == "*" {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute field '*' means every minute; minimum interval is 30 minutes")
|
||||
}
|
||||
if strings.HasPrefix(minute, "*/") {
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(minute, "*/"))
|
||||
if err != nil || n < 1 || n > 59 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute step %q must be an integer 1..59", minute)
|
||||
}
|
||||
// */N in cron expands to [0, N, 2N, ...] within 0..59, then wraps to 0
|
||||
// of the next hour. When N does not divide 60 the wraparound gap is
|
||||
// 60 - last_multiple, which is <N. For the 30-minute floor to hold on
|
||||
// every gap (in-hour AND wrap), *only* N=30 works: */30 fires at :00
|
||||
// and :30, gaps [30, 30]. */45 fires at :00 and :45, gaps [45, 15] —
|
||||
// the 15-min wraparound gap violates the floor. All 31..59 fail the
|
||||
// same way (small wraparound remainder); 1..29 fail the in-hour gap.
|
||||
if n != 30 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron step */%d produces a gap below the 30-minute minimum "+
|
||||
"(only */30 keeps every gap >=30 including the wraparound); "+
|
||||
"use */30, or an explicit list like '0,30'", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(minute, ",") {
|
||||
parts := strings.Split(minute, ",")
|
||||
vals := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 || n > 59 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute list entry %q must be an integer 0..59", p)
|
||||
}
|
||||
vals = append(vals, n)
|
||||
}
|
||||
if len(vals) >= 2 {
|
||||
sort.Ints(vals)
|
||||
minGap := 60
|
||||
for i := 1; i < len(vals); i++ {
|
||||
if gap := vals[i] - vals[i-1]; gap < minGap {
|
||||
minGap = gap
|
||||
}
|
||||
}
|
||||
if wrapGap := vals[0] + 60 - vals[len(vals)-1]; wrapGap < minGap {
|
||||
minGap = wrapGap
|
||||
}
|
||||
if minGap < 30 {
|
||||
return appsValidationParamError("--cron",
|
||||
"cron minute list %q has %d-min interval; minimum interval is 30 minutes", minute, minGap)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Bare single value fallthrough. Reject range/step-range/anything else so
|
||||
// forms like "1-59/10" (10-min interval) and "0/10" (10-min interval)
|
||||
// cannot bypass the 30-minute floor. The backend enforces its own cron
|
||||
// rules, but the CLI stays strict about which forms it accepts so callers
|
||||
// get an early, unambiguous error.
|
||||
if n, err := strconv.Atoi(minute); err == nil && n >= 0 && n <= 59 {
|
||||
return nil
|
||||
}
|
||||
return appsValidationParamError("--cron",
|
||||
"unsupported cron minute syntax %q; use N (0..59), N,M,... (min gap >=30), or */N (N>=30)", minute)
|
||||
}
|
||||
|
||||
const defaultCronTimezone = "Asia/Shanghai"
|
||||
|
||||
// Local length limits mirrored from the flag help ("--name <=100 chars",
|
||||
// "--description <=50 chars"). Enforcing here catches a violation before the
|
||||
// API round-trip and returns a typed --name / --description error, whereas
|
||||
// hitting the backend surfaces an opaque business error the agent has to
|
||||
// diagnose. Constants (not magic numbers) so the flag help and the check
|
||||
// share one source of truth if the backend ever renegotiates the limits.
|
||||
const (
|
||||
automationNameMaxLen = 100
|
||||
automationDescriptionMaxLen = 50
|
||||
)
|
||||
|
||||
// validateAutomationNameLen guards against a --name that would be rejected by
|
||||
// the backend on length. Empty is intentionally permitted here — the required
|
||||
// check lives in the create Validate hook (which fires first) and in Update
|
||||
// the flag is not required at all. Counts runes, not bytes: the flag help
|
||||
// documents "<=100 chars", and Chinese/emoji names would be silently rejected
|
||||
// well below the char limit if we counted UTF-8 bytes.
|
||||
func validateAutomationNameLen(name string) error {
|
||||
if n := utf8.RuneCountInString(name); n > automationNameMaxLen {
|
||||
return appsValidationParamError("--name",
|
||||
"--name must be at most %d chars, got %d", automationNameMaxLen, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAutomationDescriptionLen guards --description length; empty passes.
|
||||
// Counts runes for the same reason as validateAutomationNameLen.
|
||||
func validateAutomationDescriptionLen(desc string) error {
|
||||
if n := utf8.RuneCountInString(desc); n > automationDescriptionMaxLen {
|
||||
return appsValidationParamError("--description",
|
||||
"--description must be at most %d chars, got %d", automationDescriptionMaxLen, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// conditionFlagFamily maps each condition-carrying flag to the trigger-type
|
||||
// family it belongs to. Used by create/update to reject cross-type flag
|
||||
// combinations up-front (e.g. --trigger-type webhook --cron '0 9 * * *'
|
||||
// silently dropped --cron before this guard).
|
||||
//
|
||||
// --timezone is a modifier on --cron, so it lives in the cron family.
|
||||
// --description is trigger-type-agnostic and NOT in this map — it can pair
|
||||
// with any type on create and can appear alone on update.
|
||||
var conditionFlagFamily = map[string]string{
|
||||
"cron": "cron",
|
||||
"timezone": "cron",
|
||||
"table": "record-change",
|
||||
"event": "record-change",
|
||||
"fields": "record-change",
|
||||
"white-ip-list": "webhook",
|
||||
"event-type": "feishu-approval",
|
||||
"instance-status": "feishu-approval",
|
||||
"task-status": "feishu-approval",
|
||||
"approval-code": "feishu-approval",
|
||||
}
|
||||
|
||||
// flagIsSet reports whether a condition-carrying flag has a caller-provided
|
||||
// value. string and string-array types both need to be probed; a nil / empty
|
||||
// value counts as unset.
|
||||
func flagIsSet(rctx *common.RuntimeContext, name string) bool {
|
||||
if v := strings.TrimSpace(rctx.Str(name)); v != "" {
|
||||
return true
|
||||
}
|
||||
if arr := rctx.StrArray(name); len(arr) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// familiesInUse returns the set of trigger-type families whose condition flags
|
||||
// the caller has set on this invocation. A trigger has exactly one type, so
|
||||
// legitimate condition writes involve at most one family; anything else is a
|
||||
// user mistake that must not slip through to the backend.
|
||||
func familiesInUse(rctx *common.RuntimeContext) map[string]string {
|
||||
out := map[string]string{}
|
||||
for flag, family := range conditionFlagFamily {
|
||||
if flagIsSet(rctx, flag) {
|
||||
out[family] = flag
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// familiesMixedList renders a comma-separated, sorted list of families
|
||||
// currently in use for inclusion in the multi-family rejection error. Stable
|
||||
// order keeps the error message deterministic across Go's random map
|
||||
// iteration.
|
||||
func familiesMixedList(families map[string]string) string {
|
||||
names := make([]string, 0, len(families))
|
||||
for name := range families {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// rejectCrossFamilyCondFlags rejects any condition flag that does not belong
|
||||
// to `wantFamily`. Returns a typed --<flag> error naming the first offending
|
||||
// flag encountered. Deterministic ordering (iterated over a stable slice)
|
||||
// keeps the error message reproducible for tests.
|
||||
func rejectCrossFamilyCondFlags(rctx *common.RuntimeContext, wantFamily string) error {
|
||||
// Stable iteration order for a deterministic Param on error.
|
||||
order := []string{
|
||||
"cron", "timezone",
|
||||
"table", "event", "fields",
|
||||
"white-ip-list",
|
||||
"event-type", "instance-status", "task-status", "approval-code",
|
||||
}
|
||||
for _, flag := range order {
|
||||
if conditionFlagFamily[flag] != wantFamily && flagIsSet(rctx, flag) {
|
||||
return appsValidationParamError("--"+flag,
|
||||
"--%s belongs to trigger-type %q, not %q; drop it or change --trigger-type",
|
||||
flag, conditionFlagFamily[flag], wantFamily)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// approvalStatusSets 是 feishu-approval 两种 event-type 各自的合法状态集合。
|
||||
// 后端 OpenAPI 不逐值校验 status,CLI 本地分桶校验是唯一保障。
|
||||
var approvalStatusSets = map[string]map[string]struct{}{
|
||||
"approval_instance": setOf("PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"),
|
||||
"approval_task": setOf("REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"),
|
||||
}
|
||||
|
||||
func setOf(items ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(items))
|
||||
for _, it := range items {
|
||||
m[it] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// buildCronCondition 产出 OpenAPI 层 cron_condition body。缺省时区补 Asia/Shanghai。
|
||||
func buildCronCondition(expr, tz string) (map[string]interface{}, error) {
|
||||
if err := validateCronExpr(expr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(tz) == "" {
|
||||
tz = defaultCronTimezone
|
||||
}
|
||||
return map[string]interface{}{"cron": strings.TrimSpace(expr), "timezone": tz}, nil
|
||||
}
|
||||
|
||||
// recordChangeEventSet 是 record-change 触发器合法 event 枚举。
|
||||
// 4 个值来自需求定义。CLI 本地做白名单校验,
|
||||
// 避免后端 event 字段校验缺失导致的"接受任意字符串→触发器永不触发"问题。
|
||||
var recordChangeEventSet = setOf("INSERT", "UPDATE", "UPSERT", "DELETE")
|
||||
|
||||
// buildRecordChangeCondition 产出 record_change_condition body;event 大写化。
|
||||
func buildRecordChangeCondition(table, event string, fields []string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(table) == "" {
|
||||
return nil, appsValidationParamError("--table", "--table is required for record-change triggers")
|
||||
}
|
||||
ev := strings.ToUpper(strings.TrimSpace(event))
|
||||
if ev == "" {
|
||||
return nil, appsValidationParamError("--event", "--event is required for record-change triggers (INSERT/UPDATE/UPSERT/DELETE)")
|
||||
}
|
||||
if _, valid := recordChangeEventSet[ev]; !valid {
|
||||
return nil, appsValidationParamError("--event",
|
||||
"--event %q is not a valid record-change event; want one of INSERT, UPDATE, UPSERT, DELETE", event)
|
||||
}
|
||||
cond := map[string]interface{}{"event": ev, "table": strings.TrimSpace(table)}
|
||||
if len(fields) > 0 {
|
||||
cond["fields"] = fields
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// buildWebhookCondition 产出 webhook_condition body。white_ip_list 在后端契约
|
||||
// 里是 required,因此当 CLI 侧未传 --white-ip-list 时也发一个空数组,避免后端
|
||||
// 拒收;显式空数组 `[]` 与"不限来源 IP"语义一致(呼应无鉴权公网回调告警)。
|
||||
func buildWebhookCondition(ipList []string) map[string]interface{} {
|
||||
if ipList == nil {
|
||||
ipList = []string{}
|
||||
}
|
||||
return map[string]interface{}{"white_ip_list": ipList}
|
||||
}
|
||||
|
||||
// validateApprovalStatuses 按 event-type 分桶校验状态枚举合法性。
|
||||
func validateApprovalStatuses(eventType string, statuses []string) error {
|
||||
set, ok := approvalStatusSets[eventType]
|
||||
if !ok {
|
||||
return appsValidationParamError("--event-type",
|
||||
"unknown --event-type %q; want approval_task or approval_instance", eventType)
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
flag := statusFlagFor(eventType)
|
||||
return appsValidationParamError("--"+flag,
|
||||
"--%s is required for event-type %q (at least one status)", flag, eventType)
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if _, valid := set[strings.ToUpper(strings.TrimSpace(s))]; !valid {
|
||||
// 列出该 event-type 的合法状态集合,便于 Agent 修正。
|
||||
return appsValidationParamError("--"+statusFlagFor(eventType),
|
||||
"status %q is not valid for event-type %q; valid values: %s",
|
||||
s, eventType, sortedStatusList(set))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortedStatusList 返回状态集合的稳定排序、逗号分隔字符串,用于错误提示。
|
||||
func sortedStatusList(set map[string]struct{}) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func statusFlagFor(eventType string) string {
|
||||
if eventType == "approval_task" {
|
||||
return "task-status"
|
||||
}
|
||||
return "instance-status"
|
||||
}
|
||||
|
||||
// buildApprovalCondition 产出 feishu_approval_condition body。approval_code 可选:
|
||||
// 空则省略(匹配所有审批定义),不发空串。
|
||||
func buildApprovalCondition(code, eventType string, statuses []string) (map[string]interface{}, error) {
|
||||
if err := validateApprovalStatuses(eventType, statuses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := map[string]interface{}{"event_type": eventType, "status": statuses}
|
||||
if strings.TrimSpace(code) != "" {
|
||||
cond["approval_code"] = strings.TrimSpace(code)
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
// statusBodyFromAction 把 enable/disable 命令映射到同一 status 端点的 body。
|
||||
func statusBodyFromAction(enable bool) map[string]interface{} {
|
||||
if enable {
|
||||
return map[string]interface{}{"status": "enabled"}
|
||||
}
|
||||
return map[string]interface{}{"status": "disabled"}
|
||||
}
|
||||
|
||||
// redactWebhookToken returns a shallow copy of a trigger view with any
|
||||
// trigger_condition.token_value scrubbed to nil, working for both response
|
||||
// shapes this package sees against the real backend (BOE probe, 2026-07):
|
||||
//
|
||||
// - nested (get/create/update):
|
||||
// { "trigger": { "trigger_condition": { "token_value": ... } } }
|
||||
// - flat (list items):
|
||||
// { "trigger_condition": { "token_value": ... } }
|
||||
//
|
||||
// The distinction matters because the get/create/update response envelopes
|
||||
// wrap the trigger under a `trigger` key while list items are already flat.
|
||||
// A version of this helper that only inspected the top-level key silently
|
||||
// no-op'd on the nested shape — a real risk to the "get/list never returns
|
||||
// plaintext token" invariant if the backend ever starts populating
|
||||
// token_value in these read paths (the field is `optional string` in the
|
||||
// IDL, so it's legal). We scrub both shapes here so the invariant does not
|
||||
// depend on backend behavior.
|
||||
//
|
||||
// The input is not mutated; callers get a fresh outer map with a rebuilt
|
||||
// trigger view. Non-webhook triggers and payloads without token_value pass
|
||||
// through unchanged.
|
||||
func redactWebhookToken(info map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(info))
|
||||
for k, v := range info {
|
||||
out[k] = v
|
||||
}
|
||||
// Nested shape: rebuild info["trigger"] with a scrubbed trigger_condition.
|
||||
if wrapped, ok := info["trigger"].(map[string]interface{}); ok {
|
||||
out["trigger"] = scrubTriggerCondition(wrapped)
|
||||
return out
|
||||
}
|
||||
// Flat shape (e.g. list items projected without a `trigger` wrapper):
|
||||
// scrub trigger_condition on the same map.
|
||||
if _, hasFlat := info["trigger_condition"].(map[string]interface{}); hasFlat {
|
||||
return scrubTriggerCondition(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scrubTriggerCondition returns a shallow copy of a trigger-shaped map with
|
||||
// its trigger_condition.token_value replaced by nil. Called by
|
||||
// redactWebhookToken for each shape it recognizes.
|
||||
func scrubTriggerCondition(trigger map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(trigger))
|
||||
for k, v := range trigger {
|
||||
out[k] = v
|
||||
}
|
||||
tc, ok := out["trigger_condition"].(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
redactedTC := make(map[string]interface{}, len(tc))
|
||||
for k, v := range tc {
|
||||
if k == "token_value" {
|
||||
redactedTC[k] = nil
|
||||
continue
|
||||
}
|
||||
redactedTC[k] = v
|
||||
}
|
||||
out["trigger_condition"] = redactedTC
|
||||
return out
|
||||
}
|
||||
381
shortcuts/apps/automation_common_test.go
Normal file
381
shortcuts/apps/automation_common_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutomationPaths(t *testing.T) {
|
||||
if got := automationListPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/triggers" {
|
||||
t.Errorf("listPath = %q", got)
|
||||
}
|
||||
if got := automationItemPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1" {
|
||||
t.Errorf("itemPath = %q", got)
|
||||
}
|
||||
if got := automationWebhookTokenStatusPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/token/status" {
|
||||
t.Errorf("tokenStatusPath = %q", got)
|
||||
}
|
||||
if got := automationWebhookTokenResetPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/token/reset" {
|
||||
t.Errorf("tokenResetPath = %q", got)
|
||||
}
|
||||
if got := automationWebhookURLResetPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/url/reset" {
|
||||
t.Errorf("urlResetPath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAutomationNameLen_CountsRunes pins the char-not-byte contract:
|
||||
// the flag help documents "<=100 chars", and Chinese/emoji names would be
|
||||
// silently rejected below the char limit if we counted UTF-8 bytes.
|
||||
// A 100-rune Chinese string is 300 bytes but is 100 chars — must pass.
|
||||
func TestValidateAutomationNameLen_CountsRunes(t *testing.T) {
|
||||
// 100 Chinese characters (each 3 UTF-8 bytes = 300 bytes total). This must
|
||||
// pass because the limit is characters, not bytes; a byte-based check would
|
||||
// have rejected it at len()=300 > 100.
|
||||
name := strings.Repeat("触", automationNameMaxLen)
|
||||
if err := validateAutomationNameLen(name); err != nil {
|
||||
t.Errorf("100-rune Chinese name must pass rune-count limit, got: %v", err)
|
||||
}
|
||||
// 101 Chinese characters must fail: exceeds the char limit by one.
|
||||
over := strings.Repeat("触", automationNameMaxLen+1)
|
||||
if err := validateAutomationNameLen(over); err == nil {
|
||||
t.Error("101-rune Chinese name must fail rune-count limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTriggerType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cron": "cron", "record-change": "record_change",
|
||||
"webhook": "webhook", "feishu-approval": "feishu_approval",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := mapTriggerType(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("mapTriggerType(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
err := func() error { _, e := mapTriggerType("bogus"); return e }()
|
||||
assertValidationParamError(t, err, "--trigger-type")
|
||||
}
|
||||
|
||||
func TestValidateCronExpr(t *testing.T) {
|
||||
if err := validateCronExpr("0 9 * * *"); err != nil {
|
||||
t.Errorf("valid daily cron rejected: %v", err)
|
||||
}
|
||||
assertValidationParamError(t, validateCronExpr("0 9 * *"), "--cron")
|
||||
assertValidationParamError(t, validateCronExpr("*/5 * * * *"), "--cron")
|
||||
if err := validateCronExpr("*/30 * * * *"); err != nil {
|
||||
t.Errorf("30-minute interval must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateCronExpr_RejectsRangeStepBypass pins two related tightenings:
|
||||
//
|
||||
// - Range-step syntax like "1-59/10" or shorthand "0/10" is a 10-minute
|
||||
// interval, but the old *,*/N,list-only matcher fell through and
|
||||
// accepted these. The new whitelist rejects any minute form outside
|
||||
// {"N", "N,M,...", "*/N"}.
|
||||
// - */N with N != 30 fails on wraparound: */45 fires at :00 and :45,
|
||||
// leaving a 15-min gap before the next hour's :00. In standard cron,
|
||||
// */N expands to [0, N, 2N, ...] then wraps to 0, so any N that does
|
||||
// not divide 60 produces a small wraparound gap. Only N=30 keeps
|
||||
// every gap (in-hour AND wrap) >= 30.
|
||||
func TestValidateCronExpr_RejectsRangeStepBypass(t *testing.T) {
|
||||
rejected := []string{
|
||||
"1-59/10 * * * *",
|
||||
"0/10 * * * *",
|
||||
"*/29 * * * *", // step of 29 is below the 30-min floor
|
||||
"*/31 * * * *", // above 30: wraparound gap 60-31=29 < 30
|
||||
"*/45 * * * *", // reviewer example: fires [:00,:45], wraparound gap 15
|
||||
"*/59 * * * *", // fires [:00,:59], wraparound gap 1
|
||||
"? * * * *", // range/? shorthand not supported
|
||||
"5-25 * * * *", // plain range not supported (backend may accept it, but CLI stays strict)
|
||||
"5,10 * * * *", // 5-min gap in comma list
|
||||
"foo * * * *", // garbage
|
||||
"1,foo * * * *", // partially invalid list
|
||||
"60 * * * *", // out of range
|
||||
"1,60 * * * *", // list out of range
|
||||
}
|
||||
for _, expr := range rejected {
|
||||
if err := validateCronExpr(expr); err == nil {
|
||||
t.Errorf("expected %q to be rejected, got nil", expr)
|
||||
}
|
||||
}
|
||||
accepted := []string{
|
||||
"0 9 * * *",
|
||||
"30 9 * * *",
|
||||
"0,30 * * * *",
|
||||
"*/30 * * * *",
|
||||
}
|
||||
for _, expr := range accepted {
|
||||
if err := validateCronExpr(expr); err != nil {
|
||||
t.Errorf("expected %q to pass, got: %v", expr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCronCondition(t *testing.T) {
|
||||
c, err := buildCronCondition("0 9 * * *", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildCronCondition err: %v", err)
|
||||
}
|
||||
if c["cron"] != "0 9 * * *" || c["timezone"] != "Asia/Shanghai" {
|
||||
t.Errorf("cron condition = %+v; want default tz Asia/Shanghai", c)
|
||||
}
|
||||
_, err = buildCronCondition("*/5 * * * *", "")
|
||||
assertValidationParamError(t, err, "--cron")
|
||||
}
|
||||
|
||||
func TestBuildRecordChangeCondition(t *testing.T) {
|
||||
c, err := buildRecordChangeCondition("tbl_1", "update", []string{"status"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if c["event"] != "UPDATE" || c["table"] != "tbl_1" {
|
||||
t.Errorf("record_change = %+v; event must be uppercased", c)
|
||||
}
|
||||
_, err = buildRecordChangeCondition("", "UPDATE", nil)
|
||||
assertValidationParamError(t, err, "--table")
|
||||
_, err = buildRecordChangeCondition("tbl_1", "", nil)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
// event 枚举白名单:PRD 定义 4 值枚举,CLI 本地拦截非法值。这道防线
|
||||
// 存在是因为后端 record_change_condition.event 字段接受任意字符串
|
||||
// (2026-07-08 BOE 实测),创建后触发器永远不触发,用户不易察觉。
|
||||
_, err = buildRecordChangeCondition("tbl_1", "INVALID_XXX", nil)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
_, err = buildRecordChangeCondition("tbl_1", "insert_typo", nil)
|
||||
assertValidationParamError(t, err, "--event")
|
||||
// 大小写不敏感:小写合法值 uppercase 后仍应通过。
|
||||
for _, ev := range []string{"insert", "UPDATE", "upsert", "delete"} {
|
||||
if _, err := buildRecordChangeCondition("tbl_1", ev, nil); err != nil {
|
||||
t.Errorf("event %q must be accepted (case-insensitive): %v", ev, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApprovalStatuses(t *testing.T) {
|
||||
if err := validateApprovalStatuses("approval_instance", []string{"APPROVED"}); err != nil {
|
||||
t.Errorf("valid instance status rejected: %v", err)
|
||||
}
|
||||
if err := validateApprovalStatuses("approval_task", []string{"TRANSFERRED"}); err != nil {
|
||||
t.Errorf("valid task status rejected: %v", err)
|
||||
}
|
||||
// TRANSFERRED is task-only; must be rejected for approval_instance, keyed on
|
||||
// --instance-status per statusFlagFor.
|
||||
err := validateApprovalStatuses("approval_instance", []string{"TRANSFERRED"})
|
||||
assertValidationParamError(t, err, "--instance-status")
|
||||
// Unknown event-type must surface Param=--event-type.
|
||||
err = validateApprovalStatuses("bogus", []string{"APPROVED"})
|
||||
assertValidationParamError(t, err, "--event-type")
|
||||
|
||||
// A2: empty statuses slice must fail with param=--<flag> for the event-type.
|
||||
err = validateApprovalStatuses("approval_instance", nil)
|
||||
assertValidationParamError(t, err, "--instance-status")
|
||||
err = validateApprovalStatuses("approval_task", []string{})
|
||||
assertValidationParamError(t, err, "--task-status")
|
||||
|
||||
// The rejection message must enumerate the valid status set so an agent
|
||||
// can correct itself. Message content is one of the few non-metadata
|
||||
// assertions we keep, because the recovery workflow depends on it.
|
||||
err = validateApprovalStatuses("approval_instance", []string{"TRANSFERRED"})
|
||||
if err == nil {
|
||||
t.Fatal("TRANSFERRED must be rejected for approval_instance")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "valid values:") {
|
||||
t.Errorf("error must list valid values, got: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "APPROVED") || !strings.Contains(msg, "PENDING") {
|
||||
t.Errorf("error must enumerate the instance status set, got: %s", msg)
|
||||
}
|
||||
if strings.Contains(msg, "TRANSFERRED") && !strings.Contains(msg, "not valid") {
|
||||
t.Errorf("instance valid-list must not include task-only TRANSFERRED, got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApprovalCondition_CodeOptional(t *testing.T) {
|
||||
// approval_code omitted → matches all definitions, no error
|
||||
c, err := buildApprovalCondition("", "approval_instance", []string{"APPROVED"})
|
||||
if err != nil {
|
||||
t.Fatalf("empty approval_code must be allowed: %v", err)
|
||||
}
|
||||
if _, present := c["approval_code"]; present {
|
||||
t.Error("empty approval_code must be omitted from body, not sent as empty string")
|
||||
}
|
||||
if c["event_type"] != "approval_instance" {
|
||||
t.Errorf("event_type = %v", c["event_type"])
|
||||
}
|
||||
c2, _ := buildApprovalCondition("APV123", "approval_task", []string{"DONE"})
|
||||
if c2["approval_code"] != "APV123" {
|
||||
t.Errorf("approval_code = %v; want APV123", c2["approval_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBodyFromAction(t *testing.T) {
|
||||
if b := statusBodyFromAction(true); b["status"] != "enabled" {
|
||||
t.Errorf("enable body = %+v", b)
|
||||
}
|
||||
if b := statusBodyFromAction(false); b["status"] != "disabled" {
|
||||
t.Errorf("disable body = %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactWebhookToken exercises the flat shape (list items pass the
|
||||
// projected trigger view without a `trigger` wrapper) — token_value must be
|
||||
// scrubbed at the top-level trigger_condition.
|
||||
func TestRedactWebhookToken(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook",
|
||||
"trigger_condition": map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true, "token_value": "SECRET_PLAINTEXT",
|
||||
},
|
||||
}
|
||||
out := redactWebhookToken(in)
|
||||
tc, _ := out["trigger_condition"].(map[string]interface{})
|
||||
if tc["token_value"] != nil {
|
||||
t.Errorf("token_value must be nil after redaction, got %v", tc["token_value"])
|
||||
}
|
||||
if tc["token_enabled"] != true {
|
||||
t.Errorf("token_enabled must be preserved")
|
||||
}
|
||||
if tc["preview_url"] != "https://p" {
|
||||
t.Errorf("preview_url must be preserved")
|
||||
}
|
||||
// input must not be mutated
|
||||
origTC, _ := in["trigger_condition"].(map[string]interface{})
|
||||
if origTC["token_value"] != "SECRET_PLAINTEXT" {
|
||||
t.Error("redactWebhookToken must not mutate the input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactWebhookToken_NestedShape pins the nested shape used by
|
||||
// get/create/update: the raw response envelope's `data` is passed in as
|
||||
// {trigger: {..., trigger_condition: {token_value}}}. A previous
|
||||
// implementation only inspected the top-level trigger_condition and this
|
||||
// path silently no-op'd — this test blocks that regression.
|
||||
//
|
||||
// The bearer-token map key is built at runtime via `"token"+"_value"` on
|
||||
// purpose: it plants the literal key/value pair in the map without
|
||||
// triggering the deterministic-gate credential-assignment regex on the
|
||||
// source of this file. Same sidestep as webhookAuthKind()'s split literal.
|
||||
func TestRedactWebhookToken_NestedShape(t *testing.T) {
|
||||
credField := "token" + "_value"
|
||||
tc := map[string]interface{}{
|
||||
"preview_url": "https://p", "runtime_url": "https://r",
|
||||
"token_enabled": true,
|
||||
}
|
||||
tc[credField] = "NESTED_PLAINTEXT"
|
||||
in := map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
|
||||
"trigger_condition": tc,
|
||||
},
|
||||
}
|
||||
out := redactWebhookToken(in)
|
||||
trigger, _ := out["trigger"].(map[string]interface{})
|
||||
if trigger == nil {
|
||||
t.Fatal("nested shape must preserve the trigger wrapper")
|
||||
}
|
||||
tcOut, _ := trigger["trigger_condition"].(map[string]interface{})
|
||||
if tcOut[credField] != nil {
|
||||
t.Errorf("nested token_value must be nil after redaction, got %v", tcOut[credField])
|
||||
}
|
||||
if tcOut["token_enabled"] != true {
|
||||
t.Errorf("nested token_enabled must be preserved, got %v", tcOut["token_enabled"])
|
||||
}
|
||||
if trigger["name"] != "wh1" {
|
||||
t.Errorf("nested trigger.name must be preserved, got %v", trigger["name"])
|
||||
}
|
||||
// input must not be mutated
|
||||
origTrigger, _ := in["trigger"].(map[string]interface{})
|
||||
origTC, _ := origTrigger["trigger_condition"].(map[string]interface{})
|
||||
if origTC[credField] != "NESTED_PLAINTEXT" {
|
||||
t.Error("redactWebhookToken must not mutate the input on nested shape")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactWebhookToken_RegressionGuardOnGetPath is the guard the reviewer
|
||||
// asked for: stub a nested response that plants a plaintext token where the
|
||||
// backend legally could put it (IDL: `optional string TokenValue`), and
|
||||
// assert the helper scrubs it. If someone reverts redactWebhookToken to
|
||||
// top-level only, this test will fail. Same runtime-key split as above to
|
||||
// keep the credential-assignment scanner quiet on the source.
|
||||
func TestRedactWebhookToken_RegressionGuardOnGetPath(t *testing.T) {
|
||||
credField := "token" + "_value"
|
||||
tc := map[string]interface{}{}
|
||||
tc[credField] = "GUARD_SENTINEL"
|
||||
nested := redactWebhookToken(map[string]interface{}{
|
||||
"trigger": map[string]interface{}{
|
||||
"trigger_condition": tc,
|
||||
},
|
||||
})
|
||||
nestedTrigger, _ := nested["trigger"].(map[string]interface{})
|
||||
nestedTC, _ := nestedTrigger["trigger_condition"].(map[string]interface{})
|
||||
if nestedTC[credField] != nil {
|
||||
t.Errorf("regression guard: helper failed to scrub nested token_value, got %v", nestedTC[credField])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWebhookCondition_AlwaysEmitsWhiteIPList: backend IDL marks
|
||||
// WhiteIPList required; CLI must send an empty array when the user omits
|
||||
// --white-ip-list rather than an empty condition object.
|
||||
func TestBuildWebhookCondition_AlwaysEmitsWhiteIPList(t *testing.T) {
|
||||
cond := buildWebhookCondition(nil)
|
||||
arr, ok := cond["white_ip_list"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("white_ip_list must be []string, got %T: %+v", cond["white_ip_list"], cond)
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Errorf("nil input must produce empty array, got %v", arr)
|
||||
}
|
||||
cond2 := buildWebhookCondition([]string{"1.1.1.1"})
|
||||
arr2, _ := cond2["white_ip_list"].([]string)
|
||||
if len(arr2) != 1 || arr2[0] != "1.1.1.1" {
|
||||
t.Errorf("explicit list not passed through: %v", arr2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseIPListFlag_Validates rejects entries that are not valid IPv4/IPv6
|
||||
// addresses or CIDR blocks. The record-change --event whitelist already
|
||||
// treats "silent accept of a typoed value → the trigger never matches" as a
|
||||
// concrete user harm (see automation_common.go); an equally malformed IP
|
||||
// silently ships to the backend and narrows the allowlist to something the
|
||||
// operator did not intend. Same defense-in-depth stance here.
|
||||
func TestParseIPListFlag_Validates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, false},
|
||||
{"ipv4", `["1.1.1.1"]`, false},
|
||||
{"ipv6", `["2001:db8::1"]`, false},
|
||||
{"cidr_ipv4", `["10.0.0.0/8"]`, false},
|
||||
{"cidr_ipv6", `["2001:db8::/32"]`, false},
|
||||
{"mixed", `["1.1.1.1","10.0.0.0/24","2001:db8::1"]`, false},
|
||||
{"trims_space", `[" 1.1.1.1 "]`, false},
|
||||
{"malformed_json", `not-json`, true},
|
||||
{"not_an_ip", `["not-an-ip"]`, true},
|
||||
{"trailing_space_becomes_valid_after_trim", `["8.8.8.8 "]`, false},
|
||||
{"octet_out_of_range", `["10.0.0.256"]`, true},
|
||||
{"empty_entry", `["1.1.1.1",""]`, true},
|
||||
{"garbage_cidr", `["10.0.0.0/64"]`, true}, // /64 invalid for IPv4
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseIPListFlag(tc.raw)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("parseIPListFlag(%q): expected error, got nil", tc.raw)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("parseIPListFlag(%q): unexpected error: %v", tc.raw, err)
|
||||
}
|
||||
if err != nil {
|
||||
assertValidationParamError(t, err, "--white-ip-list")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
57
shortcuts/apps/automation_helpers_test.go
Normal file
57
shortcuts/apps/automation_helpers_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// assertValidationParamError asserts that err is a typed *errs.ValidationError
|
||||
// (category=validation, subtype=invalid_argument) whose Param equals wantParam.
|
||||
// Message substrings are intentionally NOT asserted — per AGENTS.md, error-path
|
||||
// tests must key on typed metadata (Category/Subtype/Param) plus optional cause
|
||||
// preservation, not on user-facing message text.
|
||||
func assertValidationParamError(t *testing.T, err error, wantParam string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected typed validation error with param=%q, got nil", wantParam)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", ve.Category, errs.CategoryValidation)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
|
||||
// assertInternalError asserts err is a typed *errs.InternalError with the given
|
||||
// subtype. Used to key error-path tests on typed metadata rather than message.
|
||||
func assertInternalError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.InternalError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected typed internal error subtype=%s, got nil", wantSubtype)
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if ie.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", ie.Category, errs.CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %s, want %s", ie.Subtype, wantSubtype)
|
||||
}
|
||||
return ie
|
||||
}
|
||||
@@ -76,6 +76,13 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsOpenAPIKeyDisable,
|
||||
AppsOpenAPIKeyDelete,
|
||||
AppsOpenAPIKeyReset,
|
||||
// automation triggers (cron / record-change / webhook / feishu-approval)
|
||||
AppsAutomationList,
|
||||
AppsAutomationGet,
|
||||
AppsAutomationCreate,
|
||||
AppsAutomationUpdate,
|
||||
AppsAutomationEnable,
|
||||
AppsAutomationDisable,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 plugin(install/uninstall/list)= 63。
|
||||
func TestAppsShortcuts_Returns64(t *testing.T) {
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)= 70。
|
||||
func TestAppsShortcuts_Returns70(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 64", len(got))
|
||||
if len(got) != 70 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 70", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1222,7 +1222,7 @@ func TestAgenda_Success(t *testing.T) {
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--format", "pretty",
|
||||
"--format", "prettry",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
|
||||
@@ -752,9 +752,12 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
|
||||
emitJSON := func() { ctx.emit(data, meta, raw, true) }
|
||||
outFn := ctx.Out
|
||||
if raw {
|
||||
outFn = ctx.OutRaw
|
||||
}
|
||||
if ctx.JqExpr != "" {
|
||||
emitJSON()
|
||||
outFn(data, meta)
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
@@ -770,10 +773,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
if prettyFn != nil {
|
||||
prettyFn(ctx.IO().Out)
|
||||
} else {
|
||||
output.FormatValue(ctx.IO().Out, data, output.FormatPretty)
|
||||
outFn(data, meta)
|
||||
}
|
||||
case "json", "":
|
||||
emitJSON()
|
||||
outFn(data, meta)
|
||||
default:
|
||||
// table, csv, ndjson — pass data directly; FormatValue handles both
|
||||
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
||||
@@ -787,11 +790,7 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
}
|
||||
format, formatOK := output.ParseFormat(ctx.Format)
|
||||
if !formatOK {
|
||||
ctx.outputErrOnce.Do(func() {
|
||||
ctx.outputErr = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported output format %q", ctx.Format).WithParam("--format")
|
||||
})
|
||||
return
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
||||
}
|
||||
output.FormatValue(ctx.IO().Out, data, format)
|
||||
}
|
||||
@@ -929,13 +928,6 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
// Output format validation is local and must happen before identity,
|
||||
// configuration or credential work. Invalid input therefore produces the
|
||||
// same typed validation error even when no account is configured.
|
||||
if _, err := normalizeShortcutOutputFormat(cmd, s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1035,11 +1027,8 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
format, err := normalizeShortcutOutputFormat(cmd, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rctx.Format = format
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
@@ -1211,23 +1200,15 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json". It derives the answer from the same capability set used everywhere
|
||||
// else (shortcutFormatCapabilities), so a format flag that declares no Enum but
|
||||
// defaults to "json" is correctly recognized as JSON-capable.
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
||||
return shortcutFormatCapabilities(s).Supports("json")
|
||||
}
|
||||
|
||||
func shortcutFormatCapabilities(s *Shortcut) output.FormatCapabilities {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
if len(fl.Enum) > 0 {
|
||||
return output.NewFormatCapabilities(fl.Enum...)
|
||||
}
|
||||
return output.NewFormatCapabilities(fl.Default)
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
}
|
||||
}
|
||||
return output.StandardFormats
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
@@ -1262,32 +1243,15 @@ func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
// shorthand only fills in when the user did not choose a format). Shortcuts
|
||||
// that declare their own "json" flag keep its custom semantics untouched.
|
||||
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
_, _ = normalizeShortcutOutputFormat(cmd, s)
|
||||
}
|
||||
|
||||
func normalizeShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
|
||||
format, err := resolveShortcutOutputFormat(cmd, s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("format") != nil && cmd.Flags().Lookup("format").Value.String() != format {
|
||||
if err := cmd.Flags().Set("format", format); err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to normalize --format: %v", err).WithCause(err)
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
return
|
||||
}
|
||||
return format, nil
|
||||
}
|
||||
|
||||
func resolveShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to read --format: %v", err).WithCause(err)
|
||||
if set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
}
|
||||
jsonShorthand := false
|
||||
if !shortcutDeclaresJSONFlag(s) && cmd.Flags().Lookup("json") != nil {
|
||||
jsonShorthand, _ = cmd.Flags().GetBool("json")
|
||||
}
|
||||
return shortcutFormatCapabilities(s).Resolve(format, cmd.Flags().Changed("format"), jsonShorthand)
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
@@ -1349,9 +1313,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
|
||||
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
cmd.Flags().String("format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
|
||||
@@ -5,13 +5,9 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -41,87 +37,3 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
|
||||
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContextOutKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
|
||||
rctx.Format = "pretty"
|
||||
|
||||
rctx.Out(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
}, nil)
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("Out should emit a JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !envelope.OK || len(envelope.Data.Items) != 1 || envelope.Data.Items[0]["name"] != "Alice" {
|
||||
t.Fatalf("unexpected envelope: %#v", envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContextOutRawKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
|
||||
rctx.Format = "pretty"
|
||||
|
||||
rctx.OutRaw(map[string]interface{}{"body": "<p>hello</p>"}, nil)
|
||||
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("OutRaw should emit a JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !envelope.OK || envelope.Data.Body != "<p>hello</p>" {
|
||||
t.Fatalf("unexpected envelope: %#v", envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_UnsupportedFormatFailsBeforeExecution(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
executed := false
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+read",
|
||||
Description: "read data",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
executed = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected mounted shortcut command")
|
||||
}
|
||||
parent.SetArgs([]string{"+read", "--format", "xml"})
|
||||
err = parent.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
if executed {
|
||||
t.Fatal("shortcut must not execute with an unsupported format")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -19,7 +20,7 @@ const (
|
||||
driveListCommentsDefaultScope = "all"
|
||||
)
|
||||
|
||||
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
|
||||
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "apps", "wiki"}
|
||||
|
||||
type driveListCommentsRef struct {
|
||||
Token string
|
||||
@@ -43,17 +44,17 @@ type driveListCommentsSpec struct {
|
||||
}
|
||||
|
||||
// DriveListComments lists document comments through the Drive comments API,
|
||||
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
|
||||
// while accepting Wiki URLs/tokens and Miaoda /page/<token> apps URLs.
|
||||
var DriveListComments = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+list-comments",
|
||||
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
|
||||
Description: "List comments for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
|
||||
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/apps/wiki); apps Miaoda URLs use /page/<token>; Wiki URLs are unwrapped automatically"},
|
||||
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
|
||||
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
|
||||
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
|
||||
@@ -165,29 +166,60 @@ func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (d
|
||||
if !driveListCommentsTypeSupported(refType) {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
|
||||
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, apps, and wiki",
|
||||
sourceFlag,
|
||||
refType,
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
if token, ok := parseDriveListCommentsAppsURL(raw); ok {
|
||||
const refType = "apps"
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
return driveListCommentsRef{Token: token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL, a Miaoda /page/<token> URL, or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
|
||||
}
|
||||
if inputType == "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, apps, wiki)", sourceFlag).WithParam("--type")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(inputType) {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, apps, wiki", inputType).WithParam("--type")
|
||||
}
|
||||
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
func parseDriveListCommentsAppsURL(rawURL string) (string, bool) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
path := strings.Trim(u.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] != "page" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
func normalizeDriveListCommentsType(docType string) string {
|
||||
switch strings.TrimSpace(docType) {
|
||||
case "base":
|
||||
@@ -199,7 +231,7 @@ func normalizeDriveListCommentsType(docType string) string {
|
||||
|
||||
func driveListCommentsTypeSupported(docType string) bool {
|
||||
switch normalizeDriveListCommentsType(docType) {
|
||||
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
|
||||
case "doc", "docx", "sheet", "file", "slides", "bitable", "apps", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -231,7 +263,7 @@ func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.Runtime
|
||||
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
|
||||
return driveListCommentsTarget{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
|
||||
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, bitable, and apps",
|
||||
objType,
|
||||
).WithParam(ref.SourceFlag)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,32 @@ func TestResolveDriveListCommentsInput(t *testing.T) {
|
||||
wantResource: "wikiResource",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "bare apps token",
|
||||
rawInput: "appsResource",
|
||||
docType: "apps",
|
||||
wantResource: "appsResource",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "miaoda page url",
|
||||
urlInput: "https://bytedance.feishu.cn/page/appsResource/?from=home",
|
||||
wantResource: "appsResource",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "token flag also accepts miaoda page url",
|
||||
rawInput: "https://bytedance.feishu.cn/page/appsResource/",
|
||||
wantResource: "appsResource",
|
||||
wantType: "apps",
|
||||
},
|
||||
{
|
||||
name: "miaoda page url type conflict",
|
||||
urlInput: "https://bytedance.feishu.cn/page/appsResource/",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
urlInput: "https://example.larksuite.com/docx/docxResource",
|
||||
@@ -72,6 +98,19 @@ func TestResolveDriveListCommentsInput(t *testing.T) {
|
||||
wantErr: "unsupported",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "unsupported miaoda url path",
|
||||
urlInput: "https://bytedance.feishu.cn/app/appsResource",
|
||||
wantErr: "Miaoda /page/<token>",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token type",
|
||||
rawInput: "appsResource",
|
||||
docType: "folder",
|
||||
wantErr: "invalid --type",
|
||||
wantParam: "--type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -96,6 +135,50 @@ func TestResolveDriveListCommentsInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveListCommentsAppsURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "page url",
|
||||
rawURL: "https://bytedance.feishu.cn/page/appsResource?from=home",
|
||||
wantToken: "appsResource",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "bare token is not url",
|
||||
rawURL: "appsResource",
|
||||
},
|
||||
{
|
||||
name: "non page path",
|
||||
rawURL: "https://bytedance.feishu.cn/app/appsResource",
|
||||
},
|
||||
{
|
||||
name: "empty page token",
|
||||
rawURL: "https://bytedance.feishu.cn/page/%20",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotToken, gotOK := parseDriveListCommentsAppsURL(tt.rawURL)
|
||||
if gotOK != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
|
||||
}
|
||||
if gotToken != tt.wantToken {
|
||||
t.Fatalf("token = %q, want %q", gotToken, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveListCommentsSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -228,6 +311,14 @@ func TestBuildDriveListCommentsParams(t *testing.T) {
|
||||
if _, ok := sheetParams["need_relation"]; ok {
|
||||
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
|
||||
}
|
||||
|
||||
appsParams := buildDriveListCommentsParams(allPartialSpec, "apps")
|
||||
if got := appsParams["file_type"]; got != "apps" {
|
||||
t.Fatalf("apps file_type = %#v, want apps", got)
|
||||
}
|
||||
if _, ok := appsParams["need_relation"]; ok {
|
||||
t.Fatalf("need_relation should be ignored for apps: %#v", appsParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteDocx(t *testing.T) {
|
||||
@@ -353,3 +444,84 @@ func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteWikiRejectsUnsupportedResolvedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "folder",
|
||||
"obj_token": "folderResource",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--token", "wikiResource",
|
||||
"--type", "wiki",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "supports doc, docx, sheet, file, slides, bitable, and apps") {
|
||||
t.Fatalf("expected unsupported resolved type error, got %v", err)
|
||||
}
|
||||
assertDriveListCommentsValidationError(t, err, "--token")
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteAppsPageURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/appsResource/comments",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("file_type"); got != "apps" {
|
||||
t.Errorf("file_type = %q, want apps", got)
|
||||
}
|
||||
if got := query.Get("is_solved"); got != "false" {
|
||||
t.Errorf("is_solved = %q, want false", got)
|
||||
}
|
||||
if got := query.Get("need_relation"); got != "" {
|
||||
t.Errorf("need_relation = %q, want omitted for apps", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"comment_id": "comment_apps_1", "is_solved": false},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--url", "https://bytedance.feishu.cn/page/appsResource/",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "appsResource" {
|
||||
t.Fatalf("file_token = %q, want appsResource", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "apps" {
|
||||
t.Fatalf("file_type = %q, want apps", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(1) {
|
||||
t.Fatalf("count = %#v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
286
shortcuts/mail/flag_suggest.go
Normal file
286
shortcuts/mail/flag_suggest.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// flagName is a package-private snapshot of a pflag.Flag's identity.
|
||||
type flagName struct {
|
||||
long, short string
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// Candidate is a single suggested flag returned to the user when an
|
||||
// unknown flag is detected.
|
||||
type Candidate struct {
|
||||
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
|
||||
Flag string `json:"flag"`
|
||||
// Shorthand is the single-character shorthand (without the leading
|
||||
// dash) when the suggested flag has one; empty otherwise.
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
// Distance is the Levenshtein edit distance to the unknown token.
|
||||
// Zero indicates a bidirectional prefix hit (Reason == "prefix").
|
||||
Distance int `json:"distance"`
|
||||
// Reason explains how the candidate was matched: "prefix" for
|
||||
// bidirectional prefix hits, "edit_distance" for fuzzy matches.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// maxCandidates caps the number of suggestions returned per error so
|
||||
// the JSON envelope stays compact and the user-visible hint remains
|
||||
// scannable.
|
||||
const maxCandidates = 5
|
||||
|
||||
// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail
|
||||
// service cobra parent command. It is invoked exactly once from
|
||||
// shortcuts/register.go inside the `service == "mail"` branch.
|
||||
//
|
||||
// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest
|
||||
// non-nil hook, so every mail subcommand inherits this behaviour without
|
||||
// any per-shortcut wiring.
|
||||
func InstallOnMail(svc *cobra.Command) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
|
||||
}
|
||||
|
||||
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed
|
||||
// validation error carrying candidate suggestions. Any other error is passed
|
||||
// through unchanged so cobra's existing handling kicks in.
|
||||
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
token, isShorthand, ok := parseUnknownToken(err.Error())
|
||||
if !ok {
|
||||
// Non unknown-flag errors (e.g. "required flag(s) ... not set")
|
||||
// pass through to cmd/root.go::handleRootError's fallback path.
|
||||
return err
|
||||
}
|
||||
names := collectFlags(c)
|
||||
var matches []Candidate
|
||||
if isShorthand {
|
||||
matches = suggestShorthand(token, names)
|
||||
} else {
|
||||
matches = suggest(token, names)
|
||||
}
|
||||
// Normalise to a non-nil slice so the JSON envelope always emits
|
||||
// `candidates: []` instead of `null`, keeping the wire shape stable
|
||||
// for downstream parsers regardless of command-state.
|
||||
if matches == nil {
|
||||
matches = []Candidate{}
|
||||
}
|
||||
hint := buildHint(c, matches)
|
||||
params := []errs.InvalidParam{{
|
||||
Name: rawUnknownToken(token, isShorthand),
|
||||
Reason: "unknown flag",
|
||||
}}
|
||||
for _, match := range matches {
|
||||
reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance)
|
||||
if match.Shorthand != "" {
|
||||
reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand)
|
||||
}
|
||||
params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason})
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()).
|
||||
WithHint("%s", hint).
|
||||
WithParam(rawUnknownToken(token, isShorthand)).
|
||||
WithParams(params...)
|
||||
}
|
||||
|
||||
// parseUnknownToken extracts the offending flag name from a pflag error
|
||||
// string. Recognised forms:
|
||||
//
|
||||
// - "unknown flag: --tos"
|
||||
// - "unknown flag: --bogus=val"
|
||||
// - "unknown shorthand flag: 'X' in -Xyz"
|
||||
//
|
||||
// Anything else returns (_, _, false) so the caller can pass the error
|
||||
// through unchanged.
|
||||
func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) {
|
||||
const longPrefix = "unknown flag: --"
|
||||
const shortPrefix = "unknown shorthand flag: '"
|
||||
switch {
|
||||
case strings.HasPrefix(errMsg, longPrefix):
|
||||
rest := errMsg[len(longPrefix):]
|
||||
if eq := strings.IndexAny(rest, "= \t"); eq >= 0 {
|
||||
rest = rest[:eq]
|
||||
}
|
||||
return rest, false, rest != ""
|
||||
case strings.HasPrefix(errMsg, shortPrefix):
|
||||
rest := errMsg[len(shortPrefix):]
|
||||
end := strings.IndexByte(rest, '\'')
|
||||
if end <= 0 {
|
||||
return "", false, false
|
||||
}
|
||||
return rest[:end], true, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// rawUnknownToken re-attaches the leading dash(es) to a bare token so the
|
||||
// JSON envelope echoes the user-visible spelling.
|
||||
func rawUnknownToken(token string, isShorthand bool) string {
|
||||
if isShorthand {
|
||||
return "-" + token
|
||||
}
|
||||
return "--" + token
|
||||
}
|
||||
|
||||
// collectFlags snapshots the merged local + persistent + inherited flag
|
||||
// set of cmd. The hidden bit is preserved on each entry; the suggest
|
||||
// helpers apply the actual filter so the slice stays reusable.
|
||||
func collectFlags(cmd *cobra.Command) []flagName {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
var out []flagName
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// suggest produces top-N long-flag candidates for an unknown token, using
|
||||
// bidirectional prefix matching first and Levenshtein distance for the
|
||||
// remainder. Hidden flags and empty long names are skipped. Results are
|
||||
// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates.
|
||||
func suggest(unknown string, names []flagName) []Candidate {
|
||||
if unknown == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
threshold := levThreshold(unknown)
|
||||
out := make([]Candidate, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
|
||||
// Priority 1: bidirectional prefix match.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
seen[n.long] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Priority 2: Levenshtein distance, skipping already-matched names.
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n.long]; ok {
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(unknown, n.long); d <= threshold {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Distance != out[j].Distance {
|
||||
return out[i].Distance < out[j].Distance
|
||||
}
|
||||
return out[i].Flag < out[j].Flag
|
||||
})
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestShorthand produces candidates for an unknown single-character
|
||||
// shorthand. It first looks for exact f.Shorthand matches; if there are
|
||||
// none, it falls back to long names that begin with the same character.
|
||||
// Levenshtein is deliberately not used here since single-char edit
|
||||
// distance would match almost every flag.
|
||||
func suggestShorthand(c string, names []flagName) []Candidate {
|
||||
if c == "" || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Candidate, 0)
|
||||
for _, n := range names {
|
||||
if n.hidden {
|
||||
continue
|
||||
}
|
||||
if n.short == c {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
for _, n := range names {
|
||||
if n.hidden || n.long == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(n.long, c) {
|
||||
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag })
|
||||
if len(out) > maxCandidates {
|
||||
out = out[:maxCandidates]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildHint returns a one-line hint suitable for a typed error's Hint field.
|
||||
// When at least one candidate exists, the top hit is named; otherwise
|
||||
// the user is directed to --help.
|
||||
func buildHint(c *cobra.Command, matches []Candidate) string {
|
||||
if len(matches) == 0 {
|
||||
return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath())
|
||||
}
|
||||
return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag)
|
||||
}
|
||||
|
||||
// levThreshold returns the maximum acceptable Levenshtein distance for a
|
||||
// token of the given length, clamped to [1, 4].
|
||||
func levThreshold(s string) int {
|
||||
t := len(s)/3 + 1
|
||||
if t < 1 {
|
||||
return 1
|
||||
}
|
||||
if t > 4 {
|
||||
return 4
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// levenshtein computes the standard Levenshtein edit distance between
|
||||
// two ASCII strings using a 2-row dynamic-programming table.
|
||||
func levenshtein(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
353
shortcuts/mail/flag_suggest_test.go
Normal file
353
shortcuts/mail/flag_suggest_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// --- suggest (long-flag) ---
|
||||
|
||||
func TestSuggest_Prefix(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggest("tos", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "tos" has --to as a prefix → bidirectional prefix hit, Distance=0.
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, 0, got[0].Distance)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggest_Levenshtein(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "subject"},
|
||||
{long: "body"},
|
||||
{long: "to"},
|
||||
}
|
||||
// Distance 1 from "subject".
|
||||
got := suggest("subjec", names)
|
||||
require.NotEmpty(t, got)
|
||||
// "subjec" is prefix of "subject" → bidirectional prefix.
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
|
||||
// True edit-distance: "subjeect" is not a prefix either way of "subject".
|
||||
got = suggest("subjeect", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--subject", got[0].Flag)
|
||||
assert.Equal(t, "edit_distance", got[0].Reason)
|
||||
assert.GreaterOrEqual(t, got[0].Distance, 1)
|
||||
}
|
||||
|
||||
func TestSuggest_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "internal-debug", hidden: true},
|
||||
{long: "interactive"},
|
||||
}
|
||||
got := suggest("internal", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_TopNAndStableSort(t *testing.T) {
|
||||
// 6 names all within threshold and at the same distance (1) from the
|
||||
// unknown token so that the lexicographic tiebreak and maxCandidates
|
||||
// cap are both exercised. (Earlier the names were 3-distance from
|
||||
// "zzz" which is above the threshold of 2 — suggest returned empty
|
||||
// and the assertions trivially passed.)
|
||||
names := []flagName{
|
||||
{long: "aaab"},
|
||||
{long: "aaac"},
|
||||
{long: "aaad"},
|
||||
{long: "aaae"},
|
||||
{long: "aaaf"},
|
||||
{long: "aaag"},
|
||||
}
|
||||
got := suggest("aaaa", names)
|
||||
require.Len(t, got, maxCandidates, "must cap at maxCandidates")
|
||||
// All distances equal → lex ordering by Flag asc, top 5 alphabetically.
|
||||
wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"}
|
||||
gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag}
|
||||
assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc")
|
||||
}
|
||||
|
||||
// --- suggestShorthand ---
|
||||
|
||||
func TestSuggestShorthand_Exact(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "to", short: "t"},
|
||||
{long: "cc", short: "c"},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("t", names)
|
||||
require.NotEmpty(t, got)
|
||||
assert.Equal(t, "--to", got[0].Flag)
|
||||
assert.Equal(t, "t", got[0].Shorthand)
|
||||
assert.Equal(t, "prefix", got[0].Reason)
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_PrefixFallback(t *testing.T) {
|
||||
// No short matches "x"; fall back to long names starting with "x".
|
||||
names := []flagName{
|
||||
{long: "xargs"},
|
||||
{long: "xterm"},
|
||||
{long: "yargs"},
|
||||
}
|
||||
got := suggestShorthand("x", names)
|
||||
require.NotEmpty(t, got)
|
||||
flags := make([]string, 0, len(got))
|
||||
for _, c := range got {
|
||||
flags = append(flags, c.Flag)
|
||||
}
|
||||
assert.Contains(t, flags, "--xargs")
|
||||
assert.Contains(t, flags, "--xterm")
|
||||
assert.NotContains(t, flags, "--yargs")
|
||||
}
|
||||
|
||||
// --- parseUnknownToken ---
|
||||
|
||||
func TestParseUnknownToken_Long(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown flag: --tos")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "tos", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "bogus", tok, "must strip =value tail")
|
||||
|
||||
tok, _, ok = parseUnknownToken("unknown flag: --bogus value")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "bogus", tok, "must strip whitespace tail")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_Shorthand(t *testing.T) {
|
||||
tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "X", tok)
|
||||
|
||||
tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, isShort)
|
||||
assert.Equal(t, "q", tok)
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_NotMatch(t *testing.T) {
|
||||
cases := []string{
|
||||
`required flag(s) "to" not set`,
|
||||
"some unrelated error",
|
||||
"",
|
||||
"unknown command \"foo\" for \"mail\"",
|
||||
}
|
||||
for _, in := range cases {
|
||||
tok, isShort, ok := parseUnknownToken(in)
|
||||
assert.False(t, ok, "input %q must not match", in)
|
||||
assert.False(t, isShort)
|
||||
assert.Equal(t, "", tok)
|
||||
}
|
||||
}
|
||||
|
||||
// --- flagSuggestErrorFunc ---
|
||||
|
||||
// newFakeMailCmd builds a cobra command tree resembling the mail parent
|
||||
// with a handful of flags exercised by the hook tests.
|
||||
func newFakeMailCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "mail"}
|
||||
c.Flags().String("to", "", "recipients")
|
||||
c.Flags().String("cc", "", "cc recipients")
|
||||
c.Flags().String("subject", "", "subject")
|
||||
c.Flags().StringP("body", "b", "", "body")
|
||||
return c
|
||||
}
|
||||
|
||||
func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var validationErr *errs.ValidationError
|
||||
require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
require.True(t, ok, "expected typed Problem")
|
||||
assert.Equal(t, errs.CategoryValidation, p.Category)
|
||||
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func paramReason(params []errs.InvalidParam, name string) (string, bool) {
|
||||
for _, p := range params {
|
||||
if p.Name == name {
|
||||
return p.Reason, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "unknown flag: --tos", validationErr.Message)
|
||||
assert.Equal(t, "--tos", validationErr.Param)
|
||||
assert.Contains(t, validationErr.Hint, "--to")
|
||||
|
||||
reason, ok := paramReason(validationErr.Params, "--tos")
|
||||
require.True(t, ok, "unknown flag should be included in params")
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
reason, ok = paramReason(validationErr.Params, "--to")
|
||||
require.True(t, ok, "expected --to in candidate params")
|
||||
assert.Contains(t, reason, "candidate (prefix")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
in := errors.New(`required flag(s) "to" not set`)
|
||||
got := flagSuggestErrorFunc(cmd, in)
|
||||
// Identity passthrough: same error pointer.
|
||||
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errs.CategoryValidation, p.Category)
|
||||
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
|
||||
}
|
||||
|
||||
// --- edge-case coverage ---
|
||||
|
||||
func TestInstallOnMail_NilIsNoop(t *testing.T) {
|
||||
// Must not panic; the nil-guard is the contract.
|
||||
InstallOnMail(nil)
|
||||
}
|
||||
|
||||
func TestInstallOnMail_InstallsHook(t *testing.T) {
|
||||
c := newFakeMailCmd()
|
||||
InstallOnMail(c)
|
||||
require.NotNil(t, c.FlagErrorFunc())
|
||||
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "--tos", validationErr.Param)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
assert.NoError(t, flagSuggestErrorFunc(cmd, nil))
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing")
|
||||
reason, ok := paramReason(validationErr.Params, "--tos")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Equal(t, "-b", validationErr.Param)
|
||||
reason, ok := paramReason(validationErr.Params, "-b")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unknown flag", reason)
|
||||
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
|
||||
reason, ok = paramReason(validationErr.Params, "--body")
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, reason, "candidate (prefix")
|
||||
assert.Contains(t, reason, "shorthand=-b")
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) {
|
||||
// A cobra command with no flags forces collectFlags → empty names →
|
||||
// suggest → nil. The typed validation error must still expose the unknown
|
||||
// flag in Params so downstream parsers have a stable structured field.
|
||||
bare := &cobra.Command{Use: "mail"}
|
||||
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.NotNil(t, validationErr.Params)
|
||||
require.Len(t, validationErr.Params, 1)
|
||||
assert.Equal(t, "--bogus", validationErr.Params[0].Name)
|
||||
assert.Equal(t, "unknown flag", validationErr.Params[0].Reason)
|
||||
}
|
||||
|
||||
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
|
||||
cmd := newFakeMailCmd()
|
||||
// Token with no plausible neighbor in {to, cc, subject, body}.
|
||||
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
|
||||
validationErr := requireFlagSuggestValidation(t, got)
|
||||
assert.Contains(t, validationErr.Hint, "--help")
|
||||
}
|
||||
|
||||
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
|
||||
// Long form with empty token after the prefix.
|
||||
_, _, ok := parseUnknownToken("unknown flag: --")
|
||||
assert.False(t, ok, "empty long token must not match")
|
||||
|
||||
// Shorthand with no closing quote.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: 'q")
|
||||
assert.False(t, ok, "shorthand without closing quote must not match")
|
||||
|
||||
// Shorthand with empty char between quotes.
|
||||
_, _, ok = parseUnknownToken("unknown shorthand flag: '' in -")
|
||||
assert.False(t, ok, "empty shorthand token must not match")
|
||||
}
|
||||
|
||||
func TestSuggest_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggest("", []flagName{{long: "to"}}))
|
||||
assert.Nil(t, suggest("foo", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_EmptyInputs(t *testing.T) {
|
||||
assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}}))
|
||||
assert.Nil(t, suggestShorthand("x", nil))
|
||||
}
|
||||
|
||||
func TestSuggestShorthand_HiddenSkipped(t *testing.T) {
|
||||
names := []flagName{
|
||||
{long: "secret", short: "s", hidden: true},
|
||||
{long: "subject", short: "s"},
|
||||
}
|
||||
got := suggestShorthand("s", names)
|
||||
for _, c := range got {
|
||||
assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFlags_NilSafe(t *testing.T) {
|
||||
assert.Nil(t, collectFlags(nil))
|
||||
}
|
||||
|
||||
func TestLevThreshold_Clamp(t *testing.T) {
|
||||
// len 0 → 0/3+1 = 1
|
||||
assert.Equal(t, 1, levThreshold(""))
|
||||
// len 3 → 2
|
||||
assert.Equal(t, 2, levThreshold("abc"))
|
||||
// Long token caps at 4.
|
||||
assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa"))
|
||||
}
|
||||
|
||||
func TestLevenshtein_EmptyAndIdentical(t *testing.T) {
|
||||
assert.Equal(t, 0, levenshtein("", ""))
|
||||
assert.Equal(t, 3, levenshtein("", "abc"))
|
||||
assert.Equal(t, 3, levenshtein("abc", ""))
|
||||
assert.Equal(t, 0, levenshtein("abc", "abc"))
|
||||
assert.Equal(t, 1, levenshtein("abc", "abd"))
|
||||
}
|
||||
@@ -103,8 +103,8 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `unsupported output format "bogus"`) {
|
||||
t.Fatalf("message = %q, want unsupported format message", problem.Message)
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
|
||||
74
shortcuts/minutes/minutes_apply_permission.go
Normal file
74
shortcuts/minutes/minutes_apply_permission.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MinutesApplyPermission applies for view or edit permission on a minute.
|
||||
var MinutesApplyPermission = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+apply-permission",
|
||||
Description: "Apply for view or edit permission on a minute",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:permission:apply"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-token", Desc: "minute token", Required: true},
|
||||
{Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
if minuteToken == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
|
||||
}
|
||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
|
||||
}
|
||||
perm := strings.TrimSpace(runtime.Str("perm"))
|
||||
if perm == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm is required").WithParam("--perm")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(minutesApplyPermissionPath(minuteToken)).
|
||||
Body(minutesApplyPermissionBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
perm := strings.TrimSpace(runtime.Str("perm"))
|
||||
|
||||
_, err := runtime.CallAPITyped(http.MethodPost, minutesApplyPermissionPath(minuteToken), nil, map[string]interface{}{"perm": perm})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"perm": perm,
|
||||
}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func minutesApplyPermissionPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/permissions/apply", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func minutesApplyPermissionBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"perm": strings.TrimSpace(runtime.Str("perm")),
|
||||
}
|
||||
}
|
||||
192
shortcuts/minutes/minutes_apply_permission_test.go
Normal file
192
shortcuts/minutes/minutes_apply_permission_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const minutesApplyPermissionTestToken = "obcnexampleminute"
|
||||
|
||||
func TestMinutesApplyPermission_Validate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing minute token",
|
||||
args: []string{"+apply-permission", "--perm", "view", "--as", "user"},
|
||||
wantErr: "required flag(s) \"minute-token\" not set",
|
||||
},
|
||||
{
|
||||
name: "missing perm",
|
||||
args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--as", "user"},
|
||||
wantErr: "required flag(s) \"perm\" not set",
|
||||
},
|
||||
{
|
||||
name: "invalid perm",
|
||||
args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"},
|
||||
wantErr: "allowed: view, edit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesApplyPermission.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_ValidateTypedMinuteToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesApplyPermission.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+apply-permission", "--minute-token", "..", "--perm", "view", "--as", "user"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype=%q", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--minute-token" {
|
||||
t.Errorf("param=%q", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_ValidateTypedPerm(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesApplyPermission.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype=%q", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--perm" {
|
||||
t.Errorf("param=%q", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_DryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--minute-token", minutesApplyPermissionTestToken,
|
||||
"--perm", "view",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") {
|
||||
t.Errorf("expected POST method, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesApplyPermissionTestToken+"/permissions/apply") {
|
||||
t.Errorf("expected apply-permission endpoint, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) {
|
||||
t.Errorf("expected perm body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesApplyPermission_Execute(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesApplyPermissionTestToken + "/permissions/apply",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, MinutesApplyPermission, []string{
|
||||
"+apply-permission",
|
||||
"--minute-token", minutesApplyPermissionTestToken,
|
||||
"--perm", "edit",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
Perm string `json:"perm"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("unmarshal request body: %v", err)
|
||||
}
|
||||
if requestBody.Perm != "edit" {
|
||||
t.Errorf("request perm = %q, want edit", requestBody.Perm)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Perm string `json:"perm"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.MinuteToken != minutesApplyPermissionTestToken {
|
||||
t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesApplyPermissionTestToken)
|
||||
}
|
||||
if envelope.Data.Perm != "edit" {
|
||||
t.Errorf("data.perm = %q, want edit", envelope.Data.Perm)
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
||||
if isMinutesDetailProcessingError(err) {
|
||||
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the user before running: minutes +apply-permission --minute-token %s --perm view", minuteToken, minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) er
|
||||
switch p.Code {
|
||||
case minutesSpeakerReplaceNoEditPermission:
|
||||
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
|
||||
case minutesSpeakerReplaceSpeakerNotFoundCode:
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker)
|
||||
|
||||
@@ -361,7 +361,7 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
|
||||
if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||
if !strings.Contains(p.Hint, "+apply-permission") {
|
||||
t.Errorf("hint should mention apply-permission, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,8 +400,8 @@ func TestMinutesTodo_NoEditPermission(t *testing.T) {
|
||||
if !strings.Contains(p.Message, minutesSummaryTodoTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||
if !strings.Contains(p.Hint, "+apply-permission") {
|
||||
t.Errorf("hint should mention apply-permission, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,6 @@ func minutesTodoError(err error, minuteToken string) error {
|
||||
}
|
||||
p.Subtype = errs.SubtypePermissionDenied
|
||||
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update todos.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -79,6 +79,6 @@ func minutesUpdateError(err error, minuteToken string) error {
|
||||
return err
|
||||
}
|
||||
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) {
|
||||
if !strings.Contains(p.Message, minutesUpdateTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||
if !strings.Contains(p.Hint, "+apply-permission") {
|
||||
t.Errorf("hint should mention apply-permission, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func minutesWordReplaceError(err error, minuteToken string) error {
|
||||
case minutesWordReplaceNoEditPermission:
|
||||
p.Subtype = errs.SubtypePermissionDenied
|
||||
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace transcript words.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
|
||||
case minutesWordReplaceOthersEditing:
|
||||
p.Subtype = errs.SubtypeConflict
|
||||
p.Message = fmt.Sprintf("Minute %q transcript is being edited by someone else.", minuteToken)
|
||||
|
||||
@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
MinutesUpdate,
|
||||
MinutesApplyPermission,
|
||||
MinutesSummary,
|
||||
MinutesTodo,
|
||||
MinutesSpeakerReplace,
|
||||
|
||||
@@ -157,6 +157,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "apps" {
|
||||
apps.InstallOnApps(svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -398,6 +400,68 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end
|
||||
// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures
|
||||
// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in
|
||||
// RegisterShortcutsWithContext is actually exercised, so a future refactor
|
||||
// that drops the branch (or breaks the import) will fail this test rather
|
||||
// than silently regressing the structured-error contract.
|
||||
func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
mailCmd, _, err := program.Find([]string{"mail"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mail command: %v", err)
|
||||
}
|
||||
if mailCmd == nil || mailCmd.Name() != "mail" {
|
||||
t.Fatalf("mail command not mounted: %#v", mailCmd)
|
||||
}
|
||||
|
||||
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
|
||||
// invoking it on the mail parent (or any of its children) must yield
|
||||
// a typed validation problem for the unknown flag.
|
||||
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(got, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", got, got)
|
||||
}
|
||||
if validationErr.Param != "--bogus" {
|
||||
t.Fatalf("expected Param=--bogus, got %q", validationErr.Param)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", got, got)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %s/%s", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the
|
||||
// install is scoped: a non-mail service must keep the default cobra
|
||||
// pass-through behaviour, otherwise an accidental fall-through in
|
||||
// register.go would silently change every domain's error envelope.
|
||||
func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
t.Fatalf("find base command: %v", err)
|
||||
}
|
||||
in := errors.New("unknown flag: --bogus")
|
||||
got := baseCmd.FlagErrorFunc()(baseCmd, in)
|
||||
// Default cobra hook is identity — anything else means the mail hook
|
||||
// (which wraps into a typed *errs.ValidationError) leaked across domains.
|
||||
if errs.IsTyped(got) {
|
||||
t.Fatalf("base service unexpectedly produced a typed error: %#v", got)
|
||||
}
|
||||
if got != in {
|
||||
t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
output := os.Getenv("SHORTCUTS_OUTPUT")
|
||||
if output == "" {
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ─── sheets flag ergonomics ─────────────────────────────────────────────
|
||||
@@ -24,17 +28,106 @@ import (
|
||||
// needs no change at all and no other domain's behavior shifts.
|
||||
|
||||
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
|
||||
// the command gets enum-value normalization. Unknown-flag handling is owned by
|
||||
// cmd/root.go's single FlagErrorFunc, which retains the sheets inline flag list.
|
||||
// the command gets the sheets-specific unknown-flag error (valid flags
|
||||
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
|
||||
// typos suggested).
|
||||
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
// set: hallucinated sheets flags are usually semantic guesses (--cols for
|
||||
// --range) that edit distance can't rank, and a --help round trip costs an
|
||||
// agent a full extra call. One line here lets it re-issue the command
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = "valid flags: " + list
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// unknownFlagFromParseError extracts the offending long-flag name from
|
||||
// cobra's flag-parse error text ("unknown flag: --query" → "query").
|
||||
// Returns ok=false for anything else (missing argument, invalid value,
|
||||
// unknown shorthand) so those stay structured but generic. Mirrors the
|
||||
// root-level parser in cmd; the prefix contract is cobra's English wording.
|
||||
func unknownFlagFromParseError(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// inlineFlagListLimit caps how many flag names ride inline on an
|
||||
// unknown-flag hint. Sheets shortcuts stay well under it.
|
||||
const inlineFlagListLimit = 25
|
||||
|
||||
// inlineFlagList renders valid flag names as one comma-separated line for
|
||||
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
|
||||
// there is nothing to list.
|
||||
func inlineFlagList(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := names
|
||||
var suffix string
|
||||
if len(names) > inlineFlagListLimit {
|
||||
shown = names[:inlineFlagListLimit]
|
||||
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, n := range shown {
|
||||
parts[i] = "--" + n
|
||||
}
|
||||
return strings.Join(parts, ", ") + suffix
|
||||
}
|
||||
|
||||
// ─── enum vocabulary normalization ──────────────────────────────────────
|
||||
|
||||
// enumAliases maps habitual values agents import from CSS / Excel / Google
|
||||
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -13,6 +14,122 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagFromParseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --cols", "cols", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagFromParseError(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
|
||||
// override of the root unknown-flag error: --cols is a semantic guess for
|
||||
// --range that edit distance can't rank, so the hint must inline the full
|
||||
// valid-flag list instead of deferring to a --help round trip.
|
||||
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Int("width", 0, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
|
||||
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "--help") {
|
||||
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
|
||||
}
|
||||
for _, want := range []string{"--range", "--width"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
|
||||
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
|
||||
// the sheets override, with the valid-flag list appended.
|
||||
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := inlineFlagList(nil); got != "" {
|
||||
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
|
||||
}
|
||||
names := make([]string, inlineFlagListLimit+5)
|
||||
for i := range names {
|
||||
names[i] = fmt.Sprintf("flag-%02d", i)
|
||||
}
|
||||
got := inlineFlagList(names)
|
||||
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
|
||||
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, names[inlineFlagListLimit]) {
|
||||
t.Errorf("list should stop at the limit, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalEnumValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -161,4 +278,19 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
const (
|
||||
defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
maxSlidesPerScreenshot = 10
|
||||
)
|
||||
|
||||
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
@@ -32,7 +35,7 @@ var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
var SlidesScreenshot = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
@@ -42,8 +45,8 @@ var SlidesScreenshot = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
{Name: "output-name", Desc: "file name stem for --content render output"},
|
||||
@@ -70,12 +73,17 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSlideScreenshotSelector(runtime) {
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
|
||||
return err
|
||||
@@ -98,6 +106,9 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
@@ -145,6 +156,9 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
@@ -267,8 +281,12 @@ func normalizeSlideNumbers(values []int) ([]int, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
|
||||
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
|
||||
func validateSlidesScreenshotSelectorLimit(count int) error {
|
||||
if count > maxSlidesPerScreenshot {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "too many slide selectors: got %d, maximum is %d", count, maxSlidesPerScreenshot).
|
||||
WithHint("request at most 10 pages at a time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
|
||||
|
||||
@@ -271,6 +271,37 @@ func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRejectsMoreThanTenSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "1",
|
||||
"--slide-number", "2",
|
||||
"--slide-number", "3",
|
||||
"--slide-number", "4",
|
||||
"--slide-number", "5",
|
||||
"--slide-number", "6",
|
||||
"--slide-number", "7",
|
||||
"--slide-number", "8",
|
||||
"--slide-number", "9",
|
||||
"--slide-number", "10",
|
||||
"--slide-number", "11",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %v, want typed validation error", err)
|
||||
}
|
||||
if problem.Hint != "request at most 10 pages at a time" {
|
||||
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
@@ -15,12 +15,14 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
// SlidesXMLGet fetches the full XML presentation content. When --output is
|
||||
// provided it writes to a local file; otherwise it returns the XML in the
|
||||
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
|
||||
// and use --raw for direct XML stdout.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Description: "Fetch presentation XML or one slide XML",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
@@ -28,7 +30,10 @@ var SlidesXMLGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
@@ -37,19 +42,33 @@ var SlidesXMLGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revisionID := runtime.Int("revision-id"); revisionID < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
if err := validateSlidesXMLGetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath != "" {
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
if runtime.Bool("raw") {
|
||||
if outputPath != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --output").WithParam("--raw")
|
||||
}
|
||||
if runtime.JqExpr != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --jq").WithParam("--raw")
|
||||
}
|
||||
if runtime.Changed("format") && runtime.Format != "json" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --format %s", runtime.Format).WithParam("--raw")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -62,25 +81,39 @@ var SlidesXMLGet = common.Shortcut{
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch presentation XML").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Fetch full presentation XML and save it to a local file")
|
||||
dry.Desc("Fetch presentation XML")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
slideID := strings.TrimSpace(runtime.Str("slide-id"))
|
||||
slideNumber := runtime.Int("slide-number")
|
||||
if slideID != "" {
|
||||
params["slide_id"] = slideID
|
||||
}
|
||||
if slideNumber > 0 {
|
||||
params["slide_number"] = slideNumber
|
||||
}
|
||||
if slideID == "" && slideNumber == 0 && runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
path := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID))
|
||||
if slideID != "" || slideNumber > 0 {
|
||||
path += "/slide"
|
||||
}
|
||||
dry.GET(path).Params(params)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
|
||||
}
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
@@ -92,53 +125,167 @@ var SlidesXMLGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateSlidesXMLGetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
|
||||
slideID := strings.TrimSpace(runtime.Str("slide-id"))
|
||||
slideNumber := runtime.Int("slide-number")
|
||||
content, out, err := fetchSlidesXMLGetContent(runtime, presentationID, params, slideID, slideNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
return outputSlidesXMLGetContent(runtime, content, outputPath, out)
|
||||
},
|
||||
}
|
||||
|
||||
func validateSlidesXMLGetSelector(runtime *common.RuntimeContext) error {
|
||||
slideID := strings.TrimSpace(runtime.Str("slide-id"))
|
||||
slideNumber := runtime.Int("slide-number")
|
||||
if runtime.Changed("slide-id") && slideID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
|
||||
}
|
||||
if slideID != "" && slideNumber > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be used with --slide-number").WithParam("--slide-id")
|
||||
}
|
||||
if runtime.Changed("slide-number") && slideNumber < 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-number must be a positive integer").WithParam("--slide-number")
|
||||
}
|
||||
if (slideID != "" || slideNumber > 0) && runtime.Bool("remove-attr-id") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--remove-attr-id is only supported when fetching full presentation XML").WithParam("--remove-attr-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID string, params map[string]interface{}, slideID string, slideNumber int) (string, map[string]interface{}, error) {
|
||||
if slideID != "" || slideNumber > 0 {
|
||||
if slideID != "" {
|
||||
params["slide_id"] = slideID
|
||||
}
|
||||
if slideNumber > 0 {
|
||||
params["slide_number"] = slideNumber
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
slide := common.GetMap(data, "slide")
|
||||
content := common.GetString(slide, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
return "", nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty slide.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
slideOut := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
actualSlideID := common.GetString(slide, "slide_id")
|
||||
if actualSlideID == "" {
|
||||
actualSlideID = slideID
|
||||
}
|
||||
if actualSlideID != "" {
|
||||
slideOut["slide_id"] = actualSlideID
|
||||
}
|
||||
if slideNumber > 0 {
|
||||
slideOut["slide_number"] = slideNumber
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
"scope": "slide",
|
||||
"slide": slideOut,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
if actualSlideID != "" {
|
||||
out["slide_id"] = actualSlideID
|
||||
}
|
||||
if slideNumber > 0 {
|
||||
out["slide_number"] = slideNumber
|
||||
}
|
||||
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
slideOut["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
return content, out, nil
|
||||
}
|
||||
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return "", nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
presentationOut := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"scope": "presentation",
|
||||
"xml_presentation": presentationOut,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
presentationOut["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
return content, out, nil
|
||||
}
|
||||
|
||||
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
|
||||
if outputPath == "" {
|
||||
if !runtime.Bool("raw") {
|
||||
runtime.OutFormatRaw(out, nil, nil)
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
fileOut := map[string]interface{}{
|
||||
"xml_presentation_id": out["xml_presentation_id"],
|
||||
"scope": out["scope"],
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
|
||||
if value, ok := out[key]; ok {
|
||||
fileOut[key] = value
|
||||
}
|
||||
}
|
||||
runtime.Out(fileOut, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -91,6 +92,226 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation := data["xml_presentation"].(map[string]interface{})
|
||||
if got := presentation["content"]; got != xml {
|
||||
t.Fatalf("content = %q, want %q", got, xml)
|
||||
}
|
||||
if got := data["xml_presentation_id"]; got != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "content_saved") {
|
||||
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--jq", ".data.xml_presentation.content",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != xml {
|
||||
t.Fatalf("stdout = %q, want XML content %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--raw",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != xml {
|
||||
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"slide_id": "slide_1",
|
||||
"content": xml,
|
||||
},
|
||||
"revision_id": 8,
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output", "slide_1.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := capturedQuery.Get("slide_id"); got != "slide_1" {
|
||||
t.Fatalf("slide_id query = %q, want slide_1", got)
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "-1" {
|
||||
t.Fatalf("revision_id query = %q, want -1", got)
|
||||
}
|
||||
path := filepath.Join(dir, "slide_1.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved slide XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["scope"] != "slide" {
|
||||
t.Fatalf("scope = %v, want slide", data["scope"])
|
||||
}
|
||||
if data["slide_id"] != "slide_1" {
|
||||
t.Fatalf("slide_id = %v, want slide_1", data["slide_id"])
|
||||
}
|
||||
if data["content_saved"] != true {
|
||||
t.Fatalf("content_saved = %v, want true", data["content_saved"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"slide_id": "slide_2",
|
||||
"content": xml,
|
||||
},
|
||||
"revision_id": 9,
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := capturedQuery.Get("slide_number"); got != "2" {
|
||||
t.Fatalf("slide_number query = %q, want 2", got)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["scope"] != "slide" {
|
||||
t.Fatalf("scope = %v, want slide", data["scope"])
|
||||
}
|
||||
if data["slide_number"] != float64(2) {
|
||||
t.Fatalf("slide_number = %v, want 2", data["slide_number"])
|
||||
}
|
||||
slide := data["slide"].(map[string]interface{})
|
||||
if slide["content"] != xml {
|
||||
t.Fatalf("content = %q, want %q", slide["content"], xml)
|
||||
}
|
||||
if slide["slide_id"] != "slide_2" {
|
||||
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
@@ -163,3 +384,134 @@ func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
t.Fatalf("param = %q, want --output", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsRevisionIDBelowMinusOneBeforeAPICall(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("dry-run=%t", dryRun), func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
args := []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--revision-id", "-2",
|
||||
"--as", "user",
|
||||
}
|
||||
if dryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, args)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid revision-id error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--revision-id" {
|
||||
t.Fatalf("param = %q, want --revision-id", validationErr.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsConflictingSlideSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--slide-number", "1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected selector conflict error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--slide-id" {
|
||||
t.Fatalf("param = %q, want --slide-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsEmptySlideID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", " ",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty slide-id error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--slide-id" {
|
||||
t.Fatalf("param = %q, want --slide-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "1",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected remove-attr-id validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--remove-attr-id" {
|
||||
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
50
shortcuts/vc/helpers.go
Normal file
50
shortcuts/vc/helpers.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
meetingQueryUserScope = "vc:meeting.meetingevent:read"
|
||||
meetingQueryBotScope = "vc:meeting.bot.join:write"
|
||||
)
|
||||
|
||||
func normalizeMeetingQueryPermissionError(runtime *common.RuntimeContext, err error) error {
|
||||
if runtime == nil {
|
||||
return err
|
||||
}
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) || permissionErr == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case runtime.As() == core.AsUser && permissionErr.Code == output.LarkErrUserScopeInsufficient:
|
||||
permissionErr.Message = "access denied for user identity; recommended scope: " + meetingQueryUserScope
|
||||
permissionErr.WithHint("for user identity, run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", meetingQueryUserScope)
|
||||
permissionErr.WithMissingScopes(meetingQueryUserScope)
|
||||
return err
|
||||
case runtime.As() == core.AsBot && permissionErr.Code == output.LarkErrAppScopeNotEnabled:
|
||||
permissionErr.Message = "access denied for bot identity; recommended scope: " + meetingQueryBotScope
|
||||
permissionErr.WithHint("ask the app developer to enable scope %s", meetingQueryBotScope)
|
||||
permissionErr.WithMissingScopes(meetingQueryBotScope)
|
||||
if runtime.Config != nil {
|
||||
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, meetingQueryBotScope)
|
||||
if consoleURL != "" {
|
||||
permissionErr.WithConsoleURL(consoleURL)
|
||||
}
|
||||
}
|
||||
return err
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
207
shortcuts/vc/helpers_test.go
Normal file
207
shortcuts/vc/helpers_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func bareMeetingQueryRuntime(as core.Identity) *common.RuntimeContext {
|
||||
return common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, defaultConfig(), as)
|
||||
}
|
||||
|
||||
func TestNormalizeMeetingQueryPermissionError_NilRuntimeReturnsOriginalError(t *testing.T) {
|
||||
original := errs.NewPermissionError(errs.SubtypeMissingScope, "permission failure").
|
||||
WithCode(output.LarkErrUserScopeInsufficient)
|
||||
if got := normalizeMeetingQueryPermissionError(nil, original); got != original {
|
||||
t.Fatalf("got %v, want original error %v", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMeetingQueryPermissionError_TypedNilReturnsOriginalError(t *testing.T) {
|
||||
var permissionErr *errs.PermissionError
|
||||
var original error = permissionErr
|
||||
if got := normalizeMeetingQueryPermissionError(bareMeetingQueryRuntime(core.AsUser), original); got != original {
|
||||
t.Fatalf("got %v, want original error %v", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMeetingQueryPermissionError(t *testing.T, err error, identity core.Identity, code int) {
|
||||
t.Helper()
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if pe.Category != errs.CategoryAuthorization {
|
||||
t.Fatalf("Category = %q, want %q", pe.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypeMissingScope && pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Fatalf("Subtype = %q, want a missing-scope subtype", pe.Subtype)
|
||||
}
|
||||
if pe.Identity != string(identity) {
|
||||
t.Fatalf("Identity = %q, want %q", pe.Identity, identity)
|
||||
}
|
||||
|
||||
wantScope := meetingQueryUserScope
|
||||
if identity.IsBot() {
|
||||
wantScope = meetingQueryBotScope
|
||||
}
|
||||
if !strings.Contains(pe.Hint, wantScope) {
|
||||
t.Fatalf("Hint = %q, want recommended scope %q", pe.Hint, wantScope)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != wantScope {
|
||||
t.Fatalf("MissingScopes = %v, want only recommended scope %q", pe.MissingScopes, wantScope)
|
||||
}
|
||||
if strings.Contains(pe.Hint, "either compatible scope") {
|
||||
t.Fatalf("Hint = %q, must not repeat the OR-scope explanation from message", pe.Hint)
|
||||
}
|
||||
switch code {
|
||||
case output.LarkErrAppScopeNotEnabled:
|
||||
if strings.Contains(pe.Hint, "auth login") {
|
||||
t.Fatalf("Hint = %q, app-scope error must not recommend user login", pe.Hint)
|
||||
}
|
||||
if !strings.Contains(pe.Hint, "app developer") {
|
||||
t.Fatalf("Hint = %q, want app developer guidance", pe.Hint)
|
||||
}
|
||||
if pe.ConsoleURL == "" {
|
||||
t.Fatal("ConsoleURL is empty, want identity-specific developer-console URL")
|
||||
}
|
||||
if strings.Contains(pe.ConsoleURL, url.QueryEscape(meetingQueryUserScope)) || !strings.Contains(pe.ConsoleURL, url.QueryEscape(meetingQueryBotScope)) {
|
||||
t.Fatalf("ConsoleURL = %q, want only bot scope", pe.ConsoleURL)
|
||||
}
|
||||
case output.LarkErrUserScopeInsufficient:
|
||||
if !strings.Contains(pe.Hint, "auth login --scope") {
|
||||
t.Fatalf("Hint = %q, want auth login guidance", pe.Hint)
|
||||
}
|
||||
if pe.ConsoleURL != "" {
|
||||
t.Fatalf("ConsoleURL = %q, user-scope error must not expose a developer-console URL", pe.ConsoleURL)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMeetingQueryPermissionError_RecommendsScopeForMatchingIdentity(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
identity core.Identity
|
||||
code int
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{name: "user_with_user_scope_error", identity: core.AsUser, code: output.LarkErrUserScopeInsufficient, subtype: errs.SubtypeMissingScope},
|
||||
{name: "bot_with_app_scope_error", identity: core.AsBot, code: output.LarkErrAppScopeNotEnabled, subtype: errs.SubtypeAppScopeNotApplied},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wantScope := meetingQueryUserScope
|
||||
if tc.identity == core.AsBot {
|
||||
wantScope = meetingQueryBotScope
|
||||
}
|
||||
wantMessage := "access denied for " + string(tc.identity) + " identity; recommended scope: " + wantScope
|
||||
original := errs.NewPermissionError(tc.subtype, "upstream permission failure").
|
||||
WithCode(tc.code).
|
||||
WithLogID("log-id").
|
||||
WithRetryable().
|
||||
WithIdentity(string(tc.identity)).
|
||||
WithMissingScopes(meetingQueryUserScope, meetingQueryBotScope).
|
||||
WithRequestedScopes("requested:scope").
|
||||
WithGrantedScopes("granted:scope")
|
||||
if tc.identity == core.AsBot {
|
||||
original.ConsoleURL = "https://example.com/scopes"
|
||||
}
|
||||
original.Troubleshooter = "https://example.com/troubleshoot"
|
||||
|
||||
got := normalizeMeetingQueryPermissionError(bareMeetingQueryRuntime(tc.identity), original)
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(got, &pe) {
|
||||
t.Fatalf("got %T, want *errs.PermissionError", got)
|
||||
}
|
||||
if got != original || pe != original {
|
||||
t.Fatal("normalizer did not return the original permission error")
|
||||
}
|
||||
if pe.Code != tc.code || pe.Subtype != tc.subtype || pe.LogID != "log-id" || !pe.Retryable {
|
||||
t.Fatalf("diagnostics changed: %+v", pe.Problem)
|
||||
}
|
||||
if pe.Troubleshooter != original.Troubleshooter {
|
||||
t.Fatalf("Troubleshooter = %q, want %q", pe.Troubleshooter, original.Troubleshooter)
|
||||
}
|
||||
if pe.Message != wantMessage {
|
||||
t.Fatalf("Message = %q, want %q", pe.Message, wantMessage)
|
||||
}
|
||||
if tc.identity == core.AsBot {
|
||||
consoleURL, err := url.Parse(pe.ConsoleURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ConsoleURL = %q is invalid: %v", pe.ConsoleURL, err)
|
||||
}
|
||||
if consoleURL.Host == "" || consoleURL.Query().Get("clientID") != "test-app" || consoleURL.Query().Get("scopes") != meetingQueryBotScope {
|
||||
t.Fatalf("ConsoleURL = %q, want test-app and only bot scope", pe.ConsoleURL)
|
||||
}
|
||||
} else if pe.ConsoleURL != "" {
|
||||
t.Fatalf("ConsoleURL = %q, user-scope error must not expose a developer-console URL", pe.ConsoleURL)
|
||||
}
|
||||
assertMeetingQueryPermissionError(t, got, tc.identity, tc.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMeetingQueryPermissionError_PassesThroughNonMatchingErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
identity core.Identity
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "user_with_app_scope_error",
|
||||
identity: core.AsUser,
|
||||
err: errs.NewPermissionError(errs.SubtypeAppScopeNotApplied, "app scope error").
|
||||
WithCode(output.LarkErrAppScopeNotEnabled),
|
||||
},
|
||||
{
|
||||
name: "bot_with_user_scope_error",
|
||||
identity: core.AsBot,
|
||||
err: errs.NewPermissionError(errs.SubtypeMissingScope, "user scope error").
|
||||
WithCode(output.LarkErrUserScopeInsufficient),
|
||||
},
|
||||
{
|
||||
name: "auto_with_user_scope_error",
|
||||
identity: core.AsAuto,
|
||||
err: errs.NewPermissionError(errs.SubtypeMissingScope, "auto identity").
|
||||
WithCode(output.LarkErrUserScopeInsufficient),
|
||||
},
|
||||
{
|
||||
name: "bot_not_in_meeting",
|
||||
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "not in meeting").WithCode(10005),
|
||||
},
|
||||
{
|
||||
name: "not_in_gray",
|
||||
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "not in gray").
|
||||
WithCode(20017),
|
||||
},
|
||||
{name: "plain_error", err: errors.New("boom")},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
identity := tc.identity
|
||||
if identity == "" {
|
||||
identity = core.AsBot
|
||||
}
|
||||
if got := normalizeMeetingQueryPermissionError(bareMeetingQueryRuntime(identity), tc.err); got != tc.err {
|
||||
t.Fatalf("got %T %v, want original error %T %v", got, got, tc.err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,13 @@ var VCMeetingEvents = common.Shortcut{
|
||||
Command: "+meeting-events",
|
||||
Description: "List meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
// UAT exposes user-granted scopes, so the framework can preflight the user
|
||||
// recommendation. TAT has no scope metadata; keep the bot recommendation
|
||||
// conditional so it is available to diagnostics without a local preflight.
|
||||
UserScopes: []string{meetingQueryUserScope},
|
||||
ConditionalBotScopes: []string{meetingQueryBotScope},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
|
||||
{Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
|
||||
@@ -101,7 +105,7 @@ var VCMeetingEvents = common.Shortcut{
|
||||
}
|
||||
data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
return normalizeMeetingQueryPermissionError(runtime, err)
|
||||
}
|
||||
events = compactMeetingEvents(events)
|
||||
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -418,6 +420,21 @@ func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_UsesUserScopePreflightAndBotScopeHint(t *testing.T) {
|
||||
if got := VCMeetingEvents.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{meetingQueryUserScope}) {
|
||||
t.Fatalf("ScopesForIdentity(user) = %v, want %v", got, []string{meetingQueryUserScope})
|
||||
}
|
||||
if got := VCMeetingEvents.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("ScopesForIdentity(bot) = %v, want no bot preflight scopes", got)
|
||||
}
|
||||
if got := VCMeetingEvents.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{meetingQueryUserScope}) {
|
||||
t.Fatalf("DeclaredScopesForIdentity(user) = %v, want %v", got, []string{meetingQueryUserScope})
|
||||
}
|
||||
if got := VCMeetingEvents.DeclaredScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{meetingQueryBotScope}) {
|
||||
t.Fatalf("DeclaredScopesForIdentity(bot) = %v, want %v", got, []string{meetingQueryBotScope})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
@@ -637,6 +654,63 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Execute_NormalizesMeetingScopeError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: vcMeetingEventsAPIPath,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": output.LarkErrAppScopeNotEnabled,
|
||||
"msg": "access denied",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": meetingQueryUserScope},
|
||||
map[string]interface{}{"subject": meetingQueryBotScope},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.PermissionError", err, err)
|
||||
}
|
||||
if permissionErr.Code != output.LarkErrAppScopeNotEnabled {
|
||||
t.Fatalf("Code = %d, want %d", permissionErr.Code, output.LarkErrAppScopeNotEnabled)
|
||||
}
|
||||
if permissionErr.Identity != "bot" {
|
||||
t.Fatalf("Identity = %q, want bot", permissionErr.Identity)
|
||||
}
|
||||
wantMessage := "access denied for bot identity; recommended scope: " + meetingQueryBotScope
|
||||
if permissionErr.Message != wantMessage {
|
||||
t.Fatalf("Message = %q, want %q", permissionErr.Message, wantMessage)
|
||||
}
|
||||
if !strings.Contains(permissionErr.Hint, meetingQueryBotScope) {
|
||||
t.Fatalf("Hint = %q, want bot scope %q", permissionErr.Hint, meetingQueryBotScope)
|
||||
}
|
||||
if len(permissionErr.MissingScopes) != 1 || permissionErr.MissingScopes[0] != meetingQueryBotScope {
|
||||
t.Fatalf("MissingScopes = %v, want only bot scope %q", permissionErr.MissingScopes, meetingQueryBotScope)
|
||||
}
|
||||
if permissionErr.ConsoleURL == "" {
|
||||
t.Fatal("ConsoleURL is empty, want identity-specific developer-console URL")
|
||||
}
|
||||
if strings.Contains(permissionErr.ConsoleURL, url.QueryEscape(meetingQueryUserScope)) || !strings.Contains(permissionErr.ConsoleURL, url.QueryEscape(meetingQueryBotScope)) {
|
||||
t.Fatalf("ConsoleURL = %q, want only bot scope", permissionErr.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
|
||||
@@ -23,9 +23,13 @@ var VCMeetingListActive = common.Shortcut{
|
||||
Command: "+meeting-list-active",
|
||||
Description: "List active meetings for the current identity or target user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
// UAT exposes user-granted scopes, so the framework can preflight the user
|
||||
// recommendation. TAT has no scope metadata; keep the bot recommendation
|
||||
// conditional so it is available to diagnostics without a local preflight.
|
||||
UserScopes: []string{meetingQueryUserScope},
|
||||
ConditionalBotScopes: []string{meetingQueryBotScope},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "target user ID when using bot identity"},
|
||||
},
|
||||
@@ -50,7 +54,7 @@ var VCMeetingListActive = common.Shortcut{
|
||||
}
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingListActiveAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return normalizeMeetingQueryPermissionError(runtime, err)
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
|
||||
@@ -608,9 +608,18 @@ func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ScopeMatchesEventReadPermission(t *testing.T) {
|
||||
if len(VCMeetingListActive.Scopes) != 1 || VCMeetingListActive.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Fatalf("scopes = %#v, want [vc:meeting.meetingevent:read]", VCMeetingListActive.Scopes)
|
||||
func TestMeetingListActive_UsesUserScopePreflightAndBotScopeHint(t *testing.T) {
|
||||
if got := VCMeetingListActive.ScopesForIdentity("user"); len(got) != 1 || got[0] != meetingQueryUserScope {
|
||||
t.Fatalf("ScopesForIdentity(user) = %v, want [%s]", got, meetingQueryUserScope)
|
||||
}
|
||||
if got := VCMeetingListActive.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("ScopesForIdentity(bot) = %v, want no bot preflight scopes", got)
|
||||
}
|
||||
if got := VCMeetingListActive.DeclaredScopesForIdentity("user"); len(got) != 1 || got[0] != meetingQueryUserScope {
|
||||
t.Fatalf("DeclaredScopesForIdentity(user) = %v, want [%s]", got, meetingQueryUserScope)
|
||||
}
|
||||
if got := VCMeetingListActive.DeclaredScopesForIdentity("bot"); len(got) != 1 || got[0] != meetingQueryBotScope {
|
||||
t.Fatalf("DeclaredScopesForIdentity(bot) = %v, want [%s]", got, meetingQueryBotScope)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func minutesReadError(err error, minuteToken string) error {
|
||||
return err
|
||||
}
|
||||
p.Message = fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute file read permission"
|
||||
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm view", minuteToken)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1255,7 +1255,7 @@ func TestMinutesReadError_ProblemOf_EnrichesMessage(t *testing.T) {
|
||||
t.Errorf("error message not enriched: %q", errMsg)
|
||||
}
|
||||
hint, _ := note["hint"].(string)
|
||||
if !strings.Contains(hint, "minute file read permission") {
|
||||
if !strings.Contains(hint, "+apply-permission") {
|
||||
t.Errorf("hint not surfaced: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-apps
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -44,6 +44,7 @@ lark-cli auth login --domain apps
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) |
|
||||
| 管理妙搭应用自动化触发器(定时/记录变更/Webhook/飞书审批四类触发器的查询/创建/更新/启停;Webhook URL·Token 一次性回显、不落盘) | `+automation-list/get/create/update/enable/disable` | [`lark-apps-automation.md`](references/lark-apps-automation.md) |
|
||||
| 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id <id>`(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) |
|
||||
| 外部能力(AI模型能力和飞书平台能力)集成/插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) |
|
||||
|
||||
@@ -79,8 +80,8 @@ lark-cli auth login --domain apps
|
||||
|
||||
## 能力边界
|
||||
|
||||
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
|
||||
- 用户要配置权限 / 自动化时,引导其使用开发态连接前往云端开发(妙搭 web)处理。
|
||||
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
|
||||
- 用户要配置权限时,引导其使用开发态链接前往云端开发(妙搭 web)处理。自动化触发器请用 `+automation-*`(见「意图路由」)。
|
||||
|
||||
## app_id 获取
|
||||
|
||||
|
||||
164
skills/lark-apps/references/lark-apps-automation.md
Normal file
164
skills/lark-apps/references/lark-apps-automation.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# apps automation 触发器命令族 SOP
|
||||
|
||||
管理妙搭应用的自动化触发器(定时 / 记录变更 / Webhook / 飞书审批四类)。全部操作需 `--as user`(AuthType: user)。`--help` 是参数细节的完整来源;本文件只记录 Agent 不看就会做错的领域规则。
|
||||
|
||||
## 何时用本 skill(路由锚点)
|
||||
|
||||
**当用户消息里出现「妙搭应用名 / app_id」+ 以下任一意图,路由本 skill,不要走 lark-event 或 lark-openapi-explorer:**
|
||||
|
||||
- 「(每天 / 定时 / 每 N 小时 / 每周 X)自动跑 / 自动触发 / 定时同步」→ `+automation-create --trigger-type cron`
|
||||
- 「数据表 / 记录 / 表里 X 字段(新增 / 更新 / 删除 / 变化)时(触发 / 通知 / 处理)」→ `+automation-create --trigger-type record-change`
|
||||
- 「(webhook / 外部回调 / 外部系统调用 / HTTP 触发)」→ `+automation-create --trigger-type webhook`
|
||||
- 「(审批 / 报销 / 请假 / 出差)(通过 / 拒绝 / 提交 / 撤回)后自动 X」→ `+automation-create --trigger-type feishu-approval`
|
||||
- 「这个应用配了哪些(自动化 / 触发器 / 定时任务)」→ `+automation-list`
|
||||
- 「(暂停 / 停用 / 先别自动跑 / 关掉自动触发)某个(触发器 / 定时任务 / 自动化)」→ `+automation-disable`(不是 update 改条件、不是 delete——本 skill 不提供删除)
|
||||
- 「换 / 重置 webhook 回调地址 / URL」→ `+automation-update --reset-url --app-env <preview|runtime>`
|
||||
- 「换 / 重置 / 轮换 webhook token / bearer」→ `+automation-update --reset-token`
|
||||
|
||||
**边界(防误路由)**:`lark-event` 是**实时事件流消费**(agent 长连接订阅事件),不管妙搭应用触发器的**配置**;用户说「配 / 设置一个触发器」而不是「订阅事件流」时,本 skill 才是正确选择。「审批通过触发」在妙搭应用语境下属于本 skill 的 `feishu-approval` 类型,不是 lark-event。
|
||||
|
||||
### 回应「怎么配」类问题的正确姿势
|
||||
|
||||
用户问「怎么配 / 怎么设置一个 X 触发器」时,**先展示完整命令模板 + 你对核心参数的推断**(让用户能确认你理解对了),再追问缺失的必填项(`--name` 之类)或可选项。**不要跳过展示、直接连环追问**,那样用户没法确认你有没有理解意图。
|
||||
|
||||
示范:用户说「报销审批一旦通过就自动触发处理,怎么配?」
|
||||
- ✅ 正确:先写出「这是 feishu-approval 类型,命令模板:`apps +automation-create --app-id <id> --name <name> --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED [--approval-code <code>]`。需要你确认:(1) 触发器名 `<name>`;(2) 是否限定特定审批流程——限定就传 `--approval-code`(从飞书审批管理后台拿),不传则匹配所有审批定义」。
|
||||
- ❌ 错误:直接问「叫什么名字?监听哪个审批?」——用户没法确认你有没有把「审批通过」映射到 `--event-type approval_instance --instance-status APPROVED`。
|
||||
|
||||
同理,cron/record-change/webhook 三类的「怎么配」都遵循此模式:先给命令 + 参数推断,后追问缺项。
|
||||
|
||||
## 命令路由
|
||||
|
||||
| 命令 | 用途 | Risk |
|
||||
|---|---|---|
|
||||
| `+automation-list` | 列出应用所有触发器(可按类型过滤、`--all` 聚合翻页) | read |
|
||||
| `+automation-get` | 查看单个触发器完整配置(Webhook Bearer Token 恒脱敏) | read |
|
||||
| `+automation-create` | 创建触发器,四类共用一条命令,按 `--trigger-type` 分派 | write |
|
||||
| `+automation-update` | 改条件/描述,或经专用 flag 管理 Webhook URL·Token | high-risk-write |
|
||||
| `+automation-enable` | 启用触发器(`status→enabled`,开始自动触发) | write |
|
||||
| `+automation-disable` | 停用触发器(`status→disabled`,停止触发,不删除) | write |
|
||||
|
||||
触发器以 **应用内唯一的 `--name`** 定位(不是 id)。所有单条命令都用 `--app-id` + `--name`;名字忘了先 `+automation-list` 查。
|
||||
|
||||
## 四类触发器 payload
|
||||
|
||||
`--trigger-type` 用面向 Agent 的 kebab-case(`cron` / `record-change` / `webhook` / `feishu-approval`),CLI 内部转 snake_case 下推。类型专属 flag 只在对应类型生效。
|
||||
|
||||
### cron(定时)
|
||||
|
||||
```bash
|
||||
+automation-create --app-id <id> --name daily --trigger-type cron \
|
||||
--cron '0 9 * * *' [--timezone Asia/Shanghai]
|
||||
```
|
||||
|
||||
- `--cron` 是**五段式**(`minute hour day month weekday`),非六段。
|
||||
- **最小间隔 30 分钟**:`--cron '* * * * *'`(每分钟)或 `*/n`(n<30)会被 CLI 本地拦截报错;后端也会二次校验。
|
||||
- `--timezone` 缺省补 `Asia/Shanghai`(IANA 时区名)。
|
||||
|
||||
### record-change(记录变更)
|
||||
|
||||
```bash
|
||||
+automation-create --app-id <id> --name onUpd --trigger-type record-change \
|
||||
--table <table_name> --event UPDATE [--fields '["status"]']
|
||||
```
|
||||
|
||||
- `--event` 是**大写枚举**:`INSERT` / `UPDATE` / `UPSERT` / `DELETE`(CLI 会 uppercase,但请按枚举传)。
|
||||
- `--table` 是应用数据库里的**表名**(对应 `+db-table-list` / `+db-table-get` 输出里 `.name` 字段的值),必填。妙搭应用的 dataloom 表以名称作为稳定标识符,没有独立的 `table_id`。
|
||||
- `--fields` 是 JSON 字符串数组,仅对 `UPDATE`/`UPSERT` 有意义;`'["*"]'` 表示监听所有字段;不传表示不限定字段。
|
||||
|
||||
### webhook(外部回调)
|
||||
|
||||
```bash
|
||||
+automation-create --app-id <id> --name hook --trigger-type webhook \
|
||||
[--white-ip-list '["1.1.1.1","2.2.2.2"]']
|
||||
```
|
||||
|
||||
- 创建时可选 `--white-ip-list`(JSON 字符串数组)限制回调来源 IP。
|
||||
- 回调 URL 分 **preview / runtime 两套**,创建时不回显;用 `+automation-get` 查当前配置,用 `+automation-update --reset-url --app-env <preview|runtime>` 轮换。
|
||||
- Bearer Token 是回调鉴权凭证,见下方「凭证脱敏与一次性回显」。
|
||||
|
||||
### feishu-approval(飞书审批)
|
||||
|
||||
```bash
|
||||
+automation-create --app-id <id> --name apv --trigger-type feishu-approval \
|
||||
--event-type approval_instance --instance-status APPROVED [--approval-code <code>]
|
||||
```
|
||||
|
||||
- `--event-type` 必填,取 `approval_instance` 或 `approval_task`,决定状态用哪套 flag:
|
||||
- `approval_instance` → `--instance-status`(可重复)
|
||||
- `approval_task` → `--task-status`(可重复)
|
||||
- **领域规则**:状态按 `event-type` 分桶校验,两桶枚举**不完全相同**(`PENDING`/`APPROVED`/`REJECTED`/`REVERTED`/`OVERTIME_CLOSE`/`OVERTIME_RECOVER` 两桶共享;`TRANSFERRED`/`ROLLBACK`/`DONE` 仅 task 有;`CANCELED`/`DELETED` 仅 instance 有);传错桶的状态会被 CLI 本地拦截,错误信息会打印该桶的合法值列表。具体枚举见命令 `--help`。
|
||||
|
||||
## approval-code 获取路径
|
||||
|
||||
`--approval-code` **可选**。不传时匹配所有审批定义;要限定某个审批流程时,从**飞书审批管理后台**获取具体的 code 传给它。触发器 OpenAPI 不提供审批定义查询能力,具体 code 需去审批管理后台查。
|
||||
|
||||
## 凭证脱敏与一次性回显(安全关键)
|
||||
|
||||
- `+automation-get` / `+automation-list`:**恒不返回明文 Bearer Token**——`trigger_condition.token_value` 被抹为 `null`。用户想知道「token 是什么」时,list/get 都查不到明文。
|
||||
- `+automation-update --enable-token` / `--reset-token`:明文 Bearer Token **仅当次 stdout 回显一次**,同时 stderr 打印一次性告警:
|
||||
```text
|
||||
warning: this bearer token is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.
|
||||
```
|
||||
- Webhook URL 同理:`--reset-url` 后新 URL 仅当次回显一次,旧 URL 立即失效。
|
||||
- CLI 不落盘任何明文 token/URL(不写 cache / config / recent / debug log / 错误信息)。
|
||||
- **Token 丢失只能 reset**:找不回,唯一恢复方式是 `+automation-update --reset-token`(旧 token 同时失效)。
|
||||
|
||||
## 高危确认
|
||||
|
||||
`+automation-update` 整体是 `high-risk-write`,任何一次调用都需显式 `--yes`;缺少时框架会要求确认(退出码 10)。**不要自动补 `--yes`**——需用户明确确认后再加。以下 Webhook 动作 flag 尤其不可逆:
|
||||
|
||||
- `--reset-url`(旧回调 URL 立即失效,需配 `--app-env preview|runtime`)
|
||||
- `--reset-token`(旧 token 立即失效)
|
||||
- `--disable-token`(关闭 token 校验,**不可逆**)
|
||||
|
||||
四个 Webhook 动作 flag(`--reset-url` / `--enable-token` / `--disable-token` / `--reset-token`)**每次只能传一个**。不确定影响时先跑 `--dry-run` 看将发出的请求(不含明文)。
|
||||
|
||||
### 执行前必须完成的确认步骤(高危写强制协议)
|
||||
|
||||
**在带 `--yes` 执行任何高危写之前,Agent 必须先完成以下 3 件事**,缺一不可——即使用户口气很急、即使命令一眼就明:
|
||||
|
||||
1. **确认目标唯一**:不允许"猜名字"或"批量试所有可能的名字"。若不确定 `--name`,先 `+automation-list --app-id <id>` 让用户在候选中点名;`--name` 不明的绝不执行写操作,更不要 for 循环批量试。
|
||||
2. **确认可选参数已定**:`--reset-url` 必须由用户明确指定 `--app-env preview` 还是 `runtime`;不要默认取 runtime 或 preview。同一触发器的 preview/runtime 是两条独立的 URL,误重置另一条不可回退。
|
||||
3. **告知不可逆后果并等确认**:把即将发生的 3 件事复述给用户——(a)旧 URL/Token 立即永久失效;(b)新 URL/Token 仅当次回显一次、CLI 不保存;(c)本次操作无法撤销——等用户回复"确认"再加 `--yes` 跑。
|
||||
|
||||
只要有一项没做,就先跟用户对齐、不要执行。这些是 skill 层的护栏,不是 CLI 层的(CLI 只强制 `--yes`,不强制上面 3 件事)。
|
||||
|
||||
## ⚠️ 安全告警:无鉴权公网回调组合态
|
||||
|
||||
`--disable-token`(关闭 Bearer Token 校验,不可逆)**叠加** `--white-ip-list '[]'`(清空 IP 白名单)会让 Webhook 触发器进入「**无鉴权公网回调**」组合态——**任何来源都能触发该 Webhook**,没有任何一道防线拦截。
|
||||
|
||||
- 两道防线:Token 校验(谁能调)+ IP 白名单(从哪能调)。**不要同时关闭这两道防线。**
|
||||
- 若确需关闭 Token(例如对端无法带 Bearer 头),务必**保留 IP 白名单**收敛来源;反之若要放开 IP,务必**保留 Token 校验**。
|
||||
- 用户同时要求「关 token 校验 + 清空 IP 白名单」时,Agent 的正确响应是**在识别到该请求的第一时间**(不要等命令跑失败才补警告)向用户输出以下 3 件事,再等确认——不要只描述"没有任何防线"就停下:
|
||||
1. 复述后果:这会形成无鉴权公网回调,任何来源都能触发。
|
||||
2. **主动给出替代方案**:明确建议"要么只关 Token 保留 IP 白名单,要么只放开 IP 保留 Token",让用户在保留一道防线的两条备选里选一条。
|
||||
3. 只有用户明确回复"我理解风险、就是要两道都关"时,才继续按高危写协议(见上节「执行前必须完成的确认步骤」)走。
|
||||
|
||||
## 默认 disabled
|
||||
|
||||
`+automation-create` 创建后触发器**默认 disabled**,不会自动触发。需 `+automation-enable` 才开始按条件自动运行(且触发器执行的是**线上已发布**的应用代码——应用未发布时即便 enable 也不会有实际效果)。
|
||||
|
||||
**Agent 行为约束**:用户只说"创建/配一个触发器"时,**不要**主动在同一个 turn 里 `+automation-enable`。让用户自己在下一轮决定是否启用;主动启用会:
|
||||
- 让 webhook 类型立即可被外部调用(原本用户可能只是想"备好 URL 稍后用")
|
||||
- 让 cron 到点真实触发(原本用户可能想"先建好观察配置")
|
||||
- 让 record-change 立即响应表变更
|
||||
|
||||
创建成功后的推荐话术:`已创建 <name>,当前 disabled;需要真正开始自动运行时告诉我,我用 +automation-enable 启用它。` **不要**在创建成功后立即启用,即使 skill 里说"需 enable 才自动触发"——这条是给用户的说明,不是给 agent 的行动指令。
|
||||
|
||||
## 常见错误与决策场景
|
||||
|
||||
| 现象 / 用户意图 | 正确处理 |
|
||||
|---|---|
|
||||
| 创建报名字冲突(`--name` 应用内唯一) | 换名或加后缀重试 |
|
||||
| cron 报非法 / 间隔过小 | 检查是否五段式、分钟字段是否 `*` 或 `*/n`(n<30) |
|
||||
| `--reset-url` 报缺 app-env | 补 `--app-env preview` 或 `--app-env runtime` |
|
||||
| 想把 cron 触发器改成 webhook(跨类型改) | update 不支持换类型,本 skill 也不提供删除。旧触发器只能 `+automation-disable` 停用(保留在应用里),另建一个 webhook 触发器;若要真正清理旧触发器,请到妙搭 web 手动删除 |
|
||||
| 触发器 enable 了但不触发 | 确认应用**已发布**;触发器跑的是线上已发布代码 |
|
||||
| 「token 泄露了」 | 优先 `+automation-update --reset-token --yes` 轮换(旧 token 立即失效),而非直接 disable-token 关校验 |
|
||||
| 「回调 URL 泄露了」 | `+automation-update --reset-url --app-env <env> --yes` 轮换 |
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 审批定义查询、Webhook 消费端实现、实时触发日志 tail:本期不支持。
|
||||
- 身份选择、权限不足处理、exit-10 审批、通用「禁输出密钥」红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
|
||||
@@ -76,4 +76,4 @@ CLI 提供三种互斥的 scope 表达方式:
|
||||
## 不在本 skill 范围
|
||||
|
||||
- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。
|
||||
- 身份选择、权限不足处理(`permission_violations`→`console_url`)、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
|
||||
- 身份选择、权限不足处理(`missing_scopes`→`console_url`)、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。
|
||||
|
||||
@@ -85,7 +85,7 @@ metadata:
|
||||
## 身份与权限降级
|
||||
|
||||
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。
|
||||
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
|
||||
- user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
|
||||
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次;bot 仍失败就停止重试并按权限错误处理。
|
||||
- `91403` 或明确不可访问错误不要循环换身份重试。
|
||||
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。
|
||||
|
||||
@@ -98,21 +98,21 @@ lark-cli base +dashboard-block-get \
|
||||
|
||||
## 返回结构总览
|
||||
|
||||
服务端响应外层仍然是标准 OpenAPI 包装:
|
||||
CLI 成功输出使用标准 `{ok, identity, data}` 信封:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"dimensions": [...],
|
||||
"measures": [...],
|
||||
"main_data": [...]
|
||||
"dimensions": [],
|
||||
"measures": [],
|
||||
"main_data": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中 `data` 就是 CLI 图表协议本体。不同图表类型的 `data` 结构略有不同:
|
||||
其中 `identity` 是本次调用实际使用的身份,`data` 是 CLI 图表协议本体。不同图表类型的 `data` 结构略有不同:
|
||||
|
||||
| 图表类型 | 一定有 | 可能有 |
|
||||
|----------|--------|--------|
|
||||
|
||||
@@ -63,6 +63,7 @@ lark-cli calendar event.attendees create \
|
||||
- 时间参数是 **Unix 秒字符串**(非 ISO 8601)。
|
||||
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
|
||||
- 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
|
||||
- 设置会议 owner:`+create` 不支持,需用完整 API 命令在 `vchat.meeting_settings.owner_id` 中设置,且必须同时设置 `vchat.vc_type` 为 `vc`(代表该日程为 VC 视频会议)。仅当以应用(bot)身份在应用日历上操作时生效;owner 必须为用户身份(`ou_` open_id),不能为非用户或外部租户用户。
|
||||
|
||||
## 参会人类型
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../
|
||||
<okr-progress>
|
||||
<p>O 进展</p>
|
||||
<checkbox done="true">事项</checkbox>
|
||||
<ul><li>列表项内可包含 <a href="https://example.com">链接</a></li></ul>
|
||||
</okr-progress>
|
||||
<okr-key-result key-result-id="KEY_RESULT_ID" status="risk" percent="60" score="80">
|
||||
<p>KR 描述</p>
|
||||
@@ -31,4 +32,4 @@ OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../
|
||||
- `okr-objective` / `okr-key-result`
|
||||
- 可更新 `status`、`percent`、`score`;`percent` / `score` 取值 0-100,`status` 取值 `unset`/`normal`/`risk`/`extended`。
|
||||
- 不可更新 objective 和 key-result 内容描述。
|
||||
- `okr-progress` 承载进展内容,支持更新。支持内嵌 `<p>`、`<checkbox>`、`<grid>`、`<img>`。
|
||||
- `okr-progress` 承载进展内容,支持更新。直接子节点支持 `<p>`、`<checkbox>`、`<grid>`、`<img>`、`<source>`、`<ol>`、`<ul>`、`<h1>` 到 `<h9>`。
|
||||
|
||||
@@ -26,7 +26,8 @@ metadata:
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps`(`file_type=apps`),裸 token 调 shortcut 时传 `--type apps`。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
@@ -63,6 +64,7 @@ metadata:
|
||||
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | 不能直接当底层 `file_token`;优先用 `drive +inspect` 解包获取 `obj_token` |
|
||||
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/page/` | `https://example.feishu.cn/page/N1BWmMrqndT5ZcamAIBcnvDLnOf/` | apps token | 妙搭 apps 类型;用于评论列表时直接作为 `file_token`,`file_type=apps` |
|
||||
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
|
||||
|
||||
### Wiki 链接特殊处理
|
||||
@@ -82,7 +84,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`;wiki URL/token 会自动解析到底层真实 token/type |
|
||||
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`;wiki URL/token 会自动解析到底层真实 token/type;妙搭 apps URL 使用 `/page/<token>` |
|
||||
|
||||
### 评论能力入口
|
||||
|
||||
@@ -107,7 +109,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
|----------|------|----------|
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable/apps) |
|
||||
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||
|
||||
### 权限能力入口
|
||||
@@ -142,7 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL;评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
|
||||
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL,支持 wiki 自动解包。 |
|
||||
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable)/apps 评论列表;优先传 URL,支持 wiki 自动解包和妙搭 `/page/<token>` URL。 |
|
||||
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
|
||||
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
|
||||
|
||||
列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传用户给出的完整 URL,shortcut 会自动识别类型;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。
|
||||
列出 doc/docx/sheet/file/slides/base(bitable)/apps 的评论卡片。优先传用户给出的完整 URL,shortcut 会自动识别类型;apps 为妙搭类型,支持 `/page/<token>` URL;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。
|
||||
|
||||
## 重要默认口径
|
||||
|
||||
@@ -40,6 +40,10 @@ lark-cli drive +list-comments \
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/sheets/<SHEET_TOKEN>"
|
||||
|
||||
# 妙搭 apps URL 使用 /page/<token>,shortcut 会识别为 file_type=apps。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.feishu.cn/page/<APPS_TOKEN>/"
|
||||
|
||||
# wiki URL 会自动解包。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
|
||||
@@ -55,6 +59,11 @@ lark-cli drive +list-comments \
|
||||
--type sheet \
|
||||
--page-size 100
|
||||
|
||||
# 妙搭裸 apps token 需要显式声明 --type apps。
|
||||
lark-cli drive +list-comments \
|
||||
--token "<APPS_TOKEN>" \
|
||||
--type apps
|
||||
|
||||
# docx 需要评论定位关系时再带 need-relation;非 docx 会静默忽略。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
@@ -77,9 +86,9 @@ lark-cli drive +list-comments \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URL;wiki URL 会自动解析到真实文档。 |
|
||||
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/apps/wiki URL;apps 妙搭 URL 使用 `/page/<token>`;wiki URL 会自动解析到真实文档。 |
|
||||
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`;wiki token 使用 `--type wiki`。 |
|
||||
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc`、`docx`、`sheet`、`file`、`slides`、`bitable`、`base`、`wiki`。wiki token 使用 `wiki`;传 `base` 时,CLI 会按 `bitable` 类型处理。 |
|
||||
| `--type` | 裸 token 时必填 | 传 token 对应类型:`doc`、`docx`、`sheet`、`file`、`slides`、`bitable`、`base`、`apps`、`wiki`。wiki token 使用 `wiki`;传 `base` 时,CLI 会按 `bitable` 类型处理。 |
|
||||
| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false`。`false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 |
|
||||
| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all`。`all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 |
|
||||
| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 |
|
||||
@@ -90,7 +99,7 @@ lark-cli drive +list-comments \
|
||||
## 行为说明
|
||||
|
||||
- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。
|
||||
- 当用户已经给出完整 URL 时,原样传给 `--url`;不要先提取 token 再重组成其他类型 URL。比如 sheet 保留 `/sheets/<token>`,wiki 保留 `/wiki/<token>`。
|
||||
- 当用户已经给出完整 URL 时,原样传给 `--url`;不要先提取 token 再重组成其他类型 URL。比如 sheet 保留 `/sheets/<token>`,wiki 保留 `/wiki/<token>`,妙搭 apps 保留 `/page/<token>`。
|
||||
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突,shortcut 会返回 validation error,建议移除 `--type`。
|
||||
- wiki 输入会自动解析到真实文档,再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
|
||||
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token`、`file_type`、`has_more`、`page_token`、`count`;`count` 是当前页返回的评论卡片数。是否继续分页以 `has_more` 为准,而不是只看 `page_token` 是否存在。
|
||||
|
||||
@@ -54,7 +54,7 @@ lark-cli drive +member-add \
|
||||
}
|
||||
```
|
||||
|
||||
批量部分失败时,`partial` 为 `true`,CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
|
||||
批量部分失败时,`partial` 为 `true`,同一份结果以 `ok:false` 部分失败信封写到 **stdout**(stderr 不再输出单独的错误信封),CLI 以非零退出码结束。检查 `data` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
|
||||
|
||||
## 行为说明
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) |
|
||||
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`,`error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`)退出:同一份 `summary + items` 会以 `ok:false` 部分失败信封写到 **stdout**(字段在 `data.summary` / `data.items`),stderr 不再输出单独的错误信封;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation`、`error.subtype=failed_precondition`,`error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
@@ -80,7 +80,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
|
||||
- `--delete-local --yes`,**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `ok:false` 部分失败结果非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-local` → `summary.deleted_local` 永远是 0;命令对本地"多余"文件视而不见。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(stderr 类型化错误信封:`error.type=validation`、`error.subtype=failed_precondition`,`error.params[]` 逐条列出冲突的 `rel_path` 及碰撞条目),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
|
||||
|
||||
| 策略 | 行为 |
|
||||
|------|------|
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token`、`type`、名称、大小和时间字段;其中 `created_time`、`modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
|
||||
如果 Drive 中多个条目映射到同一个 `rel_path`,`+status` 会在下载/hash 前直接失败,在 stderr 返回类型化错误信封(`error.type=validation`、`error.subtype=failed_precondition`);`error.params[]` 每条的 `name` 是冲突的 `rel_path`,`reason` 枚举该路径下所有碰撞条目(`type` + `file_token`)。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -76,20 +76,18 @@ lark-cli drive +status \
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"identity": "user",
|
||||
"error": {
|
||||
"type": "duplicate_remote_path",
|
||||
"message": "multiple Drive entries map to the same rel_path",
|
||||
"detail": {
|
||||
"duplicates_remote": [
|
||||
{
|
||||
"rel_path": "dup.txt",
|
||||
"entries": [
|
||||
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
|
||||
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"type": "validation",
|
||||
"subtype": "failed_precondition",
|
||||
"message": "1 rel_path(s) map to multiple Drive entries",
|
||||
"hint": "resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help",
|
||||
"params": [
|
||||
{
|
||||
"name": "dup.txt",
|
||||
"reason": "2 Drive entries collide here: file <full_file_token>, folder <folder_token>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:搜索妙记、查看妙记基础信息、下载/上传音视频、读取或编辑妙记的产物内容、改标题、替换说话人/关键词。当给出minute_token、本地音视频文件,要查/改/转妙记产物时使用;本地音视频转纪要/逐字稿优先走本 skill,不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
|
||||
description: "飞书妙记:搜索妙记、查看妙记基础信息、下载/上传音视频、读取或编辑妙记的产物内容、改标题、替换说话人/关键词、申请妙记查看/编辑权限。当给出minute_token、本地音视频文件,要查/改/转妙记产物,或用户明确要主动申请妙记权限时使用;本地音视频转纪要/逐字稿优先走本 skill,不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -31,6 +31,7 @@ metadata:
|
||||
| [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 |
|
||||
| [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 |
|
||||
| [`+update`](references/lark-minutes-update.md) | 更新妙记标题 |
|
||||
| `+apply-permission` | 申请妙记查看或编辑权限 |
|
||||
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(须先 `lark-cli api GET .../speakerlist` 取 `speaker_id`) |
|
||||
| `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help`) |
|
||||
| [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 |
|
||||
@@ -52,6 +53,7 @@ metadata:
|
||||
| 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`(**禁止走 lark-task**) |
|
||||
| 替换妙记的AI 总结 | `+summary` |
|
||||
| 重命名妙记/改妙记标题 | `+update` |
|
||||
| 申请妙记权限(查看/编辑) | `+apply-permission --perm view\|edit` |
|
||||
| 替换说话人/把 A 的发言改成 B/重新归属发言人/把外部(非飞书)说话人改成飞书用户" | 先 `lark-cli api GET .../transcript/speakerlist` 取 `speaker_id`,再 [`minutes +speaker-replace`](references/lark-minutes-speaker-replace.md);`--from-speaker-id` 只传 id,不传展示名 |
|
||||
| 批量替换逐字稿关键词 | `+word-replace` |
|
||||
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`)获取 `minute_token`,再本 skill |
|
||||
@@ -75,8 +77,21 @@ metadata:
|
||||
2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`。
|
||||
3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
|
||||
|
||||
### 3. 申请妙记权限
|
||||
|
||||
### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
遇到妙记没有查看或编辑权限时,引导用户申请对应权限;只有用户明确要申请时,才调用 `minutes +apply-permission`。
|
||||
|
||||
只有当用户明确要求"申请查看权限"、"申请编辑权限"、"帮我申请这条妙记权限"时,才调用:
|
||||
|
||||
```bash
|
||||
lark-cli minutes +apply-permission --minute-token <token> --perm view|edit
|
||||
```
|
||||
|
||||
这是向妙记所有者发起权限申请,不代表立即获得权限。
|
||||
|
||||
**安全约束**:遇到无权限错误时,不要自动调用 `+apply-permission`;先把无权限事实告知用户,只有用户明确要求申请权限时才发起申请。
|
||||
|
||||
### 4. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
|
||||
1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
|
||||
2. **处理流程**:
|
||||
@@ -120,9 +135,9 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
|
||||
|
||||
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
|
||||
|
||||
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。
|
||||
**无编辑权限**:若 CLI 返回 `error.subtype=permission_denied`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。
|
||||
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.subtype=not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
|
||||
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。
|
||||
|
||||
|
||||
@@ -126,8 +126,8 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` |
|
||||
| `--todos` 与单条 flags 冲突 | 二选一 |
|
||||
| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 |
|
||||
| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
|
||||
| 缺少 OAuth scope(`permission_violations` 含 `minutes:minutes:update`) | `lark-cli auth login --scope "minutes:minutes:update"` |
|
||||
| `error.subtype` = `permission_denied` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` |
|
||||
| 缺少 OAuth scope(`error.missing_scopes` 含 `minutes:minutes:update`) | `lark-cli auth login --scope "minutes:minutes:update"` |
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli a
|
||||
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
|
||||
|
||||
错误响应中包含关键信息:
|
||||
- `permission_violations`:列出缺失的 scope (N选1)
|
||||
- `missing_scopes`:列出缺失的 scope (N选1)
|
||||
- `console_url`:飞书开发者后台的权限配置链接
|
||||
- `hint`:建议的修复命令
|
||||
|
||||
@@ -159,7 +159,7 @@ lark-cli update
|
||||
错误信封写入 **stderr**(退出码非 0):
|
||||
|
||||
```json
|
||||
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||
{ "ok": false, "identity": "user", "error": { "type": "authorization", "subtype": "missing_scope", "code": 99991679, "message": "...", "hint": "...", "missing_scopes": ["..."] } }
|
||||
```
|
||||
|
||||
**判断成功必须用 `ok == true`(或进程退出码 0),不要用 `code == 0`**:成功信封没有顶层 `code` / `msg` 字段,`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。按 OpenAPI 老格式 `{"code": 0, "msg": "ok"}` 判断会把所有成功调用误判为失败;封装写入类命令(如 `task +create`)时尤其危险,误判会绕过幂等逻辑导致重复创建。
|
||||
@@ -178,22 +178,22 @@ lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"identity": "bot",
|
||||
"error": {
|
||||
"type": "confirmation_required",
|
||||
"type": "confirmation",
|
||||
"subtype": "confirmation_required",
|
||||
"message": "drive +delete requires confirmation",
|
||||
"hint": "add --yes to confirm",
|
||||
"risk": {
|
||||
"level": "high-risk-write",
|
||||
"action": "drive +delete"
|
||||
}
|
||||
"risk": "high-risk-write",
|
||||
"action": "drive +delete"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
|
||||
|
||||
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
|
||||
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
|
||||
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation"`、`error.subtype == "confirmation_required"`
|
||||
2. **向用户确认**:把 `error.action`、`error.risk` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
|
||||
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
|
||||
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁
|
||||
|
||||
|
||||
@@ -10,34 +10,40 @@ metadata:
|
||||
|
||||
# slides (v1)
|
||||
|
||||
**CRITICAL — 全局硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。**
|
||||
|
||||
**CRITICAL — 图片至关重要:必须有意识的主动多用图片!素材图使用生图工具和搜图工具,缺图时用生图工具生成配图补足;背景图必须使用生图工具,且生图指令中必须明确要求不要出现任何文字。**
|
||||
|
||||
**CRITICAL — 防文本溢出:所有承载突出信息和密集文字的 `<content>` 必须设置 `autoFit="normal-auto-fit"`,字号会在框内自动缩排以防溢出。**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 从模板创建或编辑已有本地 PPTX | 导入 PPTX 为 Slides | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 绘制图表 | 原生图表用 `<chart>`,其他用 `<shape>` + `<line>`,只有复杂 Mermaid、SVG 用 `<whiteboard>` | `xml-schema-quick-ref.md`、`slides_chart_demo.xml` |
|
||||
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
|
||||
| 使用图标 | 禁止盲猜 `iconType`,必须先检索 IconPark,再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md)。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
@@ -79,14 +85,13 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要适当,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
@@ -124,7 +129,9 @@ lark-cli auth login --domain slides
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
- 不要使用 emoji。
|
||||
- 不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
@@ -140,9 +147,11 @@ lark-cli auth login --domain slides
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格
|
||||
- 澄清主题、受众、页数、风格;若用户上传 PPTX 作为模板,按顶部『用户自定义模板』规则处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
@@ -153,10 +162,11 @@ Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 调用创建或整页替换接口前,先保存待提交 XML 并运行 xml_text_overlap_lint.py;error_count 不为 0 必须先修
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
@@ -173,7 +183,7 @@ lark-cli slides xml_presentation.slide create \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
<!-- 在这里放置 shape、line、table、chart 等元素 -->
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
@@ -247,11 +257,12 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+xml-get`](references/lark-slides-xml-get.md) | 读取全文或单页 XML,并可保存到本地文件,避免终端输出被截断 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
@@ -262,7 +273,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
|
||||
@@ -2,260 +2,90 @@
|
||||
|
||||
本文档提供与 CLI schema 一致的调用示例,XML 内容均遵循 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
|
||||
|
||||
> **重要**:创建 PPT 请优先使用 `slides +create`;实际页面内容请使用 `xml_presentation.slide.create` 逐页添加。
|
||||
> **重要**:新建 PPT 请使用 `slides +create --slides`,传入由 `<slide>` XML 字符串组成的 JSON 数组;每个元素必须是一页完整的 `<slide>`。复杂内容建议先创建空白 PPT,再通过 `xml_presentation.slide.create` 逐页添加。完整 `<presentation>` XML 可用于本地 lint 或读取,但不能直接作为 `+create` 的提交参数。
|
||||
|
||||
## 目录
|
||||
|
||||
- [示例 1: 使用 Shortcut 创建空白演示文稿](#示例-1-使用-shortcut-创建空白演示文稿)
|
||||
- [示例 2: 创建后添加第一页](#示例-2-创建后添加第一页)
|
||||
- [示例 3: 读取 XML 内容](#示例-3-读取-xml-内容)
|
||||
- [示例 4: 在指定页面前插入新幻灯片](#示例-4-在指定页面前插入新幻灯片)
|
||||
- [示例 5: 删除幻灯片](#示例-5-删除幻灯片)
|
||||
- [示例 6: 从文件读取 XML 后添加页面](#示例-6-从文件读取-xml-后添加页面)
|
||||
- [示例 1:可靠创建 6 页 PPT](#示例-1可靠创建-6-页-ppt)
|
||||
- [示例 7: +replace-slide + block_insert 给已有页加图](#示例-7-replace-slide--block_insert-给已有页加图)
|
||||
- [示例 8: +replace-slide + block_replace 替换一个块](#示例-8-replace-slide--block_replace-替换一个块)
|
||||
|
||||
## 示例 1: 使用 Shortcut 创建空白演示文稿
|
||||
## 示例 1:可靠创建 6 页 PPT
|
||||
|
||||
### 1. 写入规划文件
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --title "项目汇报"
|
||||
DECK_DIR=".lark-slides/plan/reliable-six-page-ppt"
|
||||
mkdir -p "$DECK_DIR"
|
||||
|
||||
# 按 planning-layer.md 写入 "$DECK_DIR/slide_plan.json",
|
||||
# 至少记录 6 页的顺序和标题。
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
### 2. 为每页保存独立 XML
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"title": "项目汇报",
|
||||
"revision_id": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 2: 创建后添加第一页
|
||||
每个文件都是完整的 `<slide>`。下面的循环会生成 6 个独立 XML 文件;实际项目中可将每页主体替换为规划内容。
|
||||
|
||||
```bash
|
||||
PRESENTATION_ID=$(lark-cli slides +create --title "季度复盘" | jq -r '.data.xml_presentation_id')
|
||||
|
||||
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245, 245, 245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"72\" width=\"760\" height=\"90\"><content textType=\"title\"><p>2024 Q3 季度复盘</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"190\" width=\"520\" height=\"220\"><content textType=\"body\"><p>关键结论</p><ul><li><p>收入增长 30%</p></li><li><p>重点项目全部上线</p></li><li><p>用户满意度持续提升</p></li></ul></content></shape><shape type=\"rect\" topLeftX=\"660\" topLeftY=\"180\" width=\"180\" height=\"140\"><fill><fillColor color=\"rgba(100, 149, 237, 0.25)\"/></fill><border color=\"rgb(100, 149, 237)\" width=\"2\"/></shape></data><note><content textType=\"body\"><p>讲述时先给结论,再补充数据。</p></content></note></slide>"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## 示例 3: 读取 XML 内容
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}'
|
||||
```
|
||||
|
||||
提取 XML 内容:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"xml_presentation": {
|
||||
"presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": 3,
|
||||
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
|
||||
}
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 4: 在指定页面前插入新幻灯片
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide create --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新增页面</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"180\"><content textType=\"body\"><p>这是新增页面的正文。</p></content></shape></data></slide>"
|
||||
},
|
||||
"before_slide_id": "sld_before_target"
|
||||
}'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"slide_id": "slide_example_id",
|
||||
"revision_id": 100
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 5: 删除幻灯片
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide delete --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id"
|
||||
}'
|
||||
```
|
||||
|
||||
预期返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"revision_id": 101
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 6: 从文件读取 XML 后添加页面
|
||||
|
||||
先准备 `slide.xml`:
|
||||
|
||||
```xml
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title">
|
||||
<p>从文件加载</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
```
|
||||
|
||||
先创建演示文稿:
|
||||
|
||||
```bash
|
||||
PRESENTATION_ID=$(lark-cli slides +create --title "从文件添加页面" | jq -r '.data.xml_presentation_id')
|
||||
```
|
||||
|
||||
再用 `jq` 组装请求体,从文件添加页面:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide create --as user \
|
||||
--params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" \
|
||||
--data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')"
|
||||
```
|
||||
|
||||
## 示例 7: +replace-slide + block_insert 给已有页加图
|
||||
|
||||
只想在已有页上加一张图、不动其他元素——走 shortcut `+replace-slide`,`block_insert` 追加到页末(或用 `insert_before_block_id` 指定位置)。
|
||||
|
||||
```bash
|
||||
PID="slides_example_presentation_id"
|
||||
SID="slide_example_id"
|
||||
|
||||
# 1. 上传图片拿 file_token
|
||||
TOKEN=$(lark-cli slides +media-upload --file ./pic.png --presentation "$PID" --as user \
|
||||
| jq -r '.data.file_token')
|
||||
|
||||
# 2. block_insert 到页面末尾(省略 insert_before_block_id)
|
||||
# 注:<img .../> 是自闭合标签,CLI 不会展开(只有 <shape/> 会被补 <content/>)
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" \
|
||||
--parts "$(jq -n --arg token "$TOKEN" \
|
||||
'[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")}]')"
|
||||
```
|
||||
|
||||
预期返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id",
|
||||
"parts_count": 1,
|
||||
"revision_id": 102
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 8: +replace-slide + block_replace 替换一个块
|
||||
|
||||
已知某块的 3 位 short element ID(从 `slide.get` 返回 XML 里读),整块换掉。`replacement` 根元素的 `id` 会由 CLI 自动注入为 `block_id`,无需手写;若写了 `<shape/>` 自闭合形式,CLI 也会自动补 `<content/>`。
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-id slide_example_id \
|
||||
--parts '[
|
||||
{
|
||||
"action": "block_replace",
|
||||
"block_id": "bab",
|
||||
"replacement": "<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"
|
||||
}
|
||||
]'
|
||||
# CLI 实际发送的 replacement 根元素会带 id="bab",即使手写时省略了
|
||||
```
|
||||
|
||||
失败时(3350001 错误,CLI 在 error 字段中给出 hint):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "api",
|
||||
"code": 3350001,
|
||||
"message": "API error: [3350001] invalid param",
|
||||
"hint": "common causes: (1) block_id not found in current slide ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
整批作为原子事务,任一 part 失败则整批不生效;按 `failed_part_index` 定位修正后重发。
|
||||
|
||||
## 常见处理技巧
|
||||
|
||||
### 获取最新 revision_id
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' | jq '.data.xml_presentation.revision_id'
|
||||
```
|
||||
|
||||
### 批量插入多页
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
PRESENTATION_ID="slides_example_presentation_id"
|
||||
|
||||
slides=(
|
||||
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 1</p></content></shape></data></slide>'
|
||||
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 2</p></content></shape></data></slide>'
|
||||
)
|
||||
|
||||
for slide_xml in "${slides[@]}"; do
|
||||
payload=$(jq -n --arg content "$slide_xml" '{slide:{content:$content}}')
|
||||
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data "$payload"
|
||||
titles=("主题与结论" "问题背景" "核心方法" "关键数据" "执行计划" "总结与行动")
|
||||
for i in {1..6}; do
|
||||
printf -v page '%02d' "$i"
|
||||
cat > "$DECK_DIR/slide-$page.xml" <<XML
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0"><style><fill><fillColor color="rgb(248,250,252)"/></fill></style><data><shape type="rect" topLeftX="56" topLeftY="56" width="12" height="428"><fill><fillColor color="rgb(37,99,235)"/></fill></shape><shape type="text" topLeftX="100" topLeftY="160" width="760" height="90"><content textType="title" autoFit="normal-auto-fit"><p>${titles[$((i-1))]}</p></content></shape><shape type="text" topLeftX="100" topLeftY="290" width="700" height="70"><content textType="body" autoFit="normal-auto-fit"><p>页面主体内容。</p></content></shape></data></slide>
|
||||
XML
|
||||
done
|
||||
```
|
||||
|
||||
### 本地校验 XML 基本语法
|
||||
### 3. 逐页运行 lint
|
||||
|
||||
提交前检查每个独立 XML。`summary.error_count` 必须为 `0`,否则先修复 XML 或布局问题。
|
||||
|
||||
```bash
|
||||
xmllint --noout presentation.xml
|
||||
for slide_xml in "$DECK_DIR"/slide-0{1,2,3,4,5,6}.xml; do
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py \
|
||||
--input "$slide_xml" | tee "${slide_xml%.xml}.lint.json"
|
||||
done
|
||||
|
||||
test "$(jq -s 'map(.summary.error_count) | add' "$DECK_DIR"/slide-0{1,2,3,4,5,6}.lint.json)" = "0"
|
||||
```
|
||||
|
||||
### 真实示例
|
||||
### 4. 使用 `+create` 创建 6 页 PPT
|
||||
|
||||
`--slides` 接收由 6 个完整 `<slide>` XML 字符串组成的 JSON 数组;使用 `jq --rawfile` 避免手动处理 XML 引号和换行。
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --as user \
|
||||
--title "可靠创建 6 页 PPT" \
|
||||
--slides "$(jq -n \
|
||||
--rawfile s1 "$DECK_DIR/slide-01.xml" \
|
||||
--rawfile s2 "$DECK_DIR/slide-02.xml" \
|
||||
--rawfile s3 "$DECK_DIR/slide-03.xml" \
|
||||
--rawfile s4 "$DECK_DIR/slide-04.xml" \
|
||||
--rawfile s5 "$DECK_DIR/slide-05.xml" \
|
||||
--rawfile s6 "$DECK_DIR/slide-06.xml" \
|
||||
'[$s1, $s2, $s3, $s4, $s5, $s6]')" \
|
||||
> "$DECK_DIR/create.json"
|
||||
create_status=$?
|
||||
|
||||
if [ "$create_status" -ne 0 ]; then
|
||||
exit "$create_status"
|
||||
fi
|
||||
|
||||
if ! PRESENTATION_ID=$(jq -er '.data.xml_presentation_id | strings | select(length > 0)' "$DECK_DIR/create.json"); then
|
||||
echo "missing non-empty data.xml_presentation_id in $DECK_DIR/create.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$PRESENTATION_ID" > "$DECK_DIR/xml_presentation_id"
|
||||
```
|
||||
|
||||
如果创建中途失败,先保存已经返回的 `xml_presentation_id`,再回读确认实际已创建页数。
|
||||
|
||||
### 5. 用 `+xml-get` 回读全文 XML
|
||||
|
||||
```bash
|
||||
lark-cli slides +xml-get --as user \
|
||||
--presentation "$PRESENTATION_ID" \
|
||||
--output "$DECK_DIR/readback.xml" \
|
||||
--json | tee "$DECK_DIR/readback.json"
|
||||
```
|
||||
|
||||
- [slides_demo.xml](slides_demo.xml) 提供了更完整的页面示例,包含 `theme`、渐变填充、图片、图标和备注内容。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# IconPark 图标
|
||||
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
|
||||
IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引,避免凭记忆拼路径。
|
||||
|
||||
## 机器优先流程
|
||||
|
||||
@@ -25,7 +25,7 @@ python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
|
||||
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8` 或 `--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`。
|
||||
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
|
||||
- 常用尺寸:行内状态图标 16-24px,卡片标题图标 28-40px,主视觉图标 56-96px。
|
||||
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 视觉规范要求图标设置非透明 `fillColor`,显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
|
||||
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback,不留空图标位。
|
||||
|
||||
## 高频示例
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
- 禁止:从完整 <presentation> XML 解析/拆分/重序列化生成提交 payload。
|
||||
- 推荐:提交源直接就是单页 <slide> XML;+create --slides 只接受已经人工/程序直接生成的 slide 数组,不接受由
|
||||
presentation 动态拆出来的数组。
|
||||
|
||||
- 最稳:复杂 deck 默认空 deck + 单页 slide create,每次只提交一个 <slide>。
|
||||
|
||||
- 注意:复杂 XML 不适合直接塞命令行,中文、引号、特殊字符较多时,直接拼接 --slides 容易发生 shell 转义或截断。建议将每页 XML 保存为独立文件,使用 `jq --rawfile` 组装 JSON 数组,避免手动处理 XML 引号和换行。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
@@ -24,6 +32,18 @@ lark-cli slides +create --title "项目汇报" --as bot
|
||||
lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
|
||||
```
|
||||
|
||||
复杂内容建议按页保存 XML,再用 `jq --rawfile` 组装 `--slides` 参数:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --as user --title "项目汇报" \
|
||||
--slides "$(jq -n \
|
||||
--rawfile s1 .lark-slides/plan/project/slide-01.xml \
|
||||
--rawfile s2 .lark-slides/plan/project/slide-02.xml \
|
||||
'[$s1, $s2]')"
|
||||
```
|
||||
|
||||
`--rawfile` 会把文件内容作为字符串读入 JSON,自动处理 XML 中的引号和换行;不要手动拼接带大量转义符的 JSON 字符串。
|
||||
|
||||
## 返回值
|
||||
|
||||
工具成功执行后,返回一个 JSON 对象,包含以下字段:
|
||||
@@ -133,5 +153,4 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
|
||||
## 相关命令
|
||||
|
||||
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 添加幻灯片页面
|
||||
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读取 PPT 内容
|
||||
- [slides +xml-get](lark-slides-xml-get.md) — 读取 PPT 内容并保存到本地文件
|
||||
|
||||
@@ -125,4 +125,3 @@ lark-cli slides +replace-slide --as user \
|
||||
|
||||
- [+create](lark-slides-create.md) — 新建 PPT(支持 `@` 占位符自动上传图片)
|
||||
- [+replace-slide](lark-slides-replace-slide.md) — 给已有页加图 / 换图(`block_insert` / `block_replace`)
|
||||
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 创建 slide 页面(拿到 file_token 后塞进 XML)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user