mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
24 Commits
docs/opt-i
...
feat/vc_ev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cffbd7229 | ||
|
|
1ff071556d | ||
|
|
e0490b73f3 | ||
|
|
e1af7e3018 | ||
|
|
693e299589 | ||
|
|
69f335be7c | ||
|
|
d1a0926dd6 | ||
|
|
008bdda861 | ||
|
|
f1da8c274b | ||
|
|
842be3fdc5 | ||
|
|
1cd7a88597 | ||
|
|
7c64e63b9d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/internal/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -17,6 +17,7 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,49 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
@@ -1106,6 +1149,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
|
||||
9
Makefile
9
Makefile
@@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
@@ -34,7 +41,7 @@ fmt-check:
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
|
||||
@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
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{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||
revokeToken := token.RefreshToken
|
||||
tokenTypeHint := "refresh_token"
|
||||
if revokeToken == "" {
|
||||
revokeToken = token.AccessToken
|
||||
tokenTypeHint = "access_token"
|
||||
}
|
||||
if revokeToken != "" {
|
||||
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
356
cmd/auth/logout_test.go
Normal file
356
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-refresh-token" &&
|
||||
values.Get("token_type_hint") == "refresh_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"error": "server_error"},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
gotErr := stderr.String()
|
||||
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||
}
|
||||
if !strings.Contains(gotErr, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -38,6 +42,7 @@ 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")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
|
||||
@@ -31,10 +31,10 @@ type fakeRT struct {
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
@@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
@@ -80,6 +80,7 @@ const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
||||
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
|
||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ type DeviceFlowResult struct {
|
||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||
type OAuthEndpoints struct {
|
||||
DeviceAuthorization string
|
||||
Revoke string
|
||||
Token string
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
@@ -42,6 +45,9 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ package auth
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
|
||||
131
internal/auth/revoke.go
Normal file
131
internal/auth/revoke.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// RevokeToken revokes a previously issued OAuth token.
|
||||
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
form.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
form.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := getInt(data, "code", 0); code != 0 {
|
||||
msg := getStr(data, "msg")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||
WithCode(code).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
if errStr := getStr(data, "error"); errStr != "" {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = errStr
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeHTTPStatusError(status int, body []byte) error {
|
||||
msg := formatOAuthErrorBody(body)
|
||||
cause := errors.New(strings.TrimSpace(string(body)))
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
cause = errors.New(msg)
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
func formatOAuthErrorBody(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "empty response"
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if msg := getStr(data, "error_description"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "message"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "error"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
207
internal/auth/revoke_test.go
Normal file
207
internal/auth/revoke_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_a" &&
|
||||
values.Get("client_secret") == "secret_b" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeToken() error = %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||
sentinel := errors.New("transport down")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, sentinel
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %#v, want network/transport", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"error": "invalid_token"},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Fatalf("expected invalid_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"code": 12345,
|
||||
"msg": "invalid revoke state",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||
t.Fatalf("expected oauth error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": "token already expired",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("problem = %#v, want api error", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token already expired") {
|
||||
t.Fatalf("expected oauth error_description, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||
sentinel := errors.New("read failed")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: errReadCloser{err: sentinel},
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||
t.Fatalf("expected read error message, got %v", err)
|
||||
}
|
||||
if _, ok := err.(*errs.InternalError); !ok {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ func ParseBrand(value string) LarkBrand {
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
// domain. It serves every grant type (client_credentials for TAT,
|
||||
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
@@ -42,6 +42,11 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||
}
|
||||
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
|
||||
@@ -19,33 +19,44 @@ import (
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
||||
// with two distinct codes:
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
|
||||
// reports failures using the OAuth 2.0 model — an `error` string plus an
|
||||
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
|
||||
//
|
||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
||||
// - 10014: invalid app_secret ("app secret invalid")
|
||||
//
|
||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
||||
// the override stays local to this TAT call site instead of leaking into the
|
||||
// shared codemeta table.
|
||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
||||
if code == 10003 {
|
||||
// invalid_client / unauthorized_client mean the configured app_id/app_secret
|
||||
// cannot mint a token; from the user's perspective that is the same actionable
|
||||
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
|
||||
// Every other deterministic error falls through to BuildAPIError, which still
|
||||
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
|
||||
// swallowing it. Transient/server-side failures (5xx / server_error) are
|
||||
// filtered out by FetchTAT before this is called, so they stay untyped.
|
||||
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
|
||||
msg := errDesc
|
||||
if msg == "" {
|
||||
msg = oauthErr
|
||||
}
|
||||
switch oauthErr {
|
||||
case "invalid_client", "unauthorized_client":
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
WithCode(code).
|
||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||
}
|
||||
return errclass.BuildAPIError(map[string]any{
|
||||
if err := errclass.BuildAPIError(map[string]any{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
}, errclass.ClassifyContext{
|
||||
Brand: brand,
|
||||
AppID: appID,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
|
||||
// function is only reached once FetchTAT has ruled out success — a non-credential
|
||||
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
|
||||
// deterministic rejection. Back it with a typed APIError so callers never receive
|
||||
// the ("", nil) "empty token, no error" pair.
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
|
||||
}
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
@@ -146,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
|
||||
@@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
||||
// which from the user's perspective is the same actionable failure as 10014
|
||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
||||
// tenant access token. The global codemeta intentionally does not map 10003
|
||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
||||
// API uses it for permission denied), so the override is local to this site.
|
||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
|
||||
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
|
||||
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
|
||||
// tenant access token, the same actionable failure the legacy 10003/10014 codes
|
||||
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
|
||||
// return invalid_client with no Lark code (code defaults to 0).
|
||||
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10003")
|
||||
t.Fatal("expected non-nil error for invalid_client")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
||||
// for 10003 does not regress the existing mapping.
|
||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10014")
|
||||
}
|
||||
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
|
||||
// unauthorized_client is treated as the same credential failure as
|
||||
// invalid_client.
|
||||
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10014 {
|
||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
||||
// the credential set fall through to the generic BuildAPIError fallback
|
||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
|
||||
// outside the credential set fall through to the generic BuildAPIError fallback
|
||||
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for unmapped code")
|
||||
t.Fatal("expected non-nil error for invalid_scope")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
||||
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
|
||||
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
|
||||
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
|
||||
// returns nil for code 0 (Feishu's success convention); without the backstop,
|
||||
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
|
||||
// with no error.
|
||||
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,46 +4,47 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
||||
// client_credentials grant with client_secret_post authentication. It does not
|
||||
// read configuration or keychain, so callers that already hold plaintext
|
||||
// credentials (e.g. the post-`config init` probe) can validate them without a
|
||||
// second keychain round-trip.
|
||||
//
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
||||
// canonical typed error from classifyTATResponseCode — the SAME classification
|
||||
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
||||
// see one consistent envelope. Transport failures, unreadable/unparseable
|
||||
// bodies, and transient server-side failures (5xx / server_error) are returned
|
||||
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
||||
// deterministic credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", appID)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
Code int `json:"code"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
// An unparseable body is ambiguous (covers non-JSON error pages and
|
||||
// truncated payloads); stay untyped so probe callers treat it as noise.
|
||||
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
|
||||
if result.Code == 0 && result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
return result.TenantAccessToken, nil
|
||||
|
||||
// Transient/server-side failures stay untyped so probe callers stay silent and
|
||||
// retryers can back off; only deterministic client rejections are typed. Covers
|
||||
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
||||
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
||||
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
||||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
||||
result.Error == "slow_down" {
|
||||
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
||||
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
||||
}
|
||||
|
||||
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
||||
if result.Code == 0 && result.Error == "" {
|
||||
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
||||
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
||||
// non-empty typed message instead of a bare "API error: [code]".
|
||||
desc := result.ErrorDescription
|
||||
if desc == "" {
|
||||
desc = result.Msg
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
@@ -55,24 +55,33 @@ func TestFetchTAT_Success(t *testing.T) {
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
// client_secret_post: grant_type + client_id + client_secret in the form body.
|
||||
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
|
||||
if !strings.Contains(rt.gotBody, want) {
|
||||
t.Errorf("request body missing %q: %s", want, rt.gotBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
|
||||
// deterministic client-side rejection that FetchTAT routes to
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
|
||||
// secret → code 20002, unknown app → code 20048).
|
||||
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 10003")
|
||||
t.Fatal("expected error for invalid_client")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
@@ -87,52 +96,115 @@ func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
|
||||
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
|
||||
// surfaces it rather than silently swallowing it — but is NOT classified as a
|
||||
// credential (invalid_client) problem.
|
||||
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 99999")
|
||||
t.Fatal("expected error for invalid_scope")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
|
||||
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
|
||||
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
|
||||
// for code 0, which would otherwise swallow this rejection into an empty-token success.
|
||||
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
|
||||
}
|
||||
if tok != "" {
|
||||
t.Errorf("token = %q, want empty", tok)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
|
||||
// must still surface its msg on the typed error, not degrade to a generic
|
||||
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
|
||||
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for {code, msg} response")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app ticket invalid") {
|
||||
t.Errorf("typed error must carry the Lark msg, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Transient server-side failures (5xx / server_error) are NOT deterministic
|
||||
// credential rejections — they must stay UNTYPED so a probe caller treats them
|
||||
// as upstream noise and stays silent (and retryers can back off).
|
||||
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server_error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
|
||||
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
|
||||
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
|
||||
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
|
||||
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rate-limit")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
|
||||
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
|
||||
// noise and stays silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
@@ -182,12 +254,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
|
||||
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
|
||||
@@ -35,9 +35,12 @@ const (
|
||||
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
|
||||
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
||||
|
||||
// TAT-endpoint variant of the "wrong app credentials" condition.
|
||||
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
|
||||
// ("app secret invalid") instead of 99991543 when the secret is wrong.
|
||||
// "Wrong app credentials" code from the LEGACY TAT endpoint
|
||||
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
|
||||
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
|
||||
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
|
||||
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
|
||||
// are retained as a defensive fallback should 10014 still arrive.
|
||||
LarkErrTATInvalidSecret = 10014
|
||||
|
||||
// Rate limit.
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
},
|
||||
"note": {
|
||||
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
|
||||
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
|
||||
},
|
||||
"sheets": {
|
||||
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
|
||||
"zh": { "title": "电子表格", "description": "电子表格操作" }
|
||||
|
||||
@@ -18,15 +18,18 @@ var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
|
||||
@@ -19,15 +19,18 @@ var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
@@ -35,7 +38,6 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -953,6 +953,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/im/im_messages_send.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
@@ -988,6 +989,18 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
|
||||
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
|
||||
for _, path := range migratedCommonHelperPaths {
|
||||
commonPaths[path] = struct{}{}
|
||||
}
|
||||
for _, path := range migratedEnvelopePaths {
|
||||
if _, ok := commonPaths[path]; !ok {
|
||||
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||
src := `package calendar
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.53",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -15,7 +15,8 @@
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
|
||||
@@ -33,6 +33,7 @@ build_target darwin arm64
|
||||
build_target linux amd64
|
||||
build_target darwin amd64
|
||||
build_target linux arm64
|
||||
build_target linux riscv64
|
||||
build_target windows amd64
|
||||
build_target windows arm64
|
||||
|
||||
@@ -55,6 +56,7 @@ const platformMap = {
|
||||
const archMap = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
|
||||
@@ -30,6 +30,7 @@ const PLATFORM_MAP = {
|
||||
const ARCH_MAP = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,7 +31,7 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -45,7 +44,7 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validateAccessScopeFlags(rctx)
|
||||
},
|
||||
@@ -90,36 +89,42 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
switch scope {
|
||||
case "specific":
|
||||
if targets == "" {
|
||||
return output.ErrValidation("--targets is required when --scope=specific")
|
||||
return appsValidationParamError("--targets", "--targets is required when --scope=specific")
|
||||
}
|
||||
if err := validateTargetsJSON(targets); err != nil {
|
||||
return err
|
||||
}
|
||||
if approver != "" && !applyEnabled {
|
||||
return output.ErrValidation("--approver requires --apply-enabled")
|
||||
return appsValidationParamError("--approver", "--approver requires --apply-enabled")
|
||||
}
|
||||
if requireLogin {
|
||||
return output.ErrValidation("--require-login is not allowed when --scope=specific")
|
||||
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific")
|
||||
}
|
||||
case "public":
|
||||
if targets != "" {
|
||||
return output.ErrValidation("--targets is not allowed when --scope=public")
|
||||
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public")
|
||||
}
|
||||
if applyEnabled {
|
||||
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
|
||||
return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public")
|
||||
}
|
||||
if approver != "" {
|
||||
return output.ErrValidation("--approver is not allowed when --scope=public")
|
||||
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public")
|
||||
}
|
||||
if !rctx.Cmd.Flags().Changed("require-login") {
|
||||
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
}
|
||||
case "tenant":
|
||||
if targets != "" || applyEnabled || approver != "" || requireLogin {
|
||||
return output.ErrValidation("no extra flags allowed when --scope=tenant")
|
||||
return appsValidationError("no extra flags allowed when --scope=tenant").
|
||||
WithParams(
|
||||
appsInvalidParam("--targets", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--approver", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--require-login", "not allowed when --scope=tenant"),
|
||||
)
|
||||
}
|
||||
default:
|
||||
return output.ErrValidation("--scope must be specific / public / tenant")
|
||||
return appsValidationParamError("--scope", "--scope must be specific / public / tenant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,18 +132,18 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
func validateTargetsJSON(targetsJSON string) error {
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
|
||||
return output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
}
|
||||
for i, t := range items {
|
||||
typ, _ := t["type"].(string)
|
||||
if !allowedAccessTargetTypes[typ] {
|
||||
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
}
|
||||
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
|
||||
return output.ErrValidation("--targets[%d].id is empty", i)
|
||||
return appsValidationParamError("--targets", "--targets[%d].id is empty", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -157,7 +162,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
|
||||
scope := rctx.Str("scope")
|
||||
enum, ok := scopeStringToServerEnum[scope]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
|
||||
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope)
|
||||
}
|
||||
body := map[string]interface{}{"scope": enum}
|
||||
|
||||
@@ -166,7 +171,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
|
||||
// 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。
|
||||
var targets []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
|
||||
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
users, departments, chats := splitAccessScopeTargets(targets)
|
||||
if len(users) > 0 {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -43,14 +42,14 @@ var AppsChat = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
// Do not echo --message content in the error (spec §4 redaction).
|
||||
if strings.TrimSpace(rctx.Str("message")) == "" {
|
||||
return output.ErrValidation("--message is required")
|
||||
return appsValidationParamError("--message", "--message is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -36,7 +35,7 @@ var AppsCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -47,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category {
|
||||
t.Fatalf("error category = %q, want %q", p.Category, category)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem {
|
||||
t.Helper()
|
||||
return requireAppsProblem(t, err, errs.CategoryValidation)
|
||||
}
|
||||
|
||||
func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem {
|
||||
t.Helper()
|
||||
return requireAppsProblem(t, err, errs.CategoryAPI)
|
||||
}
|
||||
|
||||
// +create 测试
|
||||
|
||||
func TestAppsCreate_Success(t *testing.T) {
|
||||
|
||||
@@ -31,8 +31,9 @@ import (
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后升级成 typed api_error(exit 非 0、detail 带 statement_index / completed / rolled_back),
|
||||
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
|
||||
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
|
||||
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
//
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
@@ -68,19 +69,27 @@ var AppsDBExecute = common.Shortcut{
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return output.ErrValidation("--sql and --file are mutually exclusive")
|
||||
return appsValidationError("--sql and --file are mutually exclusive").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "mutually exclusive with --file"),
|
||||
appsInvalidParam("--file", "mutually exclusive with --sql"),
|
||||
)
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--file: %v", err)
|
||||
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
|
||||
}
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
|
||||
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "one of --sql or --file is required"),
|
||||
appsInvalidParam("--file", "one of --sql or --file is required"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -113,13 +122,15 @@ var AppsDBExecute = common.Shortcut{
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
|
||||
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
@@ -140,31 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error。
|
||||
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。
|
||||
//
|
||||
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,hint 提示用户
|
||||
// 别整批重跑(否则会重复写入)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在
|
||||
// 失败位置),note 提示用户别整批重跑(否则会重复写入)。
|
||||
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
|
||||
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
|
||||
stmtNo := errIdx + 1 // 1-based 给人看
|
||||
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
|
||||
|
||||
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
|
||||
"statement_index": errIdx,
|
||||
"completed": stmts[:errIdx],
|
||||
"rolled_back": false,
|
||||
})
|
||||
if apiErr.Detail != nil {
|
||||
if errIdx > 0 {
|
||||
apiErr.Detail.Hint = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
} else {
|
||||
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
|
||||
}
|
||||
note := "no statements were applied; fix the SQL and re-run."
|
||||
if errIdx > 0 {
|
||||
note = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"results": stmts,
|
||||
"statement_index": errIdx,
|
||||
"error_code": code,
|
||||
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
|
||||
"rolled_back": false,
|
||||
"note": note,
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
|
||||
@@ -495,9 +495,9 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitError(type=api_error、非零 exit),
|
||||
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
|
||||
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout,
|
||||
// 退出信号是 PartialFailureError(非零 exit)。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -518,45 +518,64 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail.Code != 1300002 {
|
||||
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
if got := payload["statement_index"]; got != float64(1) {
|
||||
t.Errorf("statement_index = %v, want 1", got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
|
||||
if got := payload["error_code"]; got != float64(1300002) {
|
||||
t.Errorf("error_code = %v, want 1300002", got)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 2 of 2)") {
|
||||
t.Errorf("error_message missing statement locator: %q", msg)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
|
||||
if got := payload["rolled_back"]; got != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
|
||||
}
|
||||
if detail["statement_index"] != 1 {
|
||||
t.Errorf("statement_index = %v, want 1", detail["statement_index"])
|
||||
results, _ := payload["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
|
||||
}
|
||||
if detail["rolled_back"] != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
|
||||
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "already applied") {
|
||||
t.Errorf("note should warn prior statements persisted, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope,返回 data 块。
|
||||
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("envelope.ok = true, want false on partial failure")
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -573,21 +592,23 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 1 of 1)") {
|
||||
t.Errorf("error_message missing locator: %q", msg)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["statement_index"] != 0 {
|
||||
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
|
||||
if got := payload["statement_index"]; got != float64(0) {
|
||||
t.Errorf("statement_index = %v, want 0", got)
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
|
||||
t.Errorf("completed = %v, want empty", detail["completed"])
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "no statements were applied") {
|
||||
t.Errorf("note should say nothing was applied, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
|
||||
// contract on a statement failure: stdout carries only the per-statement
|
||||
// human summary (no JSON envelope stacked after it), and the command still
|
||||
// exits non-zero via the partial-failure signal.
|
||||
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "✗") {
|
||||
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"ok"`) {
|
||||
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -45,7 +44,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return output.ErrValidation("--table is required")
|
||||
return appsValidationParamError("--table", "--table is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -47,11 +47,11 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
@@ -71,7 +71,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
@@ -120,7 +120,7 @@ func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
|
||||
}
|
||||
projectPath = cwd
|
||||
}
|
||||
@@ -137,13 +137,13 @@ func checkEnvPullTarget(envFile string) error {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
|
||||
return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
|
||||
return appsValidationParamError("--project-path", "target %s must be a regular file", envFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
@@ -203,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
return out, info, skippedKeys, nil
|
||||
default:
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,3 +1079,28 @@ func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) {
|
||||
t.Error("MkdirAll over a file component must surface an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse pins that a
|
||||
// response without a usable env_vars field classifies as
|
||||
// internal/invalid_response — a broken upstream payload, not a flag problem
|
||||
// the agent should retry with different arguments.
|
||||
func TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse(t *testing.T) {
|
||||
for name, data := range map[string]map[string]interface{}{
|
||||
"missing": {},
|
||||
"wrong type": {"env_vars": "not-an-object"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, _, err := extractEnvPullVars(data)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %s env_vars", name)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
104
shortcuts/apps/apps_errors.go
Normal file
104
shortcuts/apps/apps_errors.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
func appsValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func appsValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return appsValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func appsInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
|
||||
func appsFailedPreconditionParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func appsFailedPreconditionError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
|
||||
}
|
||||
|
||||
// appsStorageError classifies a local credential/state storage failure
|
||||
// (read, write, delete, list) as internal/storage.
|
||||
func appsStorageError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsExternalToolError classifies a runtime failure of an external tool the
|
||||
// CLI shells out to (git, npx) as internal/external_tool. The tool output is
|
||||
// carried in the message; recovery guidance goes in the hint.
|
||||
func appsExternalToolError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
|
||||
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
|
||||
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
func appsInputPathError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return appsValidationParamError("--path", "unsafe --path: %s", err).WithCause(err)
|
||||
}
|
||||
return appsValidationParamError("--path", "cannot read --path: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func appsInputPathEntryError(path string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return appsValidationParamError("--path", "unsafe --path entry %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
return appsValidationParamError("--path", "cannot read --path entry %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
|
||||
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
|
||||
// endpoint: refines endpoint-scoped business codes, prefixes the message with
|
||||
// command context, and attaches endpoint-specific recovery hints. A
|
||||
// still-untyped error is lifted at the SDK boundary instead.
|
||||
func enrichHTMLPublishAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return client.WrapDoAPIError(err)
|
||||
}
|
||||
// The HTML publish business codes (90001/90002) are scoped to this
|
||||
// endpoint, not service-global, so their subtype classification lives
|
||||
// here instead of the global errclass code table. Only an
|
||||
// otherwise-unclassified API error is refined; a stronger upstream
|
||||
// classification is never overridden.
|
||||
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
}
|
||||
if p.Message != "" {
|
||||
p.Message = "html-publish failed: " + p.Message
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
113
shortcuts/apps/apps_errors_test.go
Normal file
113
shortcuts/apps/apps_errors_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func TestAppsInputPathError_ClassifiesPathValidation(t *testing.T) {
|
||||
cause := errors.New("path escapes working directory")
|
||||
err := appsInputPathError(&fileio.PathValidationError{Err: cause})
|
||||
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "unsafe --path") {
|
||||
t.Fatalf("message = %q, want unsafe --path context", problem.Message)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--path" {
|
||||
t.Fatalf("param = %q, want --path", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) || !errors.Is(err, cause) {
|
||||
t.Fatalf("path validation cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInputPathEntryError_ClassifiesReadFailure(t *testing.T) {
|
||||
cause := errors.New("permission denied")
|
||||
err := appsInputPathEntryError("dist/index.html", cause)
|
||||
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "cannot read --path entry dist/index.html") {
|
||||
t.Fatalf("message = %q, want entry read context", problem.Message)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
|
||||
cause := errors.New("archive writer failed")
|
||||
err := appsFileIOError(cause, "pack failed: %v", cause)
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
|
||||
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
|
||||
WithCode(errCodeBuildFailed).
|
||||
WithLogID("logid-build-failed")
|
||||
|
||||
got := enrichHTMLPublishAPIError(err)
|
||||
if got != err {
|
||||
t.Fatalf("typed error should be enriched in place")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, got)
|
||||
if problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.LogID != "logid-build-failed" {
|
||||
t.Fatalf("log_id = %q, want preserved", problem.LogID)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "html-publish failed") {
|
||||
t.Fatalf("message = %q, want html-publish context", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected known-code recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -35,11 +35,11 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return output.ErrValidation("--path is required")
|
||||
return appsValidationParamError("--path", "--path is required")
|
||||
}
|
||||
// Block well-known credential files in the publish payload unless the
|
||||
// caller explicitly opts in. Lives in Validate (not DryRun) so that
|
||||
@@ -150,9 +150,9 @@ func sensitiveCandidatesError(hits []string) error {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample),
|
||||
"remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
|
||||
@@ -178,15 +178,14 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "validation",
|
||||
"--path 中缺少 index.html",
|
||||
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html")
|
||||
return appsFailedPreconditionParamError("--path", "--path is missing index.html").
|
||||
WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
|
||||
}
|
||||
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
@@ -196,24 +195,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPubli
|
||||
rawTotal += c.Size
|
||||
}
|
||||
if rawTotal > maxHTMLPublishRawBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
|
||||
"在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录")
|
||||
return nil, appsValidationParamError("--path",
|
||||
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
|
||||
WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
|
||||
}
|
||||
tarball, err := buildHTMLPublishTarball(fio, candidates)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
|
||||
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
|
||||
return nil, appsValidationParamError("--path",
|
||||
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
|
||||
WithHint("reduce --path contents, remove unrelated large files, then retry")
|
||||
}
|
||||
|
||||
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
@@ -105,17 +103,11 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
@@ -153,10 +145,7 @@ func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("expected ExitError type=validation, got %v", err)
|
||||
}
|
||||
requireAppsValidationProblem(t, err)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
@@ -199,17 +188,11 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
@@ -337,18 +320,12 @@ func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("dry-run with sensitive file should fail")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, ".env") {
|
||||
t.Fatalf("error message should list the offending file, got %q", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, ".env") {
|
||||
t.Fatalf("error message should list the offending file, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--allow-sensitive") {
|
||||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(problem.Hint, "--allow-sensitive") {
|
||||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,15 +415,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tc.wantSubstr) {
|
||||
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, tc.wantSubstr) {
|
||||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -480,15 +451,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "credentials") {
|
||||
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "credentials") {
|
||||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,11 +463,7 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
|
||||
func TestSensitiveCandidatesError_Truncation(t *testing.T) {
|
||||
hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"}
|
||||
err := sensitiveCandidatesError(hits)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
msg := exitErr.Detail.Message
|
||||
msg := requireAppsValidationProblem(t, err).Message
|
||||
if !strings.Contains(msg, "7 credential file(s)") {
|
||||
t.Fatalf("message should report the full count, got %q", msg)
|
||||
}
|
||||
@@ -534,15 +495,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -72,14 +72,14 @@ var AppsInit = common.Shortcut{
|
||||
// exit-1 (root.go handleRootError case 4), bypassing the structured
|
||||
// envelope. The spec and the E2E assert exit-2 + a structured
|
||||
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
|
||||
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
|
||||
// check lives in Validate (typed validation error -> exit 2).
|
||||
{Name: "app-id", Desc: "Miaoda app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -152,14 +152,14 @@ func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error
|
||||
// path is a log-injection vector); charcheck additionally rejects dangerous
|
||||
// Unicode (bidi overrides, zero-width) that IsControl does not.
|
||||
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", output.ErrValidation("--dir must not contain control characters")
|
||||
return "", appsValidationParamError("--dir", "--dir must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
return "", appsValidationParamError("--dir", "%v", err).WithCause(err)
|
||||
}
|
||||
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
|
||||
return "", appsValidationParamError("--dir", "--dir cannot be resolved: %v", err)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -173,20 +173,20 @@ func ensureEmptyDir(dir string) error {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return output.ErrValidation("--dir must not be a symlink: %q", dir)
|
||||
return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
|
||||
return appsValidationParamError("--dir", "--dir exists and is not a directory: %q", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return output.ErrValidation("target directory %q already exists and is not empty", dir)
|
||||
return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -209,11 +209,11 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
|
||||
return nil
|
||||
@@ -224,10 +224,10 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
m["app_id"] = appID
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "write %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func hasSteeringSkills(dir string) bool {
|
||||
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
|
||||
if err != nil {
|
||||
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
|
||||
return false, appsExternalToolError(err, "git ls-files failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
f := strings.TrimSpace(line)
|
||||
@@ -274,19 +274,19 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
@@ -303,13 +303,13 @@ func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
@@ -324,13 +324,13 @@ func parseEnvFileFromEnvelope(stdout string) (string, error) {
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+env-pull reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.EnvFile) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
|
||||
return "", appsSubprocessEnvelopeError("+env-pull returned no env_file")
|
||||
}
|
||||
return env.Data.EnvFile, nil
|
||||
}
|
||||
@@ -364,7 +364,9 @@ func validateRepoURLScheme(repoURL string) error {
|
||||
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
// The URL comes from the +git-credential-init subprocess response, not user
|
||||
// input, so a non-http(s) scheme is a broken upstream contract.
|
||||
return appsSubprocessEnvelopeError(
|
||||
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
|
||||
}
|
||||
|
||||
@@ -415,12 +417,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"git executable not found on PATH", "install git and ensure it is on your PATH")
|
||||
return appsFailedPreconditionError("git executable not found on PATH").
|
||||
WithHint("install git and ensure it is on your PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
return appsFailedPreconditionError("npx executable not found on PATH").
|
||||
WithHint("install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
}
|
||||
|
||||
if err := ensureEmptyDir(dir); err != nil {
|
||||
@@ -438,11 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
@@ -536,7 +538,7 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
@@ -544,9 +546,9 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
|
||||
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
|
||||
"ensure apps +git-credential-init is available and you are logged in")
|
||||
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
WithHint("ensure apps +git-credential-init is available and you are logged in").
|
||||
WithCause(err)
|
||||
}
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
@@ -560,7 +562,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
|
||||
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
|
||||
if runErr != nil {
|
||||
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
|
||||
return false, false, appsExternalToolError(runErr, "git status failed: %s", gitErr(stderr, runErr))
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return false, false, nil
|
||||
@@ -595,7 +597,7 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
|
||||
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
|
||||
return true, false, withAppsHint(
|
||||
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
|
||||
appsExternalToolError(e, "git push failed: %s", gitErr(se, e)),
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
|
||||
}
|
||||
return true, true, nil
|
||||
@@ -609,10 +611,10 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
|
||||
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
|
||||
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
|
||||
return appsExternalToolError(e, "git add failed: %s", gitErr(se, e))
|
||||
}
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
|
||||
return appsExternalToolError(e, "git commit failed: %s", gitErr(se, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1466,3 +1467,28 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
t.Errorf("Description should mention app code: %q", AppsInit.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
cause := errors.New("exit status 128")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeExternalTool {
|
||||
t.Fatalf("classification = %s/%s, want internal/external_tool", p.Category, p.Subtype)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,7 +32,7 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,10 +31,10 @@ var AppsReleaseGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return output.ErrValidation("--release-id is required")
|
||||
return appsValidationParamError("--release-id", "--release-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -58,6 +57,9 @@ var AppsReleaseGet = common.Shortcut{
|
||||
out := data
|
||||
if release, ok := data["release"].(map[string]interface{}); ok {
|
||||
out = release
|
||||
if el, ok := data["error_logs"]; ok {
|
||||
out["error_logs"] = el
|
||||
}
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",
|
||||
|
||||
@@ -134,13 +134,15 @@ func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
@@ -200,11 +202,13 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"error_logs": []interface{}{},
|
||||
}}},
|
||||
}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
@@ -214,6 +218,69 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONErrorLogsPassthrough(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
logs, ok := env.Data["error_logs"].([]interface{})
|
||||
if !ok || len(logs) != 1 {
|
||||
t.Fatalf("JSON must passthrough data.error_logs, got: %v", env.Data["error_logs"])
|
||||
}
|
||||
first, _ := logs[0].(map[string]interface{})
|
||||
if first["step"] != "build" || first["error_log"] != "compile error" {
|
||||
t.Errorf("error_logs content mismatch: %v", logs[0])
|
||||
}
|
||||
// flattened release fields must still be present alongside error_logs
|
||||
if env.Data["release_id"] != "6" || env.Data["status"] != "failed" {
|
||||
t.Errorf("flattened release fields missing: %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONNoErrorLogsKeyWhenAbsent(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if _, present := env.Data["error_logs"]; present {
|
||||
t.Errorf("error_logs key must be absent when API omits it, got: %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
|
||||
rctx.Format = "pretty"
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsReleaseList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -31,7 +30,7 @@ var AppsSessionCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,10 +32,10 @@ var AppsSessionGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ var AppsSessionList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -35,13 +34,13 @@ var AppsSessionStop = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
|
||||
return output.ErrValidation("--turn-id is required")
|
||||
return appsValidationParamError("--turn-id", "--turn-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -34,11 +33,15 @@ var AppsUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
body := buildAppsUpdateBody(rctx)
|
||||
if len(body) == 0 {
|
||||
return output.ErrValidation("provide at least one of --name or --description")
|
||||
return appsValidationError("provide at least one of --name or --description").
|
||||
WithParams(
|
||||
appsInvalidParam("--name", "provide at least one of --name or --description"),
|
||||
appsInvalidParam("--description", "provide at least one of --name or --description"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||||
@@ -23,11 +21,11 @@ const apiBasePath = "/open-apis/spark/v1"
|
||||
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
|
||||
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
|
||||
|
||||
// withAppsHint attaches an actionable next-step hint to a failure returned by
|
||||
// CallAPI, preserving its original classification (typed subtype/code/log_id or
|
||||
// legacy detail). A hint already present on the error is kept (the upstream
|
||||
// wording wins); only an empty hint is filled in. Mirrors
|
||||
// drive.appendDriveExportRecoveryHint. err==nil passes through.
|
||||
// withAppsHint attaches an actionable next-step hint to a typed failure,
|
||||
// preserving its original classification (subtype/code/log_id). A hint already
|
||||
// present on the error is kept (the upstream wording wins); only an empty hint
|
||||
// is filled in. Mirrors drive.appendDriveExportRecoveryHint. err==nil and
|
||||
// untyped errors pass through unchanged.
|
||||
func withAppsHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -39,14 +37,5 @@ func withAppsHint(err error, hint string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Legacy *output.ExitError fallback: fill the hint in place, preserving the
|
||||
// original class / exit code rather than downgrading the error.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) == "" {
|
||||
exitErr.Detail.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestWithAppsHint(t *testing.T) {
|
||||
@@ -17,46 +17,40 @@ func TestWithAppsHint(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
|
||||
t.Run("empty hint gets filled, classification preserved", func(t *testing.T) {
|
||||
in := errs.NewAPIError(errs.SubtypeNotFound, "boom").WithCode(404)
|
||||
out := withAppsHint(in, "run +release-list")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
p, ok := errs.ProblemOf(out)
|
||||
if !ok {
|
||||
t.Fatalf("returned error is not typed: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "run +release-list" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
|
||||
if p.Hint != "run +release-list" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "run +release-list")
|
||||
}
|
||||
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
|
||||
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
|
||||
if p.Subtype != errs.SubtypeNotFound || p.Code != 404 || p.Message != "boom" {
|
||||
t.Errorf("subtype/code/message mutated: subtype=%q code=%d msg=%q", p.Subtype, p.Code, p.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", "original hint")
|
||||
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("original hint")
|
||||
out := withAppsHint(in, "new hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "original hint" {
|
||||
t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint")
|
||||
p, _ := errs.ProblemOf(out)
|
||||
if p.Hint != "original hint" {
|
||||
t.Errorf("Hint = %q, want preserved %q", p.Hint, "original hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", " ")
|
||||
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint(" ")
|
||||
out := withAppsHint(in, "filled hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "filled hint" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
|
||||
p, _ := errs.ProblemOf(out)
|
||||
if p.Hint != "filled hint" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "filled hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
|
||||
t.Run("untyped error returned unchanged, no panic", func(t *testing.T) {
|
||||
in := errors.New("plain")
|
||||
out := withAppsHint(in, "ignored")
|
||||
if out == nil || out.Error() != "plain" {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -37,7 +36,7 @@ func appDbEnvCreatePath(appID string) string {
|
||||
func requireAppID(raw string) (string, error) {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" {
|
||||
return "", output.ErrValidation("--app-id is required")
|
||||
return "", appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,10 +21,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/apps/gitcred"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -53,9 +53,12 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
|
||||
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -129,9 +132,12 @@ var AppsGitCredentialRemove = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
|
||||
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -268,7 +274,7 @@ func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
})
|
||||
data, err := parseIssueCredentialData(resp, err)
|
||||
data, err := parseIssueCredentialData(resp, err, i.rctx.APIClassifyContext())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -285,7 +291,8 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return nil, err
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
|
||||
WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
ac, err := i.f.NewAPIClientWithConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -296,7 +303,11 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
data, err := parseIssueCredentialData(resp, err)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
Identity: string(core.AsUser),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -414,13 +425,11 @@ func gitCredentialLocalError(action string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Typed errors pass through unchanged; everything the apps domain and the
|
||||
// shared runtime produce is typed, so there is no legacy envelope to forward.
|
||||
if _, ok := errs.UnwrapTypedError(err); ok {
|
||||
return err
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
@@ -448,64 +457,43 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
issued.AppID = appID
|
||||
}
|
||||
if issued.GitHTTPURL == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if issued.PAT == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) {
|
||||
// parseIssueCredentialData turns the git-credential issue response into the
|
||||
// credential data map. A standard Lark envelope (top-level "code") and any
|
||||
// HTTP error status route through the shared response classifier, so generic
|
||||
// codes (missing scope, app not authorized) and 5xx statuses keep their
|
||||
// canonical category/subtype/retryable classification. The endpoint's
|
||||
// non-standard success shapes — direct git info or a BaseResp wrapper — are
|
||||
// handled locally.
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
detail := logIDDetail(resp)
|
||||
if resp == nil || len(resp.RawBody) == 0 {
|
||||
return nil, &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "Issue Miaoda Git credential: empty response body",
|
||||
}}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"Issue Miaoda Git credential: empty response body")
|
||||
}
|
||||
var result map[string]any
|
||||
if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil {
|
||||
return nil, &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr),
|
||||
}, Cause: jsonErr}
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
msg := firstString(result, "msg", "message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
jsonErr := json.Unmarshal(resp.RawBody, &result)
|
||||
_, hasCode := result["code"]
|
||||
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
||||
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
||||
if cerr != nil {
|
||||
return nil, withAppsHint(cerr, gitCredentialIssueHint)
|
||||
}
|
||||
return nil, &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: resp.StatusCode,
|
||||
Message: msg,
|
||||
LogID: logIDString(resp),
|
||||
Hint: gitCredentialIssueHint,
|
||||
Retryable: resp.StatusCode >= http.StatusInternalServerError,
|
||||
}}
|
||||
}
|
||||
if _, hasCode := result["code"]; hasCode {
|
||||
code := firstInt64(result, "code")
|
||||
if code != 0 {
|
||||
return nil, &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: int(code),
|
||||
Message: firstString(result, "msg", "message"),
|
||||
LogID: logIDString(resp),
|
||||
Hint: gitCredentialIssueHint,
|
||||
}}
|
||||
}
|
||||
if data, ok := result["data"].(map[string]any); ok {
|
||||
if data != nil {
|
||||
result = data
|
||||
}
|
||||
// data == nil: a code==0 envelope whose fields sit beside "code" instead
|
||||
// of under "data" — keep the locally-unmarshalled top-level object.
|
||||
} else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -534,13 +522,11 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
if message == "" {
|
||||
message = "Git credential API returned non-zero BaseResp status"
|
||||
}
|
||||
return &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: int(code),
|
||||
Message: "Issue Miaoda Git credential: " + message,
|
||||
LogID: logID,
|
||||
}}
|
||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code))
|
||||
if logID != "" {
|
||||
baseErr = baseErr.WithLogID(logID)
|
||||
}
|
||||
return baseErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -578,6 +564,9 @@ func firstInt64(data map[string]interface{}, keys ...string) int64 {
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
return n
|
||||
|
||||
@@ -5,7 +5,6 @@ package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -35,7 +34,7 @@ func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read root: %w", err)
|
||||
return nil, appsStorageError(err, "apps storage: read root: %v", err)
|
||||
}
|
||||
appIDs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/apps/gitcred"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -213,7 +213,7 @@ func TestParseIssueCredentialDataAcceptsDirectBaseRespShape(t *testing.T) {
|
||||
"expiredTime":1780050600,
|
||||
"BaseResp":{"StatusCode":0,"StatusMessage":"ok"}
|
||||
}`),
|
||||
}, nil)
|
||||
}, nil, errclass.ClassifyContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("parseIssueCredentialData returned error: %v", err)
|
||||
}
|
||||
@@ -717,9 +717,9 @@ func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
|
||||
t.Fatalf("typed error was rewrapped: %#v", got)
|
||||
}
|
||||
|
||||
exitErr := output.ErrValidation("bad app")
|
||||
if got := gitCredentialLocalError("action", exitErr); got != exitErr {
|
||||
t.Fatalf("legacy output error was rewrapped: %#v", got)
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app")
|
||||
if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) {
|
||||
t.Fatalf("typed validation error was rewrapped: %#v", got)
|
||||
}
|
||||
|
||||
if got := gitCredentialLocalError("action", nil); got != nil {
|
||||
@@ -925,43 +925,43 @@ func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseIssueCredentialDataErrors(t *testing.T) {
|
||||
if _, err := parseIssueCredentialData(nil, errors.New("transport failed")); err == nil {
|
||||
if _, err := parseIssueCredentialData(nil, errors.New("transport failed"), errclass.ClassifyContext{}); err == nil {
|
||||
t.Fatal("parseIssueCredentialData transport error returned nil")
|
||||
}
|
||||
if _, err := parseIssueCredentialData(nil, nil); err == nil {
|
||||
if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil {
|
||||
t.Fatal("parseIssueCredentialData nil response returned nil")
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil); err == nil {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); err == nil {
|
||||
t.Fatal("parseIssueCredentialData bad json returned nil")
|
||||
}
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "bad request") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "bad request") {
|
||||
t.Fatalf("HTTP error = %v", err)
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
|
||||
t.Fatalf("HTTP fallback error = %v", err)
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
t.Fatalf("code error = %v", err)
|
||||
}
|
||||
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil)
|
||||
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("code zero without data returned error: %v", err)
|
||||
}
|
||||
if data["log_id"] != "log_x" {
|
||||
t.Fatalf("log_id = %v", data["log_id"])
|
||||
}
|
||||
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil)
|
||||
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("null response with log id returned error: %v", err)
|
||||
}
|
||||
if data["log_id"] != "log_x" {
|
||||
t.Fatalf("null response log_id = %v", data["log_id"])
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
t.Fatalf("BaseResp error = %v", err)
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
|
||||
t.Fatalf("BaseResp fallback error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -970,7 +970,7 @@ func TestParseIssueCredentialDataErrors(t *testing.T) {
|
||||
// credential issuance failure is flagged retryable and carries the developer-access hint.
|
||||
func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil)
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected 503 error, got nil")
|
||||
}
|
||||
@@ -990,7 +990,7 @@ func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
|
||||
// non-zero business code (no HTTP status) carries the hint but is not retryable.
|
||||
func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil)
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected business-code error, got nil")
|
||||
}
|
||||
@@ -1014,11 +1014,10 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
// server msg and assert (a) Message equals that msg exactly, and (b) neither
|
||||
// Message nor Hint contains any token/secret-shaped string.
|
||||
//
|
||||
// Note: server msg passthrough is the framework's responsibility; apps adds
|
||||
// only a static hint. There is no msg redaction in this path (verbatim
|
||||
// passthrough is the existing behavior), so this test does not assert a
|
||||
// redaction that does not exist — it asserts that apps injects nothing
|
||||
// sensitive of its own.
|
||||
// Note: server msg passthrough is the shared classifier's responsibility;
|
||||
// apps adds only a static hint. There is no msg redaction in this path, so
|
||||
// this test does not assert a redaction that does not exist — it asserts
|
||||
// that apps injects nothing sensitive of its own.
|
||||
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
const serverMsg = "permission denied"
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
@@ -1045,7 +1044,7 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseIssueCredentialData(tc.resp, nil)
|
||||
_, err := parseIssueCredentialData(tc.resp, nil, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
@@ -1053,9 +1052,12 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
// (a) The server msg is passed through verbatim.
|
||||
if p.Message != serverMsg {
|
||||
t.Fatalf("Message = %q, want server msg %q (verbatim passthrough)", p.Message, serverMsg)
|
||||
// (a) The server msg survives into the message. The business-code
|
||||
// path passes it through verbatim; the HTTP-status path reports
|
||||
// "HTTP <status>: <body>" via the shared classifier, so assert
|
||||
// containment rather than equality.
|
||||
if !strings.Contains(p.Message, serverMsg) {
|
||||
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
|
||||
}
|
||||
// apps adds only the static hint — assert that exact static text,
|
||||
// proving apps injects no per-request secret into the hint either.
|
||||
@@ -1138,3 +1140,45 @@ exit 0
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
// TestParseIssueCredentialData_SharedClassifierCoverage pins the canonical
|
||||
// classifications the shared classifier provides on the credential-issue
|
||||
// path: a generic missing-scope code becomes a typed permission error with
|
||||
// the missing scopes extracted, and an HTTP 503 becomes a retryable
|
||||
// network/server_error — neither collapses to api/unknown.
|
||||
func TestParseIssueCredentialData_SharedClassifierCoverage(t *testing.T) {
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
|
||||
t.Run("missing scope classifies as authorization with scopes", func(t *testing.T) {
|
||||
body := `{"code":99991676,"msg":"token scope insufficient","error":{"permission_violations":[{"subject":"spark:app:read"}]}}`
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{
|
||||
StatusCode: http.StatusOK, RawBody: []byte(body), Header: header,
|
||||
}, nil, errclass.ClassifyContext{})
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Subtype != errs.SubtypeTokenScopeInsufficient {
|
||||
t.Fatalf("subtype = %q, want %q", permErr.Subtype, errs.SubtypeTokenScopeInsufficient)
|
||||
}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "spark:app:read" {
|
||||
t.Fatalf("MissingScopes = %v, want [spark:app:read]", permErr.MissingScopes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("http 503 classifies as retryable network server_error", func(t *testing.T) {
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{
|
||||
StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header,
|
||||
}, nil, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer {
|
||||
t.Fatalf("classification = %s/%s, want network/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !p.Retryable {
|
||||
t.Fatalf("retryable = false, want true for 5xx")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ package gitcred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string
|
||||
return err
|
||||
}
|
||||
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
|
||||
return fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
|
||||
return err
|
||||
@@ -106,7 +106,7 @@ func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
|
||||
if isGitConfigGetMissing(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("get %s: %w", key, err)
|
||||
return "", false, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(err)
|
||||
}
|
||||
|
||||
func gitConfigUnset(ctx context.Context, key string) error {
|
||||
@@ -114,7 +114,7 @@ func gitConfigUnset(ctx context.Context, key string) error {
|
||||
if isGitConfigUnsetMissing(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unset %s: %w", key, err)
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -40,21 +40,21 @@ func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer
|
||||
func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
||||
}
|
||||
if profile.UserOpenID == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
if m.Issuer == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "git credential issuer is not configured")
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, appID, profile)
|
||||
if err != nil {
|
||||
@@ -125,14 +125,14 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
|
||||
func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
@@ -335,7 +335,7 @@ func (m *Manager) Erase(r io.Reader) error {
|
||||
}
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
record, err = m.Store.FindByURL(url)
|
||||
@@ -360,7 +360,8 @@ func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialR
|
||||
return CredentialRecord{}, "", false, err
|
||||
}
|
||||
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
|
||||
return CredentialRecord{}, "", false, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID)
|
||||
return CredentialRecord{}, "", false, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential").
|
||||
WithHint(fmt.Sprintf("run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID))
|
||||
}
|
||||
pat, err := m.Secrets.Get(record.PATRef)
|
||||
if err != nil {
|
||||
@@ -423,7 +424,7 @@ func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
|
||||
func parseNormalizedForInput(raw string) (CredentialInput, error) {
|
||||
parts := strings.SplitN(raw, "://", 2)
|
||||
if len(parts) != 2 {
|
||||
return CredentialInput{}, output.ErrValidation("invalid credential URL")
|
||||
return CredentialInput{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid credential URL")
|
||||
}
|
||||
hostPath := parts[1]
|
||||
idx := strings.Index(hostPath, "/")
|
||||
@@ -457,19 +458,19 @@ func defaultUsername(username string) string {
|
||||
|
||||
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
|
||||
if issued == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
|
||||
}
|
||||
if issued.AppID != "" && issued.AppID != appID {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if strings.TrimSpace(issued.PAT) == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
if issued.ExpiresAt <= now {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ package gitcred
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O.
|
||||
@@ -30,7 +30,7 @@ func lockURL(url string) func() {
|
||||
func lockApp(appID string) (func(), error) {
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create Git credential lock dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err)
|
||||
}
|
||||
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock"
|
||||
lock := lockfile.New(filepath.Join(dir, filepath.Base(name)))
|
||||
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func NormalizeGitHTTPURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", output.ErrValidation("git_http_url is empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err)
|
||||
}
|
||||
return normalizeParsedURL(u)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
|
||||
protocol := strings.TrimSpace(input.Protocol)
|
||||
host := strings.TrimSpace(input.Host)
|
||||
if protocol == "" || host == "" {
|
||||
return "", output.ErrValidation("git credential input must include protocol and host")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential input must include protocol and host")
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: protocol,
|
||||
@@ -44,11 +44,11 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
|
||||
func normalizeParsedURL(u *url.URL) (string, error) {
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", output.ErrValidation("git credential only supports http/https URLs")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs")
|
||||
}
|
||||
host := normalizeHost(scheme, u.Host)
|
||||
if host == "" {
|
||||
return "", output.ErrValidation("git_http_url host is empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url host is empty")
|
||||
}
|
||||
cleanPath := cleanURLPath(u.EscapedPath())
|
||||
normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String()
|
||||
|
||||
@@ -6,13 +6,13 @@ package apps
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -39,28 +39,18 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
return parseHTMLPublishResponse(apiResp.RawBody)
|
||||
}
|
||||
|
||||
func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
data, err := api.runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return nil, enrichHTMLPublishAPIError(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode html-publish response: %w", err)
|
||||
url, _ := data["url"].(string)
|
||||
if url == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"html-publish response is missing the published app url")
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg),
|
||||
buildHTMLPublishFailureHint(envelope.Code))
|
||||
}
|
||||
return &htmlPublishResponse{URL: envelope.Data.URL}, nil
|
||||
return &htmlPublishResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
// OAPI business error codes returned by the Miaoda
|
||||
@@ -74,9 +64,9 @@ const (
|
||||
func buildHTMLPublishFailureHint(code int) string {
|
||||
switch code {
|
||||
case errCodeBuildFailed:
|
||||
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
|
||||
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
|
||||
case errCodeAppNotFound:
|
||||
return "应用不存在或无权访问;请用户确认 app_id(从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)"
|
||||
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -6,21 +6,21 @@ package apps
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
@@ -94,15 +94,57 @@ func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint on code 90001")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", exitErr.Detail.Message)
|
||||
if !strings.Contains(problem.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeAppNotFound,
|
||||
"msg": "app not found",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected app-not-found recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +180,18 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil {
|
||||
t.Error("malformed html-publish response must surface a decode error")
|
||||
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
RawBody: []byte("{not json"),
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
@@ -26,7 +25,7 @@ type htmlPublishTarball struct {
|
||||
|
||||
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, errors.New("no files to pack")
|
||||
return nil, appsValidationParamError("--path", "no files to pack")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -45,10 +44,10 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, fmt.Errorf("tar close: %w", err)
|
||||
return nil, appsFileIOError(err, "tar close: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gzip close: %w", err)
|
||||
return nil, appsFileIOError(err, "gzip close: %v", err)
|
||||
}
|
||||
|
||||
return &htmlPublishTarball{
|
||||
@@ -60,12 +59,12 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
|
||||
|
||||
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
|
||||
if isUnsafeRelPath(c.RelPath) {
|
||||
return fmt.Errorf("invalid tar entry name %q", c.RelPath)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "invalid tar entry name %q", c.RelPath)
|
||||
}
|
||||
|
||||
src, err := fio.Open(c.AbsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", c.AbsPath, err)
|
||||
return appsInputPathEntryError(c.AbsPath, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
@@ -76,10 +75,10 @@ func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCa
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("write header %s: %w", c.RelPath, err)
|
||||
return appsFileIOError(err, "write header %s: %v", c.RelPath, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, src); err != nil {
|
||||
return fmt.Errorf("copy %s: %w", c.RelPath, err)
|
||||
return appsFileIOError(err, "copy %s: %v", c.RelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O.
|
||||
@@ -25,10 +25,10 @@ const storageRoot = "spark"
|
||||
// can traverse out of the storage directory.
|
||||
func checkSeg(name, what string) error {
|
||||
if err := validate.ResourceName(name, what); err != nil {
|
||||
return fmt.Errorf("apps storage: %w", err)
|
||||
return appsStorageError(err, "apps storage: %v", err)
|
||||
}
|
||||
if name == "." {
|
||||
return fmt.Errorf("apps storage: %s must not be \".\"", what)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func Read(appID, key string) ([]byte, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read: %w", err)
|
||||
return nil, appsStorageError(err, "apps storage: read: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -79,10 +79,10 @@ func Write(appID, key string, data []byte) error {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
|
||||
return fmt.Errorf("apps storage: create dir: %w", err)
|
||||
return appsStorageError(err, "apps storage: create dir: %v", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
|
||||
return fmt.Errorf("apps storage: write: %w", err)
|
||||
return appsStorageError(err, "apps storage: write: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func Delete(appID, key string) error {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("apps storage: delete: %w", err)
|
||||
return appsStorageError(err, "apps storage: delete: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func List(appID string) ([]string, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read dir: %w", err)
|
||||
return nil, appsStorageError(err, "apps storage: read dir: %v", err)
|
||||
}
|
||||
keys := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func isUnsafeRelPath(rel string) bool {
|
||||
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
|
||||
stat, err := fio.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", rootPath, err)
|
||||
return nil, appsInputPathError(err)
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
return []htmlPublishCandidate{{
|
||||
@@ -54,7 +54,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
//nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath.
|
||||
err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
return appsInputPathEntryError(path, walkErr)
|
||||
}
|
||||
// Skip a stray git repo: a directory named .git skips the whole subtree,
|
||||
// and a .git file (the gitdir pointer used by submodules/worktrees) is
|
||||
@@ -70,7 +70,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
return appsInputPathEntryError(path, err)
|
||||
}
|
||||
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。
|
||||
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。
|
||||
@@ -79,7 +79,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
}
|
||||
rel, err := filepath.Rel(rootPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
return appsFileIOError(err, "resolve relative path for %s: %v", path, err)
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
// Defense in depth: WalkDir + Rel inside rootPath should never yield a
|
||||
@@ -87,7 +87,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
// filesystem layout shouldn't be able to inject one into RelPath.
|
||||
// Mirrors the same guard at tar entry write time.
|
||||
if isUnsafeRelPath(relSlash) {
|
||||
return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "walker produced unsafe relative path %q for %s", relSlash, path)
|
||||
}
|
||||
out = append(out, htmlPublishCandidate{
|
||||
RelPath: relSlash,
|
||||
|
||||
235
shortcuts/calendar/calendar_meeting.go
Normal file
235
shortcuts/calendar/calendar_meeting.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const meetingLogPrefix = "[calendar +meeting]"
|
||||
|
||||
var scopesCalendarMeeting = []string{
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar.event:read",
|
||||
"vc:meeting.meetingevent:read",
|
||||
}
|
||||
|
||||
// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
|
||||
type mgetInstanceRelationRequestBody struct {
|
||||
InstanceIDs []string `json:"instance_ids"`
|
||||
NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
|
||||
NeedMeetingNotes bool `json:"need_meeting_notes"`
|
||||
NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
|
||||
}
|
||||
|
||||
// meetingInfoItem represents a single event's meeting info in the output.
|
||||
type meetingInfoItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
MeetingID string `json:"meeting_id,omitempty"`
|
||||
MeetingNote string `json:"meeting_note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// translateFailMsg converts API fail_msg to a user-friendly error message.
|
||||
func translateFailMsg(failMsg string) string {
|
||||
switch failMsg {
|
||||
case "No Permission":
|
||||
return "no read permission for this calendar event (not a participant of the event)"
|
||||
case "Not Found":
|
||||
return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
|
||||
default:
|
||||
return failMsg
|
||||
}
|
||||
}
|
||||
|
||||
// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
|
||||
func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
|
||||
body := &mgetInstanceRelationRequestBody{
|
||||
InstanceIDs: []string{instanceID},
|
||||
NeedMeetingInstanceIDs: true,
|
||||
NeedMeetingNotes: true,
|
||||
NeedAIMeetingNotes: true,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return &meetingInfoItem{EventID: instanceID, Error: err.Error()}
|
||||
}
|
||||
|
||||
// Check for failed instance IDs first
|
||||
if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
|
||||
for _, raw := range failedIDs {
|
||||
if failInfo, ok := raw.(map[string]any); ok {
|
||||
if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
|
||||
failMsg, _ := failInfo["fail_msg"].(string)
|
||||
return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
infos, _ := data["instance_relation_infos"].([]any)
|
||||
if len(infos) == 0 {
|
||||
return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
|
||||
}
|
||||
|
||||
info, _ := infos[0].(map[string]any)
|
||||
result := &meetingInfoItem{EventID: instanceID}
|
||||
|
||||
// Extract meeting_id (return first if multiple) — API returns string
|
||||
if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
|
||||
if id, ok := rawIDs[0].(string); ok && id != "" {
|
||||
result.MeetingID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Extract meeting_note (return first if multiple)
|
||||
if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
|
||||
if note, ok := notes[0].(string); ok && note != "" {
|
||||
result.MeetingNote = note
|
||||
}
|
||||
}
|
||||
|
||||
// Add hints for empty resources (independent checks)
|
||||
var emptyFields []string
|
||||
if result.MeetingID == "" {
|
||||
emptyFields = append(emptyFields, "meeting_id")
|
||||
}
|
||||
if result.MeetingNote == "" {
|
||||
emptyFields = append(emptyFields, "meeting_note")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CalendarMeeting gets meeting info for calendar events.
|
||||
var CalendarMeeting = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+meeting",
|
||||
Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := common.SplitCSV(runtime.Str("event-ids"))
|
||||
const maxBatchSize = 50
|
||||
if len(ids) > maxBatchSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesCalendarMeeting); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID := runtime.Str("calendar-id")
|
||||
if calendarID == "" {
|
||||
calendarID = "<primary>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
|
||||
Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
|
||||
Set("calendar_id", calendarID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
|
||||
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarID == "" {
|
||||
calendarID = PrimaryCalendarIDStr
|
||||
}
|
||||
|
||||
results := make([]*meetingInfoItem, 0, len(instanceIDs))
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
|
||||
for i, id := range instanceIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
|
||||
results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"meetings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No events.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"event_id": r.EventID}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
if r.MeetingID != "" {
|
||||
row["meeting_id"] = r.MeetingID
|
||||
}
|
||||
if r.MeetingNote != "" {
|
||||
row["meeting_note"] = r.MeetingNote
|
||||
}
|
||||
if r.Hint != "" {
|
||||
row["hint"] = r.Hint
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
484
shortcuts/calendar/calendar_meeting_test.go
Normal file
484
shortcuts/calendar/calendar_meeting_test.go
Normal file
@@ -0,0 +1,484 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var calWarmOnce sync.Once
|
||||
|
||||
func calWarmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
calWarmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func calDefaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
}
|
||||
|
||||
func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
calWarmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "calendar"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar +meeting tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
|
||||
infos := map[string]interface{}{
|
||||
"instance_id": instanceID,
|
||||
}
|
||||
mIDs := make([]interface{}, len(meetingIDs))
|
||||
for i, id := range meetingIDs {
|
||||
mIDs[i] = id
|
||||
}
|
||||
infos["meeting_instance_ids"] = mIDs
|
||||
if len(meetingNotes) > 0 {
|
||||
notes := make([]interface{}, len(meetingNotes))
|
||||
for i, n := range meetingNotes {
|
||||
notes[i] = n
|
||||
}
|
||||
infos["meeting_notes"] = notes
|
||||
}
|
||||
if len(aiMeetingNotes) > 0 {
|
||||
notes := make([]interface{}, len(aiMeetingNotes))
|
||||
for i, n := range aiMeetingNotes {
|
||||
notes[i] = n
|
||||
}
|
||||
infos["ai_meeting_notes"] = notes
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{infos},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{},
|
||||
"failed_instance_ids": []interface{}{
|
||||
map[string]interface{}{
|
||||
"instance_id": instanceID,
|
||||
"fail_msg": failMsg,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --event-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("evt%d", i)
|
||||
}
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if m["meeting_id"] != "123456" {
|
||||
t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
|
||||
}
|
||||
if m["meeting_note"] != "doc_note1" {
|
||||
t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
|
||||
}
|
||||
if _, hasAI := m["ai_meeting_note"]; hasAI {
|
||||
t.Error("ai_meeting_note should not be present in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_FailedInstance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
// Verify translated fail_msg appears in output
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) > 0 {
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
|
||||
t.Errorf("expected translated fail_msg, got: %v", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_NoMeeting(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
|
||||
t.Errorf("expected hint about meeting_id, got: %v", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar +search-event tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid --start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for start after end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "search_event") {
|
||||
t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"display_info": "Q2 周会\n2026-04-23 15:00-16:00",
|
||||
"meta_data": map[string]interface{}{
|
||||
"event_id": "evt_search1",
|
||||
"summary": "Q2 周会",
|
||||
"start": map[string]interface{}{
|
||||
"date_time": "2026-04-23T15:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end": map[string]interface{}{
|
||||
"date_time": "2026-04-23T16:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"is_all_day": false,
|
||||
"app_link": "https://applink.feishu.cn/...",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
if data["calendar_id"] != "primary" {
|
||||
t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
|
||||
}
|
||||
items, _ := data["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
if item["event_id"] != "evt_search1" {
|
||||
t.Errorf("event_id = %v, want evt_search1", item["event_id"])
|
||||
}
|
||||
if item["summary"] != "Q2 周会" {
|
||||
t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Execute_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseSearchEventTimeRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"valid", "2026-04-20", "2026-04-27", false},
|
||||
{"start only defaults end", "2026-04-20", "", false},
|
||||
{"end only defaults start", "", "2026-04-27", false},
|
||||
{"invalid start format", "not-a-date", "2026-04-27", true},
|
||||
{"start after end", "2026-04-27", "2026-04-20", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
_, _, err := parseSearchEventTimeRange(runtime)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("start only fills end with end-of-day", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
_ = cmd.Flags().Set("start", "2026-04-20")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
|
||||
t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
|
||||
}
|
||||
if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
|
||||
t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("end only fills start with start-of-day", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
_ = cmd.Flags().Set("end", "2026-04-27")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
|
||||
t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
|
||||
}
|
||||
if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
|
||||
t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
_ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "", "")
|
||||
if filter == nil {
|
||||
t.Fatal("expected filter to be non-nil")
|
||||
}
|
||||
if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
|
||||
t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
|
||||
}
|
||||
if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
|
||||
t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
|
||||
}
|
||||
if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
|
||||
t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter_Empty(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "", "")
|
||||
if filter != nil {
|
||||
t.Errorf("expected nil for empty filter, got %v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
|
||||
if filter == nil {
|
||||
t.Fatal("expected filter to be non-nil")
|
||||
}
|
||||
if filter.TimeRange == nil {
|
||||
t.Fatal("expected time_range in filter")
|
||||
}
|
||||
if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
|
||||
t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ type roomFindSlot struct {
|
||||
type roomFindTimeSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindOutput struct {
|
||||
@@ -103,11 +104,18 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
|
||||
}
|
||||
return
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
|
||||
if suggestions == nil {
|
||||
suggestions = []*roomFindSuggestion{}
|
||||
}
|
||||
ts := &roomFindTimeSlot{
|
||||
Start: slot.Start,
|
||||
End: slot.End,
|
||||
MeetingRooms: suggestions,
|
||||
})
|
||||
}
|
||||
if len(suggestions) == 0 {
|
||||
ts.Hint = "no meeting room matches the current filters for this slot"
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, ts)
|
||||
}(slot)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -374,6 +382,10 @@ var CalendarRoomFind = common.Shortcut{
|
||||
}
|
||||
for _, slot := range out.TimeSlots {
|
||||
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
|
||||
if len(slot.MeetingRooms) == 0 {
|
||||
fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
|
||||
continue
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, room := range slot.MeetingRooms {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
@@ -384,6 +396,7 @@ var CalendarRoomFind = common.Shortcut{
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -82,3 +84,60 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
|
||||
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
|
||||
slots := []roomFindSlot{
|
||||
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
|
||||
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
|
||||
}
|
||||
|
||||
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
if strings.HasPrefix(slot.Start, "2026-03-27T14") {
|
||||
return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collectRoomFindResults returned error: %v", err)
|
||||
}
|
||||
if len(out.TimeSlots) != 2 {
|
||||
t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
|
||||
}
|
||||
|
||||
for _, ts := range out.TimeSlots {
|
||||
if ts.MeetingRooms == nil {
|
||||
t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ts.Start, "2026-03-27T14"):
|
||||
if len(ts.MeetingRooms) != 1 {
|
||||
t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
|
||||
}
|
||||
if ts.Hint != "" {
|
||||
t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
|
||||
}
|
||||
case strings.HasPrefix(ts.Start, "2026-03-27T15"):
|
||||
if len(ts.MeetingRooms) != 0 {
|
||||
t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
|
||||
}
|
||||
if ts.Hint == "" {
|
||||
t.Fatal("empty slot should carry a hint explaining the filters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emptySlot := out.TimeSlots[0]
|
||||
if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
|
||||
emptySlot = out.TimeSlots[1]
|
||||
}
|
||||
raw, err := json.Marshal(emptySlot)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal empty slot: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
|
||||
t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"hint"`) {
|
||||
t.Fatalf("expected hint field in JSON, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
351
shortcuts/calendar/calendar_search_event.go
Normal file
351
shortcuts/calendar/calendar_search_event.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +search-event — search calendar events by keyword, time range, and attendees
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const searchEventLogPrefix = "[calendar +search-event]"
|
||||
|
||||
const (
|
||||
defaultSearchEventPageSize = 20
|
||||
maxSearchEventPageSize = 30
|
||||
)
|
||||
|
||||
var scopesSearchEvent = []string{
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar.event:read",
|
||||
}
|
||||
|
||||
// searchEventTimeRange represents the time range filter for search_event API.
|
||||
type searchEventTimeRange struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventFilter represents the filter object for the search_event API request.
|
||||
type searchEventFilter struct {
|
||||
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
|
||||
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
|
||||
MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
|
||||
TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventRequestBody is the request body for the search_event API.
|
||||
type searchEventRequestBody struct {
|
||||
Query string `json:"query"`
|
||||
Filter *searchEventFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventTimeInfo represents start/end time info in the search result.
|
||||
type searchEventTimeInfo struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DateTime string `json:"date_time,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventItem represents a single event in the search result output.
|
||||
type searchEventItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
Summary string `json:"summary"`
|
||||
Start *searchEventTimeInfo `json:"start,omitempty"`
|
||||
End *searchEventTimeInfo `json:"end,omitempty"`
|
||||
IsAllDay bool `json:"is_all_day,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventOutput is the structured output for +search-event.
|
||||
type searchEventOutput struct {
|
||||
CalendarID string `json:"calendar_id"`
|
||||
Items []searchEventItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
|
||||
// When only one side is provided, the other defaults to the same day's
|
||||
// boundary (start → end-of-day, end → start-of-day).
|
||||
func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
startInput := strings.TrimSpace(runtime.Str("start"))
|
||||
endInput := strings.TrimSpace(runtime.Str("end"))
|
||||
if startInput == "" && endInput == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var startSec, endSec int64
|
||||
|
||||
if startInput != "" {
|
||||
ts, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
startSec, _ = strconv.ParseInt(ts, 10, 64)
|
||||
}
|
||||
if endInput != "" {
|
||||
ts, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
endSec, _ = strconv.ParseInt(ts, 10, 64)
|
||||
}
|
||||
|
||||
if startInput == "" {
|
||||
t := time.Unix(endSec, 0).In(time.Local)
|
||||
startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
|
||||
}
|
||||
if endInput == "" {
|
||||
t := time.Unix(startSec, 0).In(time.Local)
|
||||
endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
|
||||
}
|
||||
|
||||
if startSec > endSec {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
|
||||
}
|
||||
|
||||
return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// buildSearchEventFilter builds the filter object for the search_event API.
|
||||
func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
|
||||
attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
|
||||
|
||||
var userIDs, chatIDs, roomIDs []string
|
||||
for _, id := range attendeeIDs {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
userIDs = append(userIDs, id)
|
||||
case strings.HasPrefix(id, "oc_"):
|
||||
chatIDs = append(chatIDs, id)
|
||||
case strings.HasPrefix(id, "omm_"):
|
||||
roomIDs = append(roomIDs, id)
|
||||
default:
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
var tr *searchEventTimeRange
|
||||
if startTime != "" || endTime != "" {
|
||||
tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
|
||||
return nil
|
||||
}
|
||||
return &searchEventFilter{
|
||||
AttendeeUserIDs: userIDs,
|
||||
AttendeeChatIDs: chatIDs,
|
||||
MeetingRoomIDs: roomIDs,
|
||||
TimeRange: tr,
|
||||
}
|
||||
}
|
||||
|
||||
// extractTimeInfo extracts time info from a meta_data start/end map.
|
||||
func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
info := &searchEventTimeInfo{}
|
||||
if v, ok := m["date"].(string); ok && v != "" {
|
||||
info.Date = v
|
||||
}
|
||||
if v, ok := m["date_time"].(string); ok && v != "" {
|
||||
info.DateTime = v
|
||||
}
|
||||
if v, ok := m["timezone"].(string); ok && v != "" {
|
||||
info.Timezone = v
|
||||
}
|
||||
if info.Date == "" && info.DateTime == "" {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
|
||||
var CalendarSearchEvent = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+search-event",
|
||||
Description: "Search calendar events by keyword, time range, and attendees",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "page-token", Desc: "page token for next page"},
|
||||
{Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesSearchEvent); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID := runtime.Str("calendar-id")
|
||||
if calendarID == "" {
|
||||
calendarID = "<primary>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
|
||||
Set("calendar_id", calendarID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarID == "" {
|
||||
calendarID = PrimaryCalendarIDStr
|
||||
}
|
||||
|
||||
startTime, endTime, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build request body — always send query (even if empty)
|
||||
body := &searchEventRequestBody{
|
||||
Query: strings.TrimSpace(runtime.Str("query")),
|
||||
}
|
||||
if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
// Build query params
|
||||
params := map[string]any{}
|
||||
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultSearchEventPageSize
|
||||
}
|
||||
params["page_size"] = strconv.Itoa(pageSize)
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
|
||||
params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "items")
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
|
||||
// Transform items to structured output
|
||||
outItems := make([]searchEventItem, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := item["meta_data"].(map[string]any)
|
||||
out := searchEventItem{}
|
||||
if meta != nil {
|
||||
if v, ok := meta["event_id"].(string); ok {
|
||||
out.EventID = v
|
||||
}
|
||||
if v, ok := meta["summary"].(string); ok {
|
||||
out.Summary = v
|
||||
}
|
||||
if v, ok := meta["is_all_day"].(bool); ok {
|
||||
out.IsAllDay = v
|
||||
}
|
||||
if v, ok := meta["app_link"].(string); ok {
|
||||
out.AppLink = v
|
||||
}
|
||||
if start, ok := meta["start"].(map[string]any); ok {
|
||||
out.Start = extractTimeInfo(start)
|
||||
}
|
||||
if end, ok := meta["end"].(map[string]any); ok {
|
||||
out.End = extractTimeInfo(end)
|
||||
}
|
||||
}
|
||||
outItems = append(outItems, out)
|
||||
}
|
||||
|
||||
outData := searchEventOutput{
|
||||
CalendarID: calendarID,
|
||||
Items: outItems,
|
||||
HasMore: hasMore,
|
||||
PageToken: pageToken,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
|
||||
if len(outItems) == 0 {
|
||||
fmt.Fprintln(w, "No events found.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, item := range outItems {
|
||||
row := map[string]interface{}{
|
||||
"event_id": item.EventID,
|
||||
"summary": common.TruncateStr(item.Summary, 40),
|
||||
}
|
||||
if item.Start != nil {
|
||||
if item.Start.DateTime != "" {
|
||||
row["start"] = item.Start.DateTime
|
||||
} else if item.Start.Date != "" {
|
||||
row["start"] = item.Start.Date
|
||||
}
|
||||
}
|
||||
if item.End != nil {
|
||||
if item.End.DateTime != "" {
|
||||
row["end"] = item.End.DateTime
|
||||
} else if item.End.Date != "" {
|
||||
row["end"] = item.End.Date
|
||||
}
|
||||
}
|
||||
if item.IsAllDay {
|
||||
row["is_all_day"] = true
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
|
||||
})
|
||||
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns7(t *testing.T) {
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 7 {
|
||||
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
|
||||
@@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarRoomFind,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,14 @@ func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]in
|
||||
// carry fields a caller needs on failure (e.g. the file_token an overwrite
|
||||
// returned, for token-stability handling).
|
||||
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
|
||||
return ClassifyAPIResponseWith(resp, ctx.APIClassifyContext())
|
||||
}
|
||||
|
||||
// ClassifyAPIResponseWith is the RuntimeContext-free form of
|
||||
// ClassifyAPIResponse for callers that drive the request outside a running
|
||||
// shortcut (e.g. a cobra command holding only a factory) and supply their own
|
||||
// classification context.
|
||||
func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
||||
logID, _ := logIDFromHeader(resp)["log_id"].(string)
|
||||
|
||||
result, parseErr := client.ParseJSONResponse(resp)
|
||||
@@ -321,7 +329,7 @@ func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[stri
|
||||
}
|
||||
}
|
||||
out, _ := resultMap["data"].(map[string]interface{})
|
||||
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
|
||||
if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil {
|
||||
return out, apiErr
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
|
||||
@@ -841,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
"page-size": "10",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) {
|
||||
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":"10"`) {
|
||||
t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
@@ -901,7 +901,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "open_id",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
"sort": "create_time",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
|
||||
|
||||
@@ -48,7 +48,8 @@ var ImChatList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -266,9 +267,16 @@ func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
|
||||
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
|
||||
// omit the types query param entirely (backward compatible default).
|
||||
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
|
||||
sortType := map[string]string{
|
||||
"create_time": "ByCreateTimeAsc",
|
||||
"active_time": "ByActiveTimeDesc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
|
||||
sortType = old // old value is already the upstream enum -> pass through
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"sort_type": runtime.Str("sort-type"),
|
||||
"sort_type": sortType,
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
params["page_size"] = n
|
||||
|
||||
@@ -611,3 +611,85 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatList_SortMapping(t *testing.T) {
|
||||
cases := []struct{ sort, want string }{
|
||||
{"create_time", "ByCreateTimeAsc"},
|
||||
{"active_time", "ByActiveTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sort, func(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{"sort": c.sort}, nil)
|
||||
got := buildChatListParams(rt, "")
|
||||
if got["sort_type"] != c.want {
|
||||
t.Fatalf("sort=%s -> sort_type=%v, want %s", c.sort, got["sort_type"], c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
|
||||
// exact same upstream request as the equivalent new --sort value (byte-equal).
|
||||
func TestChatList_SortAliasParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "ByCreateTimeAsc"},
|
||||
{"active_time", "ByActiveTimeDesc"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p.newVal, func(t *testing.T) {
|
||||
newRT := newChatListTestRuntimeContext(t, map[string]string{"sort": p.newVal}, nil)
|
||||
oldRT := newChatListTestRuntimeContext(t, map[string]string{"sort-type": p.oldVal}, nil)
|
||||
a := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortNewWins: both flags set -> new wins, old ignored, no error.
|
||||
func TestChatList_SortNewWins(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{
|
||||
"sort": "active_time",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
}, nil)
|
||||
got := buildChatListParams(rt, "")
|
||||
if got["sort_type"] != "ByActiveTimeDesc" {
|
||||
t.Fatalf("new should win: sort_type=%v, want ByActiveTimeDesc", got["sort_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortFlagSurface asserts the declared flag structure.
|
||||
func TestChatList_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatList.Flags {
|
||||
switch ImChatList.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatList.Flags[i]
|
||||
case "sort-type":
|
||||
aliasFlag = &ImChatList.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-type flags declared")
|
||||
}
|
||||
if sortFlag.Default != "create_time" {
|
||||
t.Errorf("--sort Default = %q, want create_time", sortFlag.Default)
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,active_time" {
|
||||
t.Errorf("--sort Enum = %q, want create_time,active_time", got)
|
||||
}
|
||||
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
|
||||
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort-type must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
||||
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ var ImChatMessageList = common.Shortcut{
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Desc: "end time (ISO 8601)"},
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
@@ -209,7 +210,11 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
|
||||
}
|
||||
|
||||
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
|
||||
params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId)
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old // old value is asc/desc -> must go through the same map, never pass through
|
||||
}
|
||||
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
|
||||
|
||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||
startTime, err := common.ParseTime(startFlag)
|
||||
|
||||
98
shortcuts/im/im_chat_messages_list_test.go
Normal file
98
shortcuts/im/im_chat_messages_list_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newMsgListTestRT registers chat-id (so the builder has a container) plus the
|
||||
// sort flags under test; only flags present in stringFlags are "set" (Changed).
|
||||
func newMsgListTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["chat-id"]; !ok {
|
||||
stringFlags["chat-id"] = "oc_test"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderMapping(t *testing.T) {
|
||||
cases := []struct{ order, want string }{
|
||||
{"asc", "ByCreateTimeAsc"},
|
||||
{"desc", "ByCreateTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.order, func(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": c.order})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("buildChatMessageListRequest() error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != c.want {
|
||||
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
|
||||
// through the SAME table as --order (NOT pass through), producing identical upstream.
|
||||
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
|
||||
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderNewWins(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
|
||||
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatMessageList.Flags {
|
||||
switch ImChatMessageList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImChatMessageList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImChatMessageList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
if orderFlag.Default != "desc" {
|
||||
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
|
||||
}
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ var ImChatSearch = common.Shortcut{
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
|
||||
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
|
||||
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
|
||||
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
@@ -209,8 +210,8 @@ var ImChatSearch = common.Shortcut{
|
||||
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
|
||||
// is empty.
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort
|
||||
// (and its hidden alias --sort-by) is unset.
|
||||
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -256,9 +257,18 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
// Build sorters (always descending)
|
||||
if sortBy := runtime.Str("sort-by"); sortBy != "" {
|
||||
body["sorter"] = sortBy
|
||||
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
|
||||
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
|
||||
sorter := map[string]string{
|
||||
"create_time": "create_time_desc",
|
||||
"update_time": "update_time_desc",
|
||||
"member_count": "member_count_desc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
|
||||
sorter = old
|
||||
}
|
||||
if sorter != "" {
|
||||
body["sorter"] = sorter
|
||||
}
|
||||
|
||||
return body
|
||||
|
||||
102
shortcuts/im/im_chat_search_test.go
Normal file
102
shortcuts/im/im_chat_search_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["query"]; !ok {
|
||||
stringFlags["query"] = "team"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestChatSearch_SortMapping(t *testing.T) {
|
||||
cases := []struct{ sort, want string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
{"member_count", "member_count_desc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sort, func(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": c.sort})
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != c.want {
|
||||
t.Fatalf("sort=%s -> sorter=%v, want %s", c.sort, body["sorter"], c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortOmittedWhenUnset: no --sort and no --sort-by -> sorter omitted.
|
||||
func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
|
||||
rt := newSearchTestRT(t, nil)
|
||||
body := buildSearchChatBody(rt)
|
||||
if _, present := body["sorter"]; present {
|
||||
t.Fatalf("sorter should be omitted when neither --sort nor --sort-by set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
|
||||
// sorter (pass-through), so it must equal the mapped new --sort body.
|
||||
func TestChatSearch_SortAliasParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
{"member_count", "member_count_desc"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p.newVal, func(t *testing.T) {
|
||||
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
|
||||
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
|
||||
if newBody["sorter"] != oldBody["sorter"] {
|
||||
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_SortNewWins(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != "member_count_desc" {
|
||||
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatSearch.Flags {
|
||||
switch ImChatSearch.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatSearch.Flags[i]
|
||||
case "sort-by":
|
||||
aliasFlag = &ImChatSearch.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-by flags declared")
|
||||
}
|
||||
if sortFlag.Default != "" {
|
||||
t.Errorf("--sort must have no default (sorter omitted when unset), got %q", sortFlag.Default)
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
|
||||
t.Errorf("--sort Enum = %q", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort-by must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
|
||||
t.Errorf("--sort-by Enum = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
@@ -39,15 +40,10 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
threadFlag := runtime.Str("thread")
|
||||
sortFlag := runtime.Str("sort")
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
pageSizeStr := runtime.Str("page-size")
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if sortFlag == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
d := common.NewDryRunAPI()
|
||||
@@ -57,21 +53,12 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
containerID = "<resolved_thread_id>"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"container_id_type": "thread",
|
||||
"container_id": containerID,
|
||||
"sort_type": sortType,
|
||||
"page_size": pageSize,
|
||||
"card_msg_content_type": "raw_card_content",
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
|
||||
|
||||
d = d.
|
||||
GET("/open-apis/im/v1/messages").
|
||||
Params(params).
|
||||
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
|
||||
Params(toDryParams(params)).
|
||||
Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
|
||||
@@ -97,26 +84,12 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sortFlag := runtime.Str("sort")
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if sortFlag == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
params := map[string][]string{
|
||||
"container_id_type": []string{"thread"},
|
||||
"container_id": []string{threadId},
|
||||
"sort_type": []string{sortType},
|
||||
"page_size": []string{strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
if err != nil {
|
||||
@@ -188,3 +161,45 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildThreadsMessagesListParams builds the upstream query params shared by
|
||||
// DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one
|
||||
// place (precondition for the dry-run == real alias-parity test).
|
||||
func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageToken string) map[string][]string {
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if dir == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
params := map[string][]string{
|
||||
"container_id_type": {"thread"},
|
||||
"container_id": {containerID},
|
||||
"sort_type": {sortType},
|
||||
"page_size": {strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// resolveThreadsOrder picks --order, falling back to the hidden --sort alias.
|
||||
func resolveThreadsOrder(runtime *common.RuntimeContext) string {
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// toDryParams flattens single-valued query params to scalars for dry-run preview,
|
||||
// matching the historical dry-run JSON shape.
|
||||
func toDryParams(p map[string][]string) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(p))
|
||||
for k, v := range p {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
81
shortcuts/im/im_threads_messages_list_test.go
Normal file
81
shortcuts/im/im_threads_messages_list_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["thread"]; !ok {
|
||||
stringFlags["thread"] = "omt_test"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderMapping(t *testing.T) {
|
||||
cases := []struct{ order, want string }{
|
||||
{"asc", "ByCreateTimeAsc"},
|
||||
{"desc", "ByCreateTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.order, func(t *testing.T) {
|
||||
got := buildThreadsMessagesListParams(c.order, "omt_test", 50, "")
|
||||
if v := got["sort_type"][0]; v != c.want {
|
||||
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, v, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreadsMessagesList_OrderAliasParity proves DryRun(--sort dir) == DryRun(--order dir).
|
||||
// This is the test the refactor exists to make meaningful (single shared mapping).
|
||||
func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newThreadsTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newThreadsTestRT(t, map[string]string{"sort": dir})
|
||||
a := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImThreadsMessagesList.Flags {
|
||||
switch ImThreadsMessagesList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImThreadsMessagesList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImThreadsMessagesList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
if orderFlag.Default != "asc" {
|
||||
t.Errorf("--order Default = %q, want asc", orderFlag.Default)
|
||||
}
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
}
|
||||
}
|
||||
18
shortcuts/im/sort_flags.go
Normal file
18
shortcuts/im/sort_flags.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
|
||||
// alias. It returns (oldValue, true) only when the old flag was explicitly used
|
||||
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
|
||||
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
|
||||
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
|
||||
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
|
||||
if rt.Changed(oldName) && !rt.Changed(newName) {
|
||||
return rt.Str(oldName), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
53
shortcuts/im/sort_flags_test.go
Normal file
53
shortcuts/im/sort_flags_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newAliasTestRT registers a new flag (with a default) and an old flag, then
|
||||
// sets only the flags present in `set` — so Changed() reflects exactly which
|
||||
// flags were "passed on the command line".
|
||||
func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(newName, newDefault, "")
|
||||
cmd.Flags().String(oldName, "", "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for k, v := range set {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("Set(%q) error = %v", k, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestAliasFlagValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
set map[string]string
|
||||
wantVal string
|
||||
wantOK bool
|
||||
}{
|
||||
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "ByActiveTimeDesc", true},
|
||||
{"neither set", nil, "", false},
|
||||
{"only new set", map[string]string{"sort": "active_time"}, "", false},
|
||||
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rt := newAliasTestRT(t, "sort", "create_time", "sort-type", c.set)
|
||||
gotVal, gotOK := aliasFlagValue(rt, "sort-type", "sort")
|
||||
if gotVal != c.wantVal || gotOK != c.wantOK {
|
||||
t.Fatalf("aliasFlagValue() = (%q, %v), want (%q, %v)", gotVal, gotOK, c.wantVal, c.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,19 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessage is the `+message` shortcut: fetch full content of a single
|
||||
// email by message ID (normalized body + attachments / inline metadata).
|
||||
// MailMessage is the `+message` shortcut: fetch full content of one email
|
||||
// by one message ID (normalized body + attachments / inline metadata).
|
||||
var MailMessage = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message",
|
||||
Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
|
||||
Description: "Use only when reading full content for one email by one message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata, including inline images.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "message-id", Desc: "Required. Email message ID", Required: true},
|
||||
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true},
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user