mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
7 Commits
sun/lark-d
...
feat/outpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
041bf48e0e | ||
|
|
37d490a198 | ||
|
|
4e44e51bef | ||
|
|
e79d49e7e4 | ||
|
|
83352fe00b | ||
|
|
21bfa84edd | ||
|
|
fc8d212a4f |
15
AGENTS.md
15
AGENTS.md
@@ -105,6 +105,20 @@ Signatures that are easy to guess wrong:
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Typed data over loose maps
|
||||
|
||||
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
|
||||
|
||||
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
|
||||
|
||||
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
|
||||
|
||||
### Transcribe faithfully — no silent fallbacks
|
||||
|
||||
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
|
||||
|
||||
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
@@ -116,6 +130,7 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -2,6 +2,37 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
@@ -1438,6 +1469,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
|
||||
@@ -40,6 +40,7 @@ type APIOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JSON bool
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string
|
||||
@@ -88,6 +89,11 @@ 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)
|
||||
}
|
||||
@@ -103,8 +109,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 format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "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)")
|
||||
@@ -116,7 +122,7 @@ Examples:
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
@@ -130,6 +136,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
if opts.Method == "" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"HTTP method must not be empty").
|
||||
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
||||
WithParam("<method>")
|
||||
}
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
@@ -243,9 +256,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
return apiDryRun(f, request, config, opts)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -256,10 +269,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
format, _ := output.ParseFormat(opts.Format)
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
@@ -297,8 +307,19 @@ func apiRun(opts *APIOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
@@ -325,6 +346,24 @@ 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,8 +68,44 @@ 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, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
@@ -79,12 +115,42 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Dry Run") {
|
||||
t.Error("expected dry run output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/test") {
|
||||
t.Error("expected path in dry run output")
|
||||
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", got["data"])
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
if strings.Contains(stdout.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +205,43 @@ 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,
|
||||
@@ -152,6 +255,22 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty HTTP method")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "method") {
|
||||
t.Fatalf("error should name the method argument, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -525,6 +644,46 @@ 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
|
||||
@@ -1000,11 +1159,23 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,7 +355,31 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(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())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -376,8 +400,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected explicit format pretty, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,11 @@ 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()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Format = format
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -41,8 +43,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
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
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -75,10 +80,10 @@ func authScopesRun(opts *ScopesOptions) error {
|
||||
"failed to get app scope info: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
for _, s := range appInfo.UserScopes {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
|
||||
fmt.Fprintf(f.IOStreams.Out, " • %s\n", s)
|
||||
}
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -26,6 +28,35 @@ 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,6 +234,7 @@ 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,11 +4,15 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -26,6 +30,85 @@ 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
|
||||
|
||||
@@ -96,6 +96,40 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
for _, field := range []string{
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"reply_to",
|
||||
"sender_type",
|
||||
"mentions",
|
||||
} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("receive schema missing field %q", field)
|
||||
}
|
||||
}
|
||||
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
|
||||
if !strings.Contains(msgDesc, "Recommended idempotency key") {
|
||||
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
|
||||
}
|
||||
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
|
||||
if strings.Contains(eventDesc, "safe for deduplication") {
|
||||
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
|
||||
@@ -9,28 +9,18 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
@@ -39,7 +29,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
@@ -82,23 +72,86 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
c.Flags().String("find", "", "")
|
||||
err := flagDidYouMean(c, parseFlagError(t, c, "--find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
// Non-unknown-flag errors retain the same validation shape and identify the
|
||||
// flag from pflag's typed ValueRequiredError.
|
||||
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 verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--find" {
|
||||
t.Errorf("Params=%v, want one entry named --find", 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. 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.
|
||||
// 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.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
@@ -283,57 +283,14 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
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.
|
||||
//
|
||||
@@ -345,6 +302,10 @@ func isCobraUsageError(err error) bool {
|
||||
// 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
|
||||
@@ -362,6 +323,132 @@ 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
|
||||
@@ -592,17 +679,21 @@ func isLarkDomain(c *cobra.Command) bool {
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
|
||||
// classifies pflag's typed parse errors and emits one stable validation shape.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
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
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
@@ -614,36 +705,69 @@ 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())
|
||||
}
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
"unknown flag %q for %q", rawName, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint).
|
||||
WithCause(ferr)
|
||||
}
|
||||
|
||||
// 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
|
||||
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"
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
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})
|
||||
}
|
||||
return rest, true
|
||||
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
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
|
||||
111
cmd/root_test.go
111
cmd/root_test.go
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -284,9 +285,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// 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"))
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values").
|
||||
WithParam("--values")
|
||||
exit := handleRootError(f, err)
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -381,10 +382,9 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
|
||||
return errObj
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are
|
||||
// typed at the Cobra validation stage, before the final dispatcher.
|
||||
func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
@@ -393,7 +393,15 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
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)
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -411,6 +419,93 @@ func TestHandleRootError_NoDeprecationTypesUsageError(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,6 +140,7 @@ type ServiceMethodOptions struct {
|
||||
PageLimit int
|
||||
PageDelay int
|
||||
Format string
|
||||
JSON bool
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
@@ -268,6 +269,11 @@ 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)
|
||||
}
|
||||
@@ -299,8 +305,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
|
||||
cmd.Flags().BoolVar(&opts.JSON, "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 {
|
||||
@@ -311,7 +317,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 []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
@@ -403,9 +409,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
return serviceDryRun(f, request, config, opts)
|
||||
}
|
||||
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -420,10 +426,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
format, _ := output.ParseFormat(opts.Format)
|
||||
|
||||
// Scope-insufficient (99991679) and all other Lark API codes route through
|
||||
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
||||
@@ -667,8 +670,19 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
@@ -695,6 +709,24 @@ 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,6 +201,42 @@ 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) {
|
||||
@@ -224,13 +260,39 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), tt.wantInURL) {
|
||||
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
if call["url"] != tt.wantInURL {
|
||||
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"file_token":"boxcn123abc"}`,
|
||||
"--dry-run",
|
||||
"--jq", ".data.api[0].url",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
|
||||
t.Fatalf("jq output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -318,8 +380,12 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Dry Run") {
|
||||
t.Error("expected dry-run output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +498,43 @@ 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,
|
||||
@@ -473,6 +576,48 @@ 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
|
||||
@@ -765,26 +910,23 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) {
|
||||
f, _, _, _ := 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"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: unknown format") {
|
||||
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
|
||||
if validationErr.Param != "--format" {
|
||||
t.Errorf("param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,11 +1223,23 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,38 @@ 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
|
||||
|
||||
@@ -13,17 +13,29 @@ import (
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
|
||||
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
|
||||
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
|
||||
}
|
||||
|
||||
type MentionOutput struct {
|
||||
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
|
||||
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
|
||||
Name string `json:"name,omitempty" desc:"Mentioned display name"`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -36,15 +48,20 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -81,7 +98,54 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
SenderType: envelope.Event.Sender.SenderType,
|
||||
RootID: msg.RootID,
|
||||
ThreadID: msg.ThreadID,
|
||||
ReplyTo: msg.ParentID,
|
||||
Content: content,
|
||||
Mentions: compactMentions(msg.Mentions),
|
||||
}
|
||||
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
|
||||
out.UpdateTime = msg.UpdateTime
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func compactMentions(mentions []interface{}) []MentionOutput {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MentionOutput, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
mention := MentionOutput{
|
||||
Key: stringField(item, "key"),
|
||||
ID: mentionOpenID(item["id"]),
|
||||
Name: stringField(item, "name"),
|
||||
}
|
||||
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
|
||||
out = append(out, mention)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +84,32 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"root_id": "om_root_001",
|
||||
"parent_id": "om_parent_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
"update_time": "1776409469999",
|
||||
"content": "{\"text\":\"hello @_user_1\"}",
|
||||
"mentions": [
|
||||
{
|
||||
"key": "@_user_1",
|
||||
"id": {"open_id": "ou_mentioned"},
|
||||
"name": "Alice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
@@ -110,12 +123,69 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
if out.Content != "hello @Alice" {
|
||||
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"sender_type": "user",
|
||||
"root_id": "om_root_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"reply_to": "om_parent_001",
|
||||
"update_time": "1776409469999",
|
||||
} {
|
||||
if got, _ := outMap[field].(string); got != want {
|
||||
t.Errorf("%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
mentions, _ := outMap["mentions"].([]interface{})
|
||||
if len(mentions) != 1 {
|
||||
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
|
||||
}
|
||||
mention, _ := mentions[0].(map[string]interface{})
|
||||
for field, want := range map[string]string{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_mentioned",
|
||||
"name": "Alice",
|
||||
} {
|
||||
if got, _ := mention[field].(string); got != want {
|
||||
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if _, ok := outMap["update_time"]; ok {
|
||||
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
@@ -188,3 +258,22 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -8,15 +8,29 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
|
||||
type DryRunOutputOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
CommandPath string
|
||||
Identity core.Identity
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// DryRunAPICall describes a single API call in dry-run output.
|
||||
type DryRunAPICall struct {
|
||||
Desc string `json:"desc,omitempty"`
|
||||
@@ -26,12 +40,21 @@ type DryRunAPICall struct {
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunContext is the execution context shared by every dry-run preview:
|
||||
// which app would make the call and, when known, as which user. The identity
|
||||
// itself lives at the envelope top level, not here.
|
||||
type DryRunContext struct {
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
UserOpenID string `json:"user_open_id,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunAPI is the builder and result type for dry-run output.
|
||||
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
|
||||
type DryRunAPI struct {
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
extra map[string]interface{}
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
context *DryRunContext
|
||||
extra map[string]interface{}
|
||||
}
|
||||
|
||||
func NewDryRunAPI() *DryRunAPI {
|
||||
@@ -40,30 +63,22 @@ func NewDryRunAPI() *DryRunAPI {
|
||||
|
||||
// --- HTTP method builders (add a call, return self for chaining) ---
|
||||
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
|
||||
// call appends a request with the method transcribed verbatim, so previews
|
||||
// never misreport what the real client would send.
|
||||
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
|
||||
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
|
||||
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
|
||||
|
||||
// Body sets the request body on the last added call.
|
||||
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
|
||||
@@ -98,12 +113,26 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
|
||||
return d
|
||||
}
|
||||
|
||||
// Context records the calling app/user under data.context; empty values are
|
||||
// omitted, and a fully empty context is not emitted at all.
|
||||
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
|
||||
if appID == "" && userOpenID == "" {
|
||||
return d
|
||||
}
|
||||
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
|
||||
func (d *DryRunAPI) resolveURL(rawURL string) string {
|
||||
for k, v := range d.extra {
|
||||
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
|
||||
}
|
||||
return rawURL
|
||||
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
|
||||
name := token[1:]
|
||||
value, ok := d.extra[name]
|
||||
if !ok {
|
||||
return token
|
||||
}
|
||||
return url.PathEscape(fmt.Sprintf("%v", value))
|
||||
})
|
||||
}
|
||||
|
||||
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
|
||||
@@ -118,13 +147,17 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
|
||||
Body: c.Body,
|
||||
}
|
||||
}
|
||||
m := make(map[string]interface{}, len(d.extra)+2)
|
||||
m := make(map[string]interface{}, len(d.extra)+3)
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
}
|
||||
// Typed fields win over same-named extra keys.
|
||||
if d.desc != "" {
|
||||
m["description"] = d.desc
|
||||
}
|
||||
m["api"] = resolved
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
if d.context != nil {
|
||||
m["context"] = d.context
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -154,11 +187,7 @@ func (d *DryRunAPI) Format() string {
|
||||
u += "?" + encodeParams(c.Params)
|
||||
}
|
||||
|
||||
method := c.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
b.WriteString(method)
|
||||
b.WriteString(c.Method)
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(u)
|
||||
b.WriteByte('\n')
|
||||
@@ -215,83 +244,74 @@ func encodeParams(params map[string]interface{}) string {
|
||||
return vals.Encode()
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
|
||||
// query params, and the app/user context common to every dry-run.
|
||||
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
|
||||
dr := NewDryRunAPI().call(request.Method, request.URL)
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
filePathDisplay := filePath
|
||||
// Identity is reported at the envelope top level, not duplicated here.
|
||||
dr.Context(config.AppID, config.UserOpenId)
|
||||
return dr
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
filePathDisplay := file.FilePath
|
||||
if filePathDisplay == "" {
|
||||
filePathDisplay = "<stdin>"
|
||||
}
|
||||
fileInfo := map[string]any{
|
||||
"file": map[string]string{"field": fileField, "path": filePathDisplay},
|
||||
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
|
||||
}
|
||||
if formFields != nil {
|
||||
fileInfo["form_fields"] = formFields
|
||||
if file.FormFields != nil {
|
||||
fileInfo["form_fields"] = file.FormFields
|
||||
}
|
||||
fileInfo["options"] = []string{"WithFileUpload"}
|
||||
dr.Body(fileInfo)
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
|
||||
// When format is "pretty", outputs human-readable text; otherwise JSON.
|
||||
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
if !util.IsNil(request.Data) {
|
||||
dr.Body(request.Data)
|
||||
}
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
|
||||
// Identity may be empty; the envelope omits it rather than guessing.
|
||||
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
|
||||
if dr == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
|
||||
}
|
||||
// The JqExpr guard is defensive: every entry point already rejects --jq
|
||||
// combined with --format pretty via output.ValidateJqFlags.
|
||||
if opts.Format == "pretty" && opts.JqExpr == "" {
|
||||
// A nil ErrOut only skips the banner decoration (mirroring
|
||||
// WriteSuccessEnvelope's warning path); the payload write to Out
|
||||
// must fail loudly rather than be silently discarded.
|
||||
if opts.ErrOut != nil {
|
||||
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
|
||||
}
|
||||
// stdout carries its own marker so logs that drop stderr still show
|
||||
// this was a preview, not an executed request.
|
||||
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
|
||||
fmt.Fprint(opts.Out, dr.Format())
|
||||
return nil
|
||||
}
|
||||
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(opts.Identity),
|
||||
DryRun: true,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ package cmdutil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -66,11 +69,31 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/:assignee_id").
|
||||
Set("assignee", "ou_bot")
|
||||
|
||||
text := dr.Format()
|
||||
if strings.Contains(text, "ou_bot_id") {
|
||||
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, ":assignee_id") {
|
||||
t.Fatalf("missing unresolved placeholder, got: %s", text)
|
||||
}
|
||||
|
||||
dr.Set("assignee_id", "ou_abc/123")
|
||||
text = dr.Format()
|
||||
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
|
||||
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
Desc("test api").
|
||||
GET("/open-apis/test").
|
||||
Set("as", "user")
|
||||
Set("note", "audit")
|
||||
|
||||
data, err := json.Marshal(dr)
|
||||
if err != nil {
|
||||
@@ -83,8 +106,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
if m["description"] != "test api" {
|
||||
t.Errorf("expected description, got: %v", m["description"])
|
||||
}
|
||||
if m["as"] != "user" {
|
||||
t.Errorf("expected as=user, got: %v", m["as"])
|
||||
if m["note"] != "audit" {
|
||||
t.Errorf("expected note=audit, got: %v", m["note"])
|
||||
}
|
||||
api, ok := m["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
@@ -123,31 +146,67 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
|
||||
|
||||
func TestPrintDryRun_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "user",
|
||||
}, &core.CliConfig{AppID: "app123"}, "json")
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
CommandPath: "lark-cli api",
|
||||
Identity: core.AsUser,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Errorf("expected header, got: %s", out)
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "app123") {
|
||||
t.Errorf("expected appId in output, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected envelope: %#v", env)
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("unexpected data: %#v", env["data"])
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
if _, exists := data["as"]; exists {
|
||||
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/test",
|
||||
Data: map[string]interface{}{"key": "val"},
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app456"}, "pretty")
|
||||
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
|
||||
Format: "pretty",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
@@ -155,6 +214,136 @@ func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
if !strings.Contains(out, "POST /open-apis/test") {
|
||||
t.Errorf("expected POST line in pretty output, got: %s", out)
|
||||
}
|
||||
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
|
||||
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
JqExpr: ".data.api[0].url",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRunWithFile(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRunWithFile failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["path"] != "report.txt" {
|
||||
t.Fatalf("file body = %#v", body)
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
for _, legacy := range []string{"as", "appId", "userOpenId"} {
|
||||
if _, exists := data[legacy]; exists {
|
||||
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "OPTIONS",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
|
||||
if call["method"] != "OPTIONS" {
|
||||
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
}, &core.CliConfig{}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
if _, exists := data["context"]; exists {
|
||||
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
|
||||
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
|
||||
if err == nil {
|
||||
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
|
||||
}
|
||||
var internal *errs.InternalError
|
||||
if !errors.As(err, &internal) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunFormatValue(t *testing.T) {
|
||||
|
||||
61
internal/output/capabilities.go
Normal file
61
internal/output/capabilities.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// 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")
|
||||
}
|
||||
53
internal/output/capabilities_test.go
Normal file
53
internal/output/capabilities_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package output
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`
|
||||
|
||||
@@ -9,6 +9,7 @@ import "io"
|
||||
type SuccessEnvelopeOptions struct {
|
||||
CommandPath string
|
||||
Identity string
|
||||
DryRun bool
|
||||
JqExpr string
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
@@ -41,6 +42,7 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
|
||||
@@ -104,6 +104,47 @@ func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
DryRun: true,
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", env)
|
||||
}
|
||||
if _, ok := env["data"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("data = %#v, want object", env["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
DryRun: true,
|
||||
JqExpr: ".dry_run",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "true" {
|
||||
t.Fatalf("jq output = %q, want true", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&mockProvider{
|
||||
|
||||
@@ -103,6 +103,9 @@ 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 {
|
||||
@@ -149,6 +152,9 @@ 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,6 +73,30 @@ 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{}{
|
||||
@@ -149,6 +173,20 @@ 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,6 +10,7 @@ type Format int
|
||||
|
||||
const (
|
||||
FormatJSON Format = iota
|
||||
FormatPretty
|
||||
FormatNDJSON
|
||||
FormatTable
|
||||
FormatCSV
|
||||
@@ -22,6 +23,8 @@ 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":
|
||||
@@ -36,6 +39,8 @@ 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,6 +14,9 @@ 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},
|
||||
@@ -52,6 +55,7 @@ func TestFormatString(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{FormatJSON, "json"},
|
||||
{FormatPretty, "pretty"},
|
||||
{FormatNDJSON, "ndjson"},
|
||||
{FormatTable, "table"},
|
||||
{FormatCSV, "csv"},
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -94,7 +95,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif
|
||||
Short: c.Short,
|
||||
Example: c.Example,
|
||||
Hidden: c.Hidden,
|
||||
Runnable: c.Runnable(),
|
||||
Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c),
|
||||
Source: source,
|
||||
Generated: cmdmeta.Generated(c),
|
||||
Identities: cmdmeta.Identities(c),
|
||||
|
||||
@@ -90,6 +90,20 @@ 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 {
|
||||
|
||||
@@ -878,16 +878,23 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
|
||||
var firstErr error
|
||||
for start >= 0 {
|
||||
var preview struct {
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
Data struct {
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
|
||||
if err := dec.Decode(&preview); err == nil {
|
||||
if len(preview.API) == 0 {
|
||||
api := preview.API
|
||||
if len(api) == 0 {
|
||||
api = preview.Data.API
|
||||
}
|
||||
if len(api) == 0 {
|
||||
if firstErr == nil {
|
||||
firstErr = errNoDryRunAPI
|
||||
}
|
||||
} else {
|
||||
return preview.API[0], len(preview.API), nil
|
||||
return api[0], len(api), nil
|
||||
}
|
||||
} else if firstErr == nil {
|
||||
firstErr = err
|
||||
|
||||
@@ -33,6 +33,17 @@ func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDryRunJSONReadsSuccessEnvelope(t *testing.T) {
|
||||
raw := `{"ok":true,"dry_run":true,"data":{"api":[{"method":"GET","url":"/open-apis/test"}]}}`
|
||||
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("extractDryRunJSON() error = %v", err)
|
||||
}
|
||||
if got.Method != "GET" || got.URL != "/open-apis/test" || apiCallCount != 1 {
|
||||
t.Fatalf("got request=%#v apiCallCount=%d, want enveloped GET and count 1", got, apiCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDryRunJSONSkipsBannerWithBraces(t *testing.T) {
|
||||
raw := "banner {not json}\n{\"api\":[{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]}\n"
|
||||
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
|
||||
|
||||
@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
// recommend.allow special-cases scopes absent from scope_priorities.json
|
||||
// (application v7 is not in the platform catalog yet) so interactive
|
||||
// login's "common scopes" tier still offers them. Only the read scope is
|
||||
// admitted: write stays out of the recommended tier by design.
|
||||
if !allowSet["application:app_slash_command:read"] {
|
||||
t.Error("expected application:app_slash_command:read in override allow set")
|
||||
}
|
||||
if allowSet["application:app_slash_command:write"] {
|
||||
t.Error("write scope must NOT be in the recommended tier")
|
||||
}
|
||||
if len(allowSet) != 1 {
|
||||
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"allow": [
|
||||
"application:app_slash_command:read"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"application": {
|
||||
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands for the currently bound app" },
|
||||
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理" }
|
||||
},
|
||||
"apps": {
|
||||
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
|
||||
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
|
||||
|
||||
@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.68",
|
||||
"version": "1.0.69",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
18
shortcuts/application/shortcuts.go
Normal file
18
shortcuts/application/shortcuts.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application provides shortcuts for Open Platform app
|
||||
// self-management (slash commands of the current bound app).
|
||||
package application
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all shortcuts of the application domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
SlashCommandUpdate,
|
||||
SlashCommandDelete,
|
||||
}
|
||||
}
|
||||
105
shortcuts/application/slash_command_common.go
Normal file
105
shortcuts/application/slash_command_common.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
|
||||
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
|
||||
|
||||
// clientCacheHint is printed to stderr after every successful write.
|
||||
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
|
||||
|
||||
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
|
||||
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
|
||||
func parseDescriptionI18n(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]string, len(values))
|
||||
for _, v := range values {
|
||||
idx := strings.Index(v, "=")
|
||||
if idx <= 0 || idx == len(v)-1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
lang := strings.TrimSpace(v[:idx])
|
||||
text := v[idx+1:]
|
||||
if lang == "" || strings.TrimSpace(text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: language and text must be non-empty", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
if _, dup := m[lang]; dup {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"duplicate language %q in --description-i18n", lang).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
m[lang] = text
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateCommandName rejects empty and slash-prefixed command names.
|
||||
func validateCommandName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not start with \"/\" - the slash is implied (use %q)",
|
||||
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeCommandIDPathSegment applies the same normalization and escaping to
|
||||
// command IDs in dry-run output and real requests.
|
||||
func encodeCommandIDPathSegment(id string) string {
|
||||
return validate.EncodePathSegment(strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
// buildSlashCommandBody assembles a create/update request body. Only provided
|
||||
// fields are included: PATCH is field-level partial (absent top-level fields
|
||||
// are preserved server-side; a provided i18n map REPLACES the whole map).
|
||||
// icon sits at the top level, sibling of description (verified live; the
|
||||
// official create sample nesting icon inside description is a doc bug).
|
||||
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if command != "" {
|
||||
body["command"] = command
|
||||
}
|
||||
if description != "" || len(i18n) > 0 {
|
||||
desc := map[string]interface{}{}
|
||||
if description != "" {
|
||||
desc["default_value"] = description
|
||||
}
|
||||
if len(i18n) > 0 {
|
||||
desc["i18n"] = i18n
|
||||
}
|
||||
body["description"] = desc
|
||||
}
|
||||
if iconKey != "" {
|
||||
body["icon"] = map[string]interface{}{"icon_key": iconKey}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// isCommandExists reports whether err is the server-side name-collision error
|
||||
// (code=40000000, message contains "command already exists"; verified live).
|
||||
func isCommandExists(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return p.Code == 40000000 && strings.Contains(p.Message, "command already exists")
|
||||
}
|
||||
197
shortcuts/application/slash_command_common_test.go
Normal file
197
shortcuts/application/slash_command_common_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDescriptionI18n_OK(t *testing.T) {
|
||||
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["zh_cn"] != "你好" {
|
||||
t.Errorf("zh_cn = %q", m["zh_cn"])
|
||||
}
|
||||
// 只按首个 = 分割:值内可含 =
|
||||
if m["en_us"] != "Hello=World" {
|
||||
t.Errorf("en_us = %q", m["en_us"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_Empty(t *testing.T) {
|
||||
m, err := parseDescriptionI18n(nil)
|
||||
if err != nil || m != nil {
|
||||
t.Fatalf("nil input: m=%v err=%v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
|
||||
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
|
||||
_, err := parseDescriptionI18n([]string{bad})
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%q: expected validation problem, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
|
||||
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate language error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %v", err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--description-i18n" {
|
||||
t.Fatalf("expected param --description-i18n, got %#v", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandName(t *testing.T) {
|
||||
if err := validateCommandName("greet", "--command"); err != nil {
|
||||
t.Fatalf("greet: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", " ", "/greet"} {
|
||||
if err := validateCommandName(bad, "--command"); err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSlashCommandBody(t *testing.T) {
|
||||
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
|
||||
if body["command"] != "greet" {
|
||||
t.Errorf("command = %v", body["command"])
|
||||
}
|
||||
desc := body["description"].(map[string]interface{})
|
||||
if desc["default_value"] != "hi" {
|
||||
t.Errorf("default_value = %v", desc["default_value"])
|
||||
}
|
||||
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
|
||||
t.Errorf("i18n = %v", desc["i18n"])
|
||||
}
|
||||
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
|
||||
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
|
||||
t.Errorf("icon = %v", body["icon"])
|
||||
}
|
||||
// partial:不提供的字段不出现(PATCH 语义依赖)
|
||||
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
|
||||
if _, has := partial["command"]; has {
|
||||
t.Error("empty command must be omitted")
|
||||
}
|
||||
if _, has := partial["description"]; has {
|
||||
t.Error("empty description must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandExists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matching code and message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000000),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same message with different code",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000031),
|
||||
},
|
||||
{
|
||||
name: "same code with different message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'icon_key'. icon_key is invalid.").WithCode(40000000),
|
||||
},
|
||||
{name: "nil error"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isCommandExists(tt.err); got != tt.want {
|
||||
t.Fatalf("isCommandExists() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
|
||||
// reversal of the OAuth-isolation design: all four slash-command shortcuts
|
||||
// declare identical scopes for the bot and user identities (plain Scopes /
|
||||
// ConditionalScopes, no per-identity overrides), so a user-identity
|
||||
// pre-flight sees the same scope set a bot identity would.
|
||||
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantScope string
|
||||
wantConditional string
|
||||
hasConditional bool
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlashCommandList,
|
||||
wantScope: "application:app_slash_command:read",
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: SlashCommandCreate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: SlashCommandUpdate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: SlashCommandDelete,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, identity := range []string{"user", "bot"} {
|
||||
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
|
||||
if !containsStr(declared, tc.wantScope) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
|
||||
}
|
||||
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
118
shortcuts/application/slash_command_create.go
Normal file
118
shortcuts/application/slash_command_create.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandCreate registers a new slash command on the current bound app.
|
||||
var SlashCommandCreate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-create",
|
||||
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
|
||||
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
|
||||
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
|
||||
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and update it in place"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
|
||||
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description must not be blank").WithParam("--description")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("Create a slash command on the current bound app").
|
||||
POST(slashCommandBasePath).
|
||||
Body(body)
|
||||
if runtime.Bool("force") {
|
||||
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
|
||||
action := "created"
|
||||
if err != nil {
|
||||
if !isCommandExists(err) {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("force") {
|
||||
p, _ := errs.ProblemOf(err)
|
||||
rewrapped := errs.NewAPIError(errs.SubtypeAlreadyExists, "slash command %q already exists", name).
|
||||
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
|
||||
WithCause(err)
|
||||
if p.Code != 0 {
|
||||
rewrapped = rewrapped.WithCode(p.Code)
|
||||
}
|
||||
if p.LogID != "" {
|
||||
rewrapped = rewrapped.WithLogID(p.LogID)
|
||||
}
|
||||
return rewrapped
|
||||
}
|
||||
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
|
||||
id, rerr := resolveCommandID(runtime, name)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, patchBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action = "updated"
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = action
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
229
shortcuts/application/slash_command_create_test.go
Normal file
229
shortcuts/application/slash_command_create_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func createOKStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", "id-new"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createConflictStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func patchOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/application/v7/app_slash_commands/" + id,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_OK(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createOKStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi",
|
||||
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
|
||||
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "created" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
if data["command_id"] != "id-new" {
|
||||
t.Fatalf("command_id = %v", data["command_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := [][]string{
|
||||
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
|
||||
}
|
||||
for i, args := range cases {
|
||||
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("case %d: expected validation error", i)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("case %d: expected validation problem, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeAlreadyExists || p.Code != 40000000 {
|
||||
t.Fatalf("expected api/already_exists code 40000000, got %#v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
|
||||
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
|
||||
}
|
||||
if errors.Unwrap(apiErr) == nil {
|
||||
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v (force must convert to update)", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_TrimsCommandBeforeCreateAndForceResolution(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
conflict := createConflictStub()
|
||||
reg.Register(conflict)
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", " greet ", "--description", "hi", "--force", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(conflict.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured create body: %v", err)
|
||||
}
|
||||
if body["command"] != "greet" {
|
||||
t.Fatalf("command = %q, want trimmed value %q", body["command"], "greet")
|
||||
}
|
||||
}
|
||||
|
||||
func createIconInvalidStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
|
||||
// blindly treating ANY POST failure as a name collision: only the
|
||||
// "command already exists" (40000000) shape may fall through to the
|
||||
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
|
||||
// the code mistakenly attempted a PATCH, the httpmock registry would fail
|
||||
// the unexpected request and surface a different (registry) error instead
|
||||
// of the original icon_key failure asserted below.
|
||||
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createIconInvalidStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the original icon_key error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype == errs.SubtypeAlreadyExists || p.Code != 40000031 {
|
||||
t.Fatalf("expected original API error code 40000031 without collision reclassification, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
|
||||
t.Fatalf("dry-run must show POST path: %s", out)
|
||||
}
|
||||
// icon 顶层:dry-run body 里 icon 不嵌套在 description 内
|
||||
if !strings.Contains(out, "icon_key") {
|
||||
t.Fatalf("dry-run must include body: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceHelpHasNoMetavar(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
SlashCommandCreate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
forceFlag := cmd.Flags().Lookup("force")
|
||||
if forceFlag == nil {
|
||||
t.Fatal("missing --force flag")
|
||||
}
|
||||
placeholder, usage := pflag.UnquoteUsage(forceFlag)
|
||||
if placeholder != "" {
|
||||
t.Fatalf("boolean --force must not render a value placeholder, got %q", placeholder)
|
||||
}
|
||||
if !strings.Contains(usage, "update it in place") || strings.Contains(usage, "gh ") {
|
||||
t.Fatalf("unexpected --force help: %q", usage)
|
||||
}
|
||||
if help := cmd.Flags().FlagUsages(); !strings.Contains(help, "--force") || !strings.Contains(help, "update it in place") {
|
||||
t.Fatalf("rendered help missing --force description:\n%s", help)
|
||||
}
|
||||
}
|
||||
85
shortcuts/application/slash_command_delete.go
Normal file
85
shortcuts/application/slash_command_delete.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandDelete removes a slash command (irreversible; command_id is not
|
||||
// reused - recreating the same name yields a NEW id).
|
||||
var SlashCommandDelete = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-delete",
|
||||
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-delete --command greet --yes --as bot",
|
||||
"deleted commands may linger in clients for ~5 minutes (client cache)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
return validateCommandName(name, "--command")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.DELETE(slashCommandBasePath + "/" + target)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"action": "deleted", "command_id": id}
|
||||
if name != "" {
|
||||
out["command"] = name
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "deleted command_id %s\n", id)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
135
shortcuts/application/slash_command_delete_test.go
Normal file
135
shortcuts/application/slash_command_delete_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func deleteOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: slashCommandBasePath + "/" + id,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
if errs.CategoryOf(err) != errs.CategoryConfirmation {
|
||||
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
// 上游 DELETE 返回空对象;CLI 必须补 action/command_id(写操作返回资源 ID)
|
||||
if data["action"] != "deleted" || data["command_id"] != "id1" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
|
||||
reg.Register(deleteOKStub("id7"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["command"] != "greet" || data["command_id"] != "id7" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "DELETE" || strings.Contains(got.API[1].Desc, "resolve command_id") {
|
||||
t.Fatalf("second call must be the delete without the resolve description: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
for _, args := range [][]string{
|
||||
{"+slash-command-delete", "--yes", "--as", "bot"},
|
||||
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
|
||||
} {
|
||||
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%v: expected validation error", args)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%v: expected validation problem, got %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", " id/with space?x ", "--yes", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
58
shortcuts/application/slash_command_list.go
Normal file
58
shortcuts/application/slash_command_list.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandList lists all slash commands of the current bound app.
|
||||
var SlashCommandList = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-list",
|
||||
Description: "List all slash commands (/ commands) registered on the currently bound Open Platform app; source of command_id for update/delete",
|
||||
Risk: "read",
|
||||
Scopes: []string{"application:app_slash_command:read"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-list --as bot",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
|
||||
"the upstream API returns all commands at once (max 100 per app, no pagination)",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List all slash commands of the current bound app (read-only)").
|
||||
GET(slashCommandBasePath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out := map[string]interface{}{"items": items, "count": len(items)}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if d, ok := m["description"].(map[string]interface{}); ok {
|
||||
desc, _ = d["default_value"].(string)
|
||||
}
|
||||
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
115
shortcuts/application/slash_command_list_test.go
Normal file
115
shortcuts/application/slash_command_list_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func appTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
|
||||
// Mirrors shortcuts/contact tests.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func listStub(items []interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"items": items},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleItem(name, id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"command": name, "command_id": id,
|
||||
"create_time": "1783318553", "update_time": "1783318553",
|
||||
"description": map[string]interface{}{"default_value": "desc of " + name},
|
||||
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
if data["count"] != float64(2) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
first := items[0].(map[string]interface{})
|
||||
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
|
||||
if _, ok := first[k]; !ok {
|
||||
t.Errorf("missing item key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("empty list must be [] not %v", data["items"])
|
||||
}
|
||||
if data["count"] != float64(0) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
|
||||
t.Fatalf("dry-run must show GET path, got %s", out)
|
||||
}
|
||||
}
|
||||
54
shortcuts/application/slash_command_resolve.go
Normal file
54
shortcuts/application/slash_command_resolve.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// matchCommandID finds the command_id of the item whose "command" equals
|
||||
// name (exact match - the server enforces name uniqueness, so first hit is the
|
||||
// only hit).
|
||||
func matchCommandID(items []interface{}, name string) string {
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["command"] == name {
|
||||
id, _ := m["command_id"].(string)
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandNotFoundError reports a resolution miss against the live list as an
|
||||
// API-category not-found error (the name is a valid argument shape; the
|
||||
// resource simply does not exist server-side - this is not a validation
|
||||
// failure of caller input).
|
||||
func commandNotFoundError(name string) error {
|
||||
return errs.NewAPIError(errs.SubtypeNotFound,
|
||||
"slash command %q not found in the current bound app", name).
|
||||
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
|
||||
}
|
||||
|
||||
// resolveCommandID resolves a command name to its command_id via the live
|
||||
// list endpoint (in-memory only; never touches local files). Requires the
|
||||
// read scope on the current identity.
|
||||
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, error) {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
id := matchCommandID(items, name)
|
||||
if id == "" {
|
||||
return "", commandNotFoundError(name)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
shortcuts/application/slash_command_resolve_test.go
Normal file
41
shortcuts/application/slash_command_resolve_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestMatchCommandID(t *testing.T) {
|
||||
items := []interface{}{
|
||||
sampleItem("greet", "id1"),
|
||||
sampleItem("weather", "id2"),
|
||||
}
|
||||
id := matchCommandID(items, "weather")
|
||||
if id != "id2" {
|
||||
t.Fatalf("got id=%q", id)
|
||||
}
|
||||
id = matchCommandID(items, "nope")
|
||||
if id != "" {
|
||||
t.Fatalf("miss should return empty, got id=%q", id)
|
||||
}
|
||||
// 精确匹配:大小写与空白不做宽容
|
||||
id = matchCommandID(items, "Greet")
|
||||
if id != "" {
|
||||
t.Fatalf("match must be exact, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotFoundErrorShape(t *testing.T) {
|
||||
err := commandNotFoundError("nope")
|
||||
if err == nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
124
shortcuts/application/slash_command_update.go
Normal file
124
shortcuts/application/slash_command_update.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
|
||||
// least one editable field; --description-i18n requires --description (PATCH
|
||||
// replaces the whole description object - sending i18n alone would drop
|
||||
// default_value, so both values must be provided together).
|
||||
func validateUpdateTarget(runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
if err := validateCommandName(name, "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
|
||||
hasI18n := len(runtime.StrArray("description-i18n")) > 0
|
||||
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
|
||||
if !hasDesc && !hasI18n && !hasIcon {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
|
||||
}
|
||||
if hasI18n && !hasDesc {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
|
||||
var SlashCommandUpdate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-update",
|
||||
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
|
||||
{Name: "description", Desc: "new default description (description.default_value)"},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
|
||||
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
|
||||
"PATCH is field-level partial: fields you do not pass are preserved server-side",
|
||||
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateUpdateTarget(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI()
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.PATCH(slashCommandBasePath + "/" + target).
|
||||
Desc("Update a slash command by command_id").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = "updated"
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
150
shortcuts/application/slash_command_update_test.go
Normal file
150
shortcuts/application/slash_command_update_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestSlashCommandUpdate_ByID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
|
||||
reg.Register(patchOKStub("id9"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
|
||||
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
|
||||
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
|
||||
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected validation error", c.name)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: expected validation problem, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", " greet ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
if strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "PATCH" || !strings.Contains(got.API[1].Desc, "Update a slash command") {
|
||||
t.Fatalf("second call must describe update: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
|
||||
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
|
||||
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)
|
||||
}
|
||||
}
|
||||
@@ -23,19 +23,21 @@ func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
if env.Data.API[0].Method != "POST" || env.Data.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.Data.API[0].Method, env.Data.API[0].URL)
|
||||
}
|
||||
body := env.API[0].Body
|
||||
body := env.Data.API[0].Body
|
||||
if _, ok := body["start_timestamp_ns"]; !ok {
|
||||
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
|
||||
}
|
||||
@@ -92,14 +94,16 @@ func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
filter := env.API[0].Body["filter"].(map[string]interface{})
|
||||
filter := env.Data.API[0].Body["filter"].(map[string]interface{})
|
||||
deviceTypes := filter["device_types"].([]interface{})
|
||||
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
|
||||
t.Fatalf("device_types = %#v", deviceTypes)
|
||||
|
||||
@@ -101,14 +101,16 @@ func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
a := env.Data.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
@@ -136,13 +138,15 @@ func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
if env.Data.API[0].Body["enabled"] != false || env.Data.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.Data.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
@@ -178,14 +182,16 @@ func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
a := env.Data.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
|
||||
@@ -37,13 +37,7 @@ func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbChangelogURL {
|
||||
|
||||
@@ -71,13 +71,7 @@ func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
|
||||
@@ -97,14 +97,7 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbDataImportURL {
|
||||
@@ -131,12 +124,11 @@ func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if len(env.API) != 1 {
|
||||
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
|
||||
}
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
@@ -174,11 +166,7 @@ func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Params["table"] != "customers" {
|
||||
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
|
||||
|
||||
@@ -30,13 +30,7 @@ func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
@@ -91,11 +85,7 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
@@ -180,13 +170,7 @@ func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
@@ -331,14 +315,11 @@ func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if len(env.API) != 1 {
|
||||
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
|
||||
}
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
|
||||
@@ -165,14 +165,7 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -254,11 +247,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
@@ -79,11 +79,7 @@ func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
@@ -165,13 +165,7 @@ func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -196,11 +190,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
@@ -149,11 +149,7 @@ func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -228,11 +224,7 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
}
|
||||
@@ -353,13 +345,7 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
got := stdout.String()
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
|
||||
@@ -48,13 +48,7 @@ func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileDeleteURL {
|
||||
|
||||
@@ -41,12 +41,7 @@ func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
|
||||
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)
|
||||
|
||||
@@ -46,13 +46,7 @@ func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
|
||||
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
|
||||
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)
|
||||
|
||||
@@ -95,13 +95,7 @@ func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -139,11 +133,7 @@ func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
|
||||
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
|
||||
if _, ok := env.API[0].Params[banned]; ok {
|
||||
|
||||
@@ -22,13 +22,7 @@ func TestAppsFileSign_DryRunBody(t *testing.T) {
|
||||
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" {
|
||||
|
||||
@@ -76,13 +76,7 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
|
||||
|
||||
@@ -737,15 +737,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
|
||||
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
|
||||
// directly rather than via parseEnvelopeData.
|
||||
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
data, err := decodeDryRunDataMap(stdout.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String())
|
||||
}
|
||||
desc, _ := data["description"].(string)
|
||||
@@ -1447,8 +1444,8 @@ func TestAppsInit_DryRun_DescribesEnvPull(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &m); err != nil {
|
||||
m, err := decodeDryRunDataMap(stdout.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String())
|
||||
}
|
||||
ep, _ := m["env_pull"].(string)
|
||||
|
||||
@@ -25,13 +25,7 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
@@ -39,13 +39,7 @@ func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -90,11 +84,7 @@ func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
@@ -24,13 +24,7 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -73,13 +67,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
46
shortcuts/apps/dryrun_test.go
Normal file
46
shortcuts/apps/dryrun_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type dryRunAPICall struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
}
|
||||
|
||||
type dryRunAPIEnvelope struct {
|
||||
API []dryRunAPICall
|
||||
}
|
||||
|
||||
func (e *dryRunAPIEnvelope) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Data struct {
|
||||
API []dryRunAPICall `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
e.API = raw.Data.API
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeDryRunDataMap(data []byte) (map[string]interface{}, error) {
|
||||
var raw struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw.Data == nil {
|
||||
return nil, fmt.Errorf("dry-run stdout is not a success envelope: %s", data)
|
||||
}
|
||||
return raw.Data, nil
|
||||
}
|
||||
@@ -38,25 +38,27 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
LocalEffects []string `json:"local_effects"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
LocalEffects []string `json:"local_effects"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1", len(payload.API))
|
||||
if len(payload.Data.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1", len(payload.Data.API))
|
||||
}
|
||||
call := payload.API[0]
|
||||
call := payload.Data.API[0]
|
||||
if call.Method != "GET" {
|
||||
t.Fatalf("method = %q, want GET", call.Method)
|
||||
}
|
||||
@@ -69,19 +71,19 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
if call.Body != nil {
|
||||
t.Fatalf("body = %#v, want nil", call.Body)
|
||||
}
|
||||
if payload.Mode != "api-plus-local-setup" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
if payload.Data.Mode != "api-plus-local-setup" {
|
||||
t.Fatalf("mode = %q", payload.Data.Mode)
|
||||
}
|
||||
if payload.Action != "initialize_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
if payload.Data.Action != "initialize_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Data.Action)
|
||||
}
|
||||
if payload.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.AppID)
|
||||
if payload.Data.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.Data.AppID)
|
||||
}
|
||||
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.MetadataFile)
|
||||
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.LocalEffects, []string{
|
||||
assertStringSliceEqual(t, payload.Data.LocalEffects, []string{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
@@ -96,32 +98,34 @@ func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
StorageRoot string `json:"storage_root"`
|
||||
Reads []string `json:"reads"`
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
StorageRoot string `json:"storage_root"`
|
||||
Reads []string `json:"reads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
|
||||
t.Fatalf("description = %q", payload.Description)
|
||||
if payload.Data.Description != "Preview local Git credential listing (no API call, read-only local state)." {
|
||||
t.Fatalf("description = %q", payload.Data.Description)
|
||||
}
|
||||
if len(payload.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.API))
|
||||
if len(payload.Data.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
|
||||
}
|
||||
if payload.Mode != "local-read-only" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
if payload.Data.Mode != "local-read-only" {
|
||||
t.Fatalf("mode = %q", payload.Data.Mode)
|
||||
}
|
||||
if payload.Action != "list_local_git_credentials" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
if payload.Data.Action != "list_local_git_credentials" {
|
||||
t.Fatalf("action = %q", payload.Data.Action)
|
||||
}
|
||||
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
|
||||
t.Fatalf("storage_root = %q", payload.StorageRoot)
|
||||
if !strings.HasSuffix(payload.Data.StorageRoot, filepath.Join("spark")) {
|
||||
t.Fatalf("storage_root = %q", payload.Data.StorageRoot)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.Reads, []string{
|
||||
assertStringSliceEqual(t, payload.Data.Reads, []string{
|
||||
"scan app-scoped git credential metadata under the CLI config directory",
|
||||
"derive per-app repository URLs and local credential status from local metadata",
|
||||
})
|
||||
@@ -135,36 +139,38 @@ func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
Effects []string `json:"effects"`
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []interface{} `json:"api"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
AppID string `json:"app_id"`
|
||||
MetadataFile string `json:"metadata_file"`
|
||||
Effects []string `json:"effects"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
|
||||
t.Fatalf("description = %q", payload.Description)
|
||||
if payload.Data.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
|
||||
t.Fatalf("description = %q", payload.Data.Description)
|
||||
}
|
||||
if len(payload.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.API))
|
||||
if len(payload.Data.API) != 0 {
|
||||
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
|
||||
}
|
||||
if payload.Mode != "local-cleanup-only" {
|
||||
t.Fatalf("mode = %q", payload.Mode)
|
||||
if payload.Data.Mode != "local-cleanup-only" {
|
||||
t.Fatalf("mode = %q", payload.Data.Mode)
|
||||
}
|
||||
if payload.Action != "remove_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Action)
|
||||
if payload.Data.Action != "remove_local_git_credential" {
|
||||
t.Fatalf("action = %q", payload.Data.Action)
|
||||
}
|
||||
if payload.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.AppID)
|
||||
if payload.Data.AppID != "app_xxx" {
|
||||
t.Fatalf("app_id = %q", payload.Data.AppID)
|
||||
}
|
||||
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.MetadataFile)
|
||||
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
|
||||
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
|
||||
}
|
||||
assertStringSliceEqual(t, payload.Effects, []string{
|
||||
assertStringSliceEqual(t, payload.Data.Effects, []string{
|
||||
"read app-scoped git credential metadata",
|
||||
"remove the saved PAT from the local system credential store",
|
||||
"remove the app-scoped Git helper from global git config when present",
|
||||
|
||||
@@ -1222,7 +1222,7 @@ func TestAgenda_Success(t *testing.T) {
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--format", "prettry",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
|
||||
@@ -752,12 +752,9 @@ 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) {
|
||||
outFn := ctx.Out
|
||||
if raw {
|
||||
outFn = ctx.OutRaw
|
||||
}
|
||||
emitJSON := func() { ctx.emit(data, meta, raw, true) }
|
||||
if ctx.JqExpr != "" {
|
||||
outFn(data, meta)
|
||||
emitJSON()
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
@@ -773,10 +770,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
if prettyFn != nil {
|
||||
prettyFn(ctx.IO().Out)
|
||||
} else {
|
||||
outFn(data, meta)
|
||||
output.FormatValue(ctx.IO().Out, data, output.FormatPretty)
|
||||
}
|
||||
case "json", "":
|
||||
outFn(data, meta)
|
||||
emitJSON()
|
||||
default:
|
||||
// table, csv, ndjson — pass data directly; FormatValue handles both
|
||||
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
||||
@@ -790,7 +787,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
}
|
||||
format, formatOK := output.ParseFormat(ctx.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
||||
ctx.outputErrOnce.Do(func() {
|
||||
ctx.outputErr = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported output format %q", ctx.Format).WithParam("--format")
|
||||
})
|
||||
return
|
||||
}
|
||||
output.FormatValue(ctx.IO().Out, data, format)
|
||||
}
|
||||
@@ -928,6 +929,13 @@ 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
|
||||
@@ -1027,8 +1035,11 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
format, err := normalizeShortcutOutputFormat(cmd, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rctx.Format = format
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
@@ -1070,7 +1081,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if rctx.stdinConsumed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
|
||||
}
|
||||
rctx.stdinConsumed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
@@ -1104,9 +1115,16 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
verr := ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
// Rejected @file paths are usually absolute (temp files under
|
||||
// /tmp). Steer toward stdin rather than cd / copying the file
|
||||
// into the project tree.
|
||||
verr = verr.WithHint("this flag also reads stdin: pipe the file contents into this command and pass --%s -", fl.Name)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
@@ -1146,14 +1164,19 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
|
||||
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
||||
WithParam("--dry-run")
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
|
||||
dryResult := s.DryRun(rctx.ctx, rctx)
|
||||
if rctx.Format == "pretty" {
|
||||
fmt.Fprint(f.IOStreams.Out, dryResult.Format())
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, dryResult)
|
||||
if dryResult != nil {
|
||||
// Same data.context contract as the service/api dry-run paths.
|
||||
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
|
||||
}
|
||||
return nil
|
||||
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
|
||||
Format: rctx.Format,
|
||||
JqExpr: rctx.JqExpr,
|
||||
CommandPath: rctx.Cmd.CommandPath(),
|
||||
Identity: rctx.As(),
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
}
|
||||
|
||||
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
|
||||
@@ -1188,15 +1211,23 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "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.
|
||||
// "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.
|
||||
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" {
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
if len(fl.Enum) > 0 {
|
||||
return output.NewFormatCapabilities(fl.Enum...)
|
||||
}
|
||||
return output.NewFormatCapabilities(fl.Default)
|
||||
}
|
||||
}
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
return output.StandardFormats
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
@@ -1231,15 +1262,32 @@ 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) {
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
_, _ = normalizeShortcutOutputFormat(cmd, s)
|
||||
}
|
||||
|
||||
func normalizeShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
|
||||
format, err := resolveShortcutOutputFormat(cmd, s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
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 set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
@@ -1301,9 +1349,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 format: json (default) | pretty | table | ndjson | csv")
|
||||
cmd.Flags().String("format", "json", output.StandardFormats.Usage())
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
|
||||
@@ -5,9 +5,13 @@ 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"
|
||||
)
|
||||
|
||||
@@ -37,3 +41,87 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,35 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
|
||||
// an @file path is rejected (typically an absolute /tmp path): flags that
|
||||
// also accept stdin must explain the portable `--flag -` form — never cd'ing
|
||||
// into the target directory or copying the file into the project tree.
|
||||
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
|
||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
||||
|
||||
err := resolveInputFlags(rctx, flags)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected @file path")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--csv")
|
||||
if !strings.Contains(vErr.Hint, "pipe the file contents") || !strings.Contains(vErr.Hint, "--csv -") {
|
||||
t.Errorf("hint %q should explain the portable stdin form", vErr.Hint)
|
||||
}
|
||||
|
||||
// A flag without stdin support must not get the stdin hint.
|
||||
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
|
||||
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected @file path")
|
||||
}
|
||||
vErr = assertValidationParam(t, err, "--file")
|
||||
if strings.Contains(vErr.Hint, "stdin") {
|
||||
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||
|
||||
@@ -6,6 +6,7 @@ package common
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -229,6 +230,75 @@ func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
|
||||
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run in dry-run")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("dry-run", "true")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
if err := runShortcut(cmd, f, s, false); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
stdout := f.IOStreams.Out.(*bytes.Buffer)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", env)
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
if call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", call)
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "test" {
|
||||
t.Fatalf("runner must inject data.context like the service/api paths, got: %#v", data["context"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_DryRunWithJq(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
|
||||
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run in dry-run")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("dry-run", "true")
|
||||
cmd.Flags().Set("jq", ".dry_run")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
if err := runShortcut(cmd, f, s, false); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
stdout := f.IOStreams.Out.(*bytes.Buffer)
|
||||
if got := strings.TrimSpace(stdout.String()); got != "true" {
|
||||
t.Fatalf("jq output = %q, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext("", "")
|
||||
|
||||
|
||||
@@ -45,6 +45,16 @@ func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
func dryRunDataMap(t *testing.T, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := decodeJSONMap(t, raw)
|
||||
data, ok := out["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("dry-run data is %T, want map[string]interface{}\nstdout:\n%s", out["data"], raw)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mustMapValue(t *testing.T, value interface{}, path string) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
@@ -1628,8 +1638,8 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "slide block comment") {
|
||||
t.Fatalf("dry-run output missing slide block comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
@@ -1656,8 +1666,8 @@ func TestDryRunBaseDirectURL(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "record-local comment") {
|
||||
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
@@ -1699,8 +1709,8 @@ func TestDryRunWikiResolvesToSlides(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "slide block comment") {
|
||||
t.Fatalf("dry-run output missing slide block comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
@@ -1736,8 +1746,8 @@ func TestDryRunWikiSlidesInvalidBlockIDSurfaces(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "slide --block-id must be") || !strings.Contains(stdout.String(), "shape_2") {
|
||||
t.Fatalf("dry-run output missing block-id format error: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 0 {
|
||||
t.Fatalf("dry-run should not preview API calls with malformed block-id: %s", stdout.String())
|
||||
}
|
||||
@@ -1821,8 +1831,8 @@ func TestDryRunFileDirectURL(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "verify supported file metadata") {
|
||||
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
out := dryRunDataMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "data.api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
|
||||
@@ -54,15 +54,21 @@ type ImportParams struct {
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
// FileExtension optionally overrides the extension inferred from File's
|
||||
// name. Leave empty to infer from File (the default). Callers that have
|
||||
// sniffed the file's real container use this to correct a mislabeled name
|
||||
// so the backend receives the true format.
|
||||
FileExtension string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
EffectiveExt: strings.TrimPrefix(strings.ToLower(p.FileExtension), "."),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +133,7 @@ func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportPara
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
@@ -203,14 +209,14 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64,
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file")
|
||||
}
|
||||
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
|
||||
if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, info.Size()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
|
||||
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
|
||||
extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType)
|
||||
if err != nil {
|
||||
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
|
||||
}
|
||||
|
||||
@@ -59,14 +59,39 @@ type driveImportSpec struct {
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string // existing bitable token to import data into (only for type=bitable)
|
||||
|
||||
// EffectiveExt is a caller-supplied override for the extension otherwise
|
||||
// derived from FilePath (see ImportParams.FileExtension). It lets a caller
|
||||
// that has detected the file's real container correct a mislabeled name
|
||||
// (e.g. an OOXML workbook saved as .xls). Empty means "trust the filename".
|
||||
EffectiveExt string
|
||||
}
|
||||
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
// rawExtension is the lowercased extension taken verbatim from the file name.
|
||||
func (s driveImportSpec) rawExtension() string {
|
||||
return strings.TrimPrefix(strings.ToLower(filepath.Ext(s.FilePath)), ".")
|
||||
}
|
||||
|
||||
// FileExtension is the extension the import pipeline treats as authoritative:
|
||||
// the content-sniffed override when set, otherwise the file name's extension.
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
if s.EffectiveExt != "" {
|
||||
return s.EffectiveExt
|
||||
}
|
||||
return s.rawExtension()
|
||||
}
|
||||
|
||||
// SourceFileName is the name used when staging the upload media. When content
|
||||
// sniffing corrected the extension, the staged name must carry the corrected
|
||||
// suffix too: the import backend cross-checks the media file name's extension
|
||||
// against the file_extension in the import task and rejects a mismatch with
|
||||
// "import file extension not match" (code 1069910).
|
||||
func (s driveImportSpec) SourceFileName() string {
|
||||
return filepath.Base(s.FilePath)
|
||||
base := filepath.Base(s.FilePath)
|
||||
if s.EffectiveExt != "" && s.EffectiveExt != s.rawExtension() {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base)) + "." + s.EffectiveExt
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (s driveImportSpec) TargetFileName() string {
|
||||
@@ -97,18 +122,20 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
// endpoint and returns the file token consumed by import_tasks.
|
||||
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
|
||||
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, spec driveImportSpec) (string, error) {
|
||||
filePath := spec.FilePath
|
||||
fileName := spec.SourceFileName()
|
||||
importInfo, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return "", driveInputStatError(err)
|
||||
}
|
||||
|
||||
fileSize := importInfo.Size()
|
||||
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
|
||||
if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
extra, err := buildImportMediaExtra(filePath, docType)
|
||||
extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -139,12 +166,12 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
})
|
||||
}
|
||||
|
||||
func buildImportMediaExtra(filePath, docType string) (string, error) {
|
||||
func buildImportMediaExtra(ext, docType string) (string, error) {
|
||||
// The import media endpoint uses extra to decide both the target native type
|
||||
// and how to interpret the uploaded source file.
|
||||
extraBytes, err := json.Marshal(map[string]string{
|
||||
"obj_type": docType,
|
||||
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
|
||||
"file_extension": ext,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err)
|
||||
@@ -152,10 +179,10 @@ func buildImportMediaExtra(filePath, docType string) (string, error) {
|
||||
return string(extraBytes), nil
|
||||
}
|
||||
|
||||
func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
func driveImportFileSizeLimit(ext, docType string) (int64, bool) {
|
||||
// Keep the limit mapping local to import flows so we do not widen behavior
|
||||
// changes beyond drive +import.
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
switch ext {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "pptx":
|
||||
@@ -174,13 +201,12 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateDriveImportFileSize(filePath, docType string, fileSize int64) error {
|
||||
limit, ok := driveImportFileSizeLimit(filePath, docType)
|
||||
func validateDriveImportFileSize(ext, docType string, fileSize int64) error {
|
||||
limit, ok := driveImportFileSizeLimit(ext, docType)
|
||||
if !ok || fileSize <= limit {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if ext == "csv" {
|
||||
// CSV is the only source format whose limit depends on the target type.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
|
||||
@@ -94,61 +94,61 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
ext string
|
||||
docType string
|
||||
fileSize int64
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "docx exceeds 600mb limit",
|
||||
filePath: "./report.docx",
|
||||
ext: "docx",
|
||||
docType: "docx",
|
||||
fileSize: driveImport600MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 600.0 MB import limit for .docx",
|
||||
},
|
||||
{
|
||||
name: "csv sheet exceeds 20mb limit",
|
||||
filePath: "./data.csv",
|
||||
ext: "csv",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport20MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet",
|
||||
},
|
||||
{
|
||||
name: "csv bitable exceeds 100mb limit",
|
||||
filePath: "./data.csv",
|
||||
ext: "csv",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport100MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable",
|
||||
},
|
||||
{
|
||||
name: "xlsx within 800mb limit",
|
||||
filePath: "./data.xlsx",
|
||||
ext: "xlsx",
|
||||
docType: "sheet",
|
||||
fileSize: driveImport800MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "pptx exceeds 500mb limit",
|
||||
filePath: "./deck.pptx",
|
||||
ext: "pptx",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 500.0 MB import limit for .pptx",
|
||||
},
|
||||
{
|
||||
name: "pptx within 500mb limit",
|
||||
filePath: "./deck.pptx",
|
||||
ext: "pptx",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "base exceeds 20mb limit",
|
||||
filePath: "./snapshot.base",
|
||||
ext: "base",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport20MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 20.0 MB import limit for .base",
|
||||
},
|
||||
{
|
||||
name: "base within 20mb limit",
|
||||
filePath: "./snapshot.base",
|
||||
ext: "base",
|
||||
docType: "bitable",
|
||||
fileSize: driveImport20MBFileSizeLimit,
|
||||
},
|
||||
@@ -158,7 +158,7 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize)
|
||||
err := validateDriveImportFileSize(tt.ext, tt.docType, tt.fileSize)
|
||||
if tt.wantText == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
|
||||
@@ -518,15 +518,17 @@ func TestDriveMemberAdd_PermDefaultsToView(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.API[0].Body["perm"] != "view" {
|
||||
t.Fatalf("perm = %v, want view", got.API[0].Body["perm"])
|
||||
if got.Data.API[0].Body["perm"] != "view" {
|
||||
t.Fatalf("perm = %v, want view", got.Data.API[0].Body["perm"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,18 +627,20 @@ func TestDriveMemberAdd_DryRunAcceptsAppID(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.API[0].Body["member_type"] != "appid" {
|
||||
t.Fatalf("member_type = %v, want appid", got.API[0].Body["member_type"])
|
||||
if got.Data.API[0].Body["member_type"] != "appid" {
|
||||
t.Fatalf("member_type = %v, want appid", got.Data.API[0].Body["member_type"])
|
||||
}
|
||||
if _, ok := got.API[0].Body["type"]; ok {
|
||||
t.Fatalf("type = %v, want omitted for appid", got.API[0].Body["type"])
|
||||
if _, ok := got.Data.API[0].Body["type"]; ok {
|
||||
t.Fatalf("type = %v, want omitted for appid", got.Data.API[0].Body["type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,15 +664,17 @@ func TestDriveMemberAdd_DryRunAcceptsWikiSpaceID(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.API[0].Body["member_type"] != "wikispaceid" || got.API[0].Body["type"] != "wiki_space_viewer" {
|
||||
t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.API[0].Body)
|
||||
if got.Data.API[0].Body["member_type"] != "wikispaceid" || got.Data.API[0].Body["type"] != "wiki_space_viewer" {
|
||||
t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.Data.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,20 +798,22 @@ func TestDriveMemberAdd_DryRunInfersTypeAndDefaultsWikiPermType(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1; stdout=%s", len(got.API), stdout.String())
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1; stdout=%s", len(got.Data.API), stdout.String())
|
||||
}
|
||||
api := got.API[0]
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/wikTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
@@ -836,19 +844,21 @@ func TestDriveMemberAdd_DryRunAcceptsUppercaseEnumsForDocx(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.API[0].Params["type"] != "docx" {
|
||||
t.Fatalf("params.type = %v, want docx", got.API[0].Params["type"])
|
||||
if got.Data.API[0].Params["type"] != "docx" {
|
||||
t.Fatalf("params.type = %v, want docx", got.Data.API[0].Params["type"])
|
||||
}
|
||||
if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" {
|
||||
t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body)
|
||||
if got.Data.API[0].Body["member_type"] != "openid" || got.Data.API[0].Body["perm"] != "edit" {
|
||||
t.Fatalf("body = %#v, want canonical lowercase enum values", got.Data.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,19 +882,21 @@ func TestDriveMemberAdd_DryRunAcceptsUppercaseWikiPermType(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.API[0].Params["type"] != "wiki" {
|
||||
t.Fatalf("params.type = %v, want wiki", got.API[0].Params["type"])
|
||||
if got.Data.API[0].Params["type"] != "wiki" {
|
||||
t.Fatalf("params.type = %v, want wiki", got.Data.API[0].Params["type"])
|
||||
}
|
||||
if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" || got.API[0].Body["perm_type"] != "container" {
|
||||
t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body)
|
||||
if got.Data.API[0].Body["member_type"] != "openid" || got.Data.API[0].Body["perm"] != "edit" || got.Data.API[0].Body["perm_type"] != "container" {
|
||||
t.Fatalf("body = %#v, want canonical lowercase enum values", got.Data.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,19 +961,21 @@ func TestDriveMemberAdd_DryRunBatch(t *testing.T) {
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.API))
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.API[0]
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/shtcnTok/members/batch_create" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,53 @@ func extractUserIDs(users []interface{}) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// stringField safely extracts a string value from a map.
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// mentionOpenID extracts open_id from a mention id field (nested object or plain string).
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// compactMentions converts the raw mentions array into a compact form with key, id, name.
|
||||
func compactMentions(mentions []interface{}) []map[string]interface{} {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
m := map[string]interface{}{}
|
||||
if k := stringField(item, "key"); k != "" {
|
||||
m["key"] = k
|
||||
}
|
||||
if id := mentionOpenID(item["id"]); id != "" {
|
||||
m["id"] = id
|
||||
}
|
||||
if n := stringField(item, "name"); n != "" {
|
||||
m["name"] = n
|
||||
}
|
||||
if len(m) > 0 {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactBase builds the common compact output fields shared by all IM event processors.
|
||||
// Every compact output includes: type (event_type), event_id, and timestamp (header create_time).
|
||||
func compactBase(raw *RawEvent) map[string]interface{} {
|
||||
|
||||
@@ -16,9 +16,13 @@ import (
|
||||
// ImMessageProcessor handles im.message.receive_v1 events.
|
||||
//
|
||||
// Compact output fields:
|
||||
// - type, id, message_id, create_time, timestamp
|
||||
// - chat_id, chat_type, message_type, sender_id
|
||||
// - content: human-readable text converted via convertlib (supports text, post, image, file, card, etc.)
|
||||
// - type, event_id, timestamp
|
||||
// - id, message_id, create_time, update_time
|
||||
// - chat_id, chat_type, message_type
|
||||
// - sender_id, sender_type
|
||||
// - root_id, thread_id, reply_to
|
||||
// - content: human-readable text converted via convertlib
|
||||
// - mentions: compact mentions array with key, id, name
|
||||
type ImMessageProcessor struct{}
|
||||
|
||||
func (p *ImMessageProcessor) EventType() string { return "im.message.receive_v1" }
|
||||
@@ -32,15 +36,20 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
var ev struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -67,6 +76,9 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
out := map[string]interface{}{
|
||||
"type": raw.Header.EventType,
|
||||
}
|
||||
if raw.Header.EventID != "" {
|
||||
out["event_id"] = raw.Header.EventID
|
||||
}
|
||||
if ev.Message.MessageID != "" {
|
||||
out["id"] = ev.Message.MessageID
|
||||
out["message_id"] = ev.Message.MessageID
|
||||
@@ -80,6 +92,9 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
} else if ev.Message.CreateTime != "" {
|
||||
out["timestamp"] = ev.Message.CreateTime
|
||||
}
|
||||
if ev.Message.UpdateTime != "" && ev.Message.UpdateTime != ev.Message.CreateTime {
|
||||
out["update_time"] = ev.Message.UpdateTime
|
||||
}
|
||||
if ev.Message.ChatID != "" {
|
||||
out["chat_id"] = ev.Message.ChatID
|
||||
}
|
||||
@@ -92,9 +107,24 @@ func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode Tr
|
||||
if ev.Sender.SenderID.OpenID != "" {
|
||||
out["sender_id"] = ev.Sender.SenderID.OpenID
|
||||
}
|
||||
if ev.Sender.SenderType != "" {
|
||||
out["sender_type"] = ev.Sender.SenderType
|
||||
}
|
||||
if ev.Message.RootID != "" {
|
||||
out["root_id"] = ev.Message.RootID
|
||||
}
|
||||
if ev.Message.ThreadID != "" {
|
||||
out["thread_id"] = ev.Message.ThreadID
|
||||
}
|
||||
if ev.Message.ParentID != "" {
|
||||
out["reply_to"] = ev.Message.ParentID
|
||||
}
|
||||
if content != "" {
|
||||
out["content"] = content
|
||||
}
|
||||
if mentions := compactMentions(ev.Message.Mentions); len(mentions) > 0 {
|
||||
out["mentions"] = mentions
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -792,7 +792,6 @@ func TestImMessageProcessor_CompactInteractiveFallsBackToRaw(t *testing.T) {
|
||||
t.Fatalf("stderr hint = %q, want interactive fallback message", string(hint))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProcessor_CompactUnmarshalError(t *testing.T) {
|
||||
p := &GenericProcessor{}
|
||||
raw := makeRawEvent("some.type", `not valid json`)
|
||||
|
||||
@@ -504,6 +504,84 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey empty string passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(""); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 50 chars passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(strings.Repeat("a", 50)); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 51 chars fails", func(t *testing.T) {
|
||||
err := validateIdempotencyKey(strings.Repeat("a", 51))
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("validateIdempotencyKey() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 50 Chinese chars passes", func(t *testing.T) {
|
||||
if err := validateIdempotencyKey(strings.Repeat("中", 50)); err != nil {
|
||||
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validateIdempotencyKey 51 Chinese chars fails", func(t *testing.T) {
|
||||
err := validateIdempotencyKey(strings.Repeat("中", 51))
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("validateIdempotencyKey() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend idempotency key too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": strings.Repeat("a", 51),
|
||||
}, nil)
|
||||
err := ImMessagesSend.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend idempotency key valid", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": "my-key-001",
|
||||
}, nil)
|
||||
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply idempotency key too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": strings.Repeat("b", 51),
|
||||
}, nil)
|
||||
err := ImMessagesReply.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
|
||||
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply idempotency key valid", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"text": "hello",
|
||||
"idempotency-key": "reply-key-001",
|
||||
}, nil)
|
||||
if err := ImMessagesReply.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesReply.Validate() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply invalid message id", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "bad_id",
|
||||
|
||||
@@ -35,7 +35,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
@@ -85,6 +85,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
content := runtime.Str("content")
|
||||
text := runtime.Str("text")
|
||||
markdown := runtime.Str("markdown")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
imageKey := runtime.Str("image")
|
||||
fileKey := runtime.Str("file")
|
||||
videoKey := runtime.Str("video")
|
||||
@@ -114,6 +115,9 @@ var ImMessagesReply = common.Shortcut{
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||
}
|
||||
if err := validateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
|
||||
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
|
||||
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
@@ -97,6 +97,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
content := runtime.Str("content")
|
||||
text := runtime.Str("text")
|
||||
markdown := runtime.Str("markdown")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
imageKey := runtime.Str("image")
|
||||
fileKey := runtime.Str("file")
|
||||
videoKey := runtime.Str("video")
|
||||
@@ -135,6 +136,9 @@ var ImMessagesSend = common.Shortcut{
|
||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
|
||||
}
|
||||
if err := validateIdempotencyKey(idempotencyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != "" && !json.Valid([]byte(content)) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
|
||||
}
|
||||
@@ -211,6 +215,15 @@ var ImMessagesSend = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
const maxIdempotencyKeyChars = 50
|
||||
|
||||
func validateIdempotencyKey(value string) error {
|
||||
if chars := len([]rune(value)); chars > maxIdempotencyKeyChars {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotency-key exceeds the maximum of %d characters (got %d)", maxIdempotencyKeyChars, chars).WithParam("--idempotency-key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMediaKey returns true if the value looks like an existing API key rather than a local file path.
|
||||
func isMediaKey(value string) bool {
|
||||
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
// 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]
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
// 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, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
if !strings.Contains(problem.Message, `unsupported output format "bogus"`) {
|
||||
t.Fatalf("message = %q, want unsupported format message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/application"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
"github.com/larksuite/cli/shortcuts/base"
|
||||
"github.com/larksuite/cli/shortcuts/calendar"
|
||||
@@ -61,6 +62,7 @@ var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, application.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
@@ -155,9 +157,6 @@ 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)
|
||||
}
|
||||
@@ -205,16 +204,18 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility help grouping.
|
||||
// Sheets backward-compatibility grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
// upgrading only the binary. applySheetsCompatGroups tags each alias into a
|
||||
// dedicated deprecated cobra group. The refactored commands have been the
|
||||
// default for over a month, so `sheets --help` no longer lists these aliases:
|
||||
// sheetsUsageTemplate renders every group except the deprecated one. The
|
||||
// grouping is still applied for two reasons — the unknown-subcommand path
|
||||
// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each
|
||||
// alias's own `sheets <alias> --help` still surfaces the "(→ +new-command)"
|
||||
// migration pointer appended below. The aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
@@ -224,9 +225,10 @@ const (
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own
|
||||
// --help and reused by wrapSheetsBackwardDeprecation for the on-execution
|
||||
// _notice. Aliases absent from this map still land in the deprecated group,
|
||||
// just without a pointer, so a missing entry degrades gracefully.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
@@ -279,6 +281,43 @@ var sheetsAliasReplacement = map[string]string{
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single
|
||||
// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the
|
||||
// deprecated pre-refactor aliases are omitted from `sheets --help` altogether.
|
||||
// Everything else — current commands, ungrouped metaapi subcommands under
|
||||
// "Additional Commands", flags — renders exactly as cobra's default. Keep in
|
||||
// sync with cobra's defaultUsageTemplate on upgrade.
|
||||
var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}}
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
|
||||
`, sheetsDeprecatedGroupID)
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
@@ -310,6 +349,11 @@ func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
|
||||
// Refactored commands have been the default for over a month: drop the
|
||||
// deprecated group from `sheets --help` (see sheetsUsageTemplate). The
|
||||
// aliases remain grouped and executable, just no longer advertised here.
|
||||
svc.SetUsageTemplate(sheetsUsageTemplate)
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
|
||||
@@ -7,14 +7,12 @@ 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"
|
||||
@@ -400,68 +398,6 @@ 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 == "" {
|
||||
@@ -532,10 +468,11 @@ func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
// End-to-end: `sheets --help` must list refactored shortcuts under Available
|
||||
// Commands, but no longer advertise the deprecated pre-refactor aliases or the
|
||||
// deprecated group heading (sheetsUsageTemplate skips that group). The aliases
|
||||
// stay registered and executable — hidden from the parent listing, not removed.
|
||||
func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -551,19 +488,25 @@ func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
for _, want := range []string{"Available Commands:", "+cells-get"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"+write",
|
||||
} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
|
||||
if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil {
|
||||
t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
|
||||
@@ -5,6 +5,7 @@ package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,20 +18,30 @@ import (
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
fakeOfficePrefix = "fake_office_"
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
@@ -135,7 +146,8 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
|
||||
if v, ok := wrapped.(*errs.ValidationError); ok {
|
||||
var v *errs.ValidationError
|
||||
if errors.As(wrapped, &v) {
|
||||
return "", nil, v.WithParam("--file")
|
||||
}
|
||||
return "", nil, wrapped
|
||||
|
||||
@@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
{
|
||||
shortcut: "+rows-resize",
|
||||
sc: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","height":30}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cols-resize",
|
||||
@@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
wantContains: "--count must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
name: "+rows-resize --height with --type standard",
|
||||
shortcut: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"},
|
||||
subShortcut: "+rows-resize",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
|
||||
wantContains: "--type pixel requires --size",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`,
|
||||
wantContains: "--height cannot be combined with --type standard",
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete missing sheet selector",
|
||||
@@ -469,6 +469,34 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
|
||||
// (--widths/--heights) expands into its own batch_update, and batch_update
|
||||
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
|
||||
// rejected with a pointer to the standalone form — it is standalone-valid,
|
||||
// so this case cannot live in the standalone-vs-batch equivalence table.
|
||||
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
shortcut string
|
||||
input string
|
||||
}{
|
||||
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
|
||||
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
|
||||
t.Fatalf("bad input JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
||||
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
||||
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
||||
@@ -611,10 +639,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
"--position is required",
|
||||
},
|
||||
{
|
||||
"+rows-resize missing --type",
|
||||
"+rows-resize missing both --height and --type",
|
||||
"+rows-resize",
|
||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||
"--type is required",
|
||||
"give --height <px> for a pixel size, or --type standard / auto",
|
||||
},
|
||||
{
|
||||
"+range-copy missing --target-range",
|
||||
@@ -802,7 +830,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
||||
// bare single-element ranges.
|
||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
@@ -887,3 +915,99 @@ func TestBatchOp_RequiredFlagParity(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOp_EnumParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("canonical casing is normalized before translation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-clear",
|
||||
"input": map[string]interface{}{
|
||||
"sheet-id": "sh1",
|
||||
"range": "A1:B2",
|
||||
"scope": "FORMATS",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("translateBatchOp: %v", err)
|
||||
}
|
||||
input, _ := got["input"].(map[string]interface{})
|
||||
if input["clear_type"] != "formats" {
|
||||
t.Fatalf("clear_type = %v, want formats", input["clear_type"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-vocabulary alias is normalized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet-id": "sh1", "range": "A1", "vertical-alignment": "center",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("translateBatchOp: %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
||||
if style["vertical_alignment"] != "middle" {
|
||||
t.Fatalf("vertical_alignment = %v, want middle", style["vertical_alignment"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("underscore input keys are accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+range-copy",
|
||||
"input": map[string]interface{}{
|
||||
"sheet_id": "sh1", "source_range": "A1:B2", "target_range": "D1", "paste_type": "values",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("translateBatchOp: %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" || input["destination_range"] != "D1" || input["paste_type"] != "value_only" {
|
||||
t.Fatalf("translated underscore-key input = %#v", input)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
input map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid clear scope",
|
||||
shortcut: "+cells-clear",
|
||||
input: map[string]interface{}{
|
||||
"sheet-id": "sh1", "range": "A1:B2", "scope": "formtas",
|
||||
},
|
||||
want: "invalid value \"formtas\" for --scope",
|
||||
},
|
||||
{
|
||||
name: "invalid copy paste type",
|
||||
shortcut: "+range-copy",
|
||||
input: map[string]interface{}{
|
||||
"sheet-id": "sh1", "source-range": "A1:B2", "target-range": "D1", "paste-type": "valuez",
|
||||
},
|
||||
want: "invalid value \"valuez\" for --paste-type",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": tt.shortcut,
|
||||
"input": tt.input,
|
||||
}, testToken, 0)
|
||||
validationErr := requireValidation(t, err, tt.want)
|
||||
if validationErr.Param != "--operations" {
|
||||
t.Errorf("param = %q, want --operations", validationErr.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -118,10 +119,19 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
}},
|
||||
|
||||
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
||||
// The map form (--heights/--widths) fans out into its own batch_update
|
||||
// and cannot nest inside +batch-update; sub-ops must use the uniform
|
||||
// single-range form (range + height/width or type).
|
||||
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectResizeMapInBatch(fv, "row"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resizeInput(fv, token, sid, sname, "row")
|
||||
}},
|
||||
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectResizeMapInBatch(fv, "column"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resizeInput(fv, token, sid, sname, "column")
|
||||
}},
|
||||
|
||||
@@ -197,6 +207,54 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
||||
}
|
||||
|
||||
// allowedBatchShortcuts lists every shortcut accepted inside +batch-update,
|
||||
// sorted, for the not-allowed error hint.
|
||||
func allowedBatchShortcuts() []string {
|
||||
out := make([]string, 0, len(batchOpDispatch))
|
||||
for sc := range batchOpDispatch {
|
||||
out = append(out, sc)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// subOpInputContract renders one shortcut's complete sub-op key vocabulary
|
||||
// (wire-style underscore names) for the translator-failure hint: required
|
||||
// flags are marked, the sheet selector pair collapses to a choose-one, and
|
||||
// spreadsheet locators are omitted (reserved for the batch top level).
|
||||
// Returns "" for shortcuts without a flag-defs entry.
|
||||
func subOpInputContract(sc string) string {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
var keys []string
|
||||
sheetSelector := ""
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Hidden {
|
||||
continue
|
||||
}
|
||||
switch df.Name {
|
||||
case "url", "spreadsheet-token":
|
||||
continue // reserved: supplied by +batch-update top level
|
||||
case idFlag, nameFlag:
|
||||
sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)"
|
||||
continue
|
||||
}
|
||||
key := strings.ReplaceAll(df.Name, "-", "_")
|
||||
if df.Required == "required" {
|
||||
key += " (required)"
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if sheetSelector != "" {
|
||||
keys = append([]string{sheetSelector}, keys...)
|
||||
}
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// rejectLocalImageInBatch blocks the local-file --image source inside
|
||||
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
@@ -262,7 +320,8 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index)
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index).
|
||||
WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
@@ -270,13 +329,15 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
// Inline the full allow-list: an agent that guessed a read op or a
|
||||
// fan-out wrapper can pick the right shortcut immediately instead of
|
||||
// spending a --print-schema round trip on the operations enum.
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
)
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
inputRaw, hasInput := op["input"]
|
||||
var input map[string]interface{}
|
||||
@@ -319,12 +380,22 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
if err := fv.validateRawTypes(); err != nil {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
if err := fv.normalizeAndValidateEnums(); err != nil {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
|
||||
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
// The inner error names one problem at a time (first missing flag);
|
||||
// the hint lists the sub-op's complete key contract so an agent fixes
|
||||
// every gap in a single retry instead of iterating flag by flag.
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": mapping.mcpToolName,
|
||||
@@ -332,18 +403,59 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maxBatchOperations caps how many sub-operations a single +batch-update may
|
||||
// carry. Every translated op (with its own cells/properties payload) is held in
|
||||
// the out slice at once before the whole batch is marshaled, so an unbounded
|
||||
// operation count is the same unbounded-materialization hazard as the fan-out
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
}
|
||||
if len(rawOps) > maxBatchOperations {
|
||||
batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations
|
||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)).
|
||||
WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations)
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
return nil, sheetsValidationForFlag("operations",
|
||||
"--operations materialize %d cells total, over the %d-cell safety cap; reduce the number or size of cell operations",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
switch cells := input["cells"].(type) {
|
||||
case [][]interface{}:
|
||||
var total int64
|
||||
for _, row := range cells {
|
||||
total += int64(len(row))
|
||||
}
|
||||
return total
|
||||
case []interface{}:
|
||||
var total int64
|
||||
for _, rawRow := range cells {
|
||||
if row, ok := rawRow.([]interface{}); ok {
|
||||
total += int64(len(row))
|
||||
}
|
||||
}
|
||||
return total
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
{
|
||||
"+formula-verify": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string_slice",
|
||||
"required": "optional",
|
||||
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
|
||||
},
|
||||
{
|
||||
"name": "max-locations",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max locations / samples per error type; default 20.",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "exit-on-error",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -25,6 +80,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+revision-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -73,6 +154,17 @@
|
||||
"desc": "Initial column count (default 20, max 200)",
|
||||
"default": "20"
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "New sub-sheet type: sheet (spreadsheet); default sheet.",
|
||||
"default": "sheet",
|
||||
"enum": [
|
||||
"sheet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -219,7 +311,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
|
||||
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -515,7 +607,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1069,7 +1161,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Group nesting level to ungroup; default 1 (outermost)",
|
||||
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
@@ -1711,6 +1803,13 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2294,32 +2393,43 @@
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "xor",
|
||||
"desc": "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "heights",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)",
|
||||
"required": "xor",
|
||||
"desc": "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`",
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"
|
||||
"required": "xor",
|
||||
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2361,31 +2471,42 @@
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "xor",
|
||||
"desc": "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "widths",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)",
|
||||
"required": "xor",
|
||||
"desc": "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`",
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)"
|
||||
"required": "xor",
|
||||
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2739,7 +2860,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2759,6 +2880,13 @@
|
||||
"required": "optional",
|
||||
"desc": "Font color (hex, e.g. `#000000`)"
|
||||
},
|
||||
{
|
||||
"name": "font-family",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
|
||||
},
|
||||
{
|
||||
"name": "font-size",
|
||||
"kind": "own",
|
||||
@@ -2885,7 +3013,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2965,7 +3093,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3009,7 +3137,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -3127,7 +3255,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -4066,7 +4194,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
|
||||
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -4510,7 +4638,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"
|
||||
"desc": "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"
|
||||
},
|
||||
{
|
||||
"name": "position-row",
|
||||
@@ -4626,15 +4754,15 @@
|
||||
"name": "image-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"
|
||||
"required": "optional",
|
||||
"desc": "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"
|
||||
},
|
||||
{
|
||||
"name": "image-uri",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"
|
||||
"required": "optional",
|
||||
"desc": "Optional image URI handle returned by the upload flow (not a sheet object reference_id); mutually exclusive with `--image-token`; omit both to keep the current image; converted to file_token automatically"
|
||||
},
|
||||
{
|
||||
"name": "position-row",
|
||||
@@ -4747,5 +4875,138 @@
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-list": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "end-version",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "history-version-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "History version to revert to (from +history-list)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "transaction-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Async revert transaction id (from +history-revert)."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+changeset-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "start-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "required",
|
||||
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
|
||||
},
|
||||
{
|
||||
"name": "end-revision",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
|
||||
"default": "-1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user