Compare commits

..

1 Commits

Author SHA1 Message Date
zhaoyukun.yk
417b0d1820 feat(credential): resolve the active app from the invocation context
Each lark-cli invocation can now carry its own app identity, so callers
bound to different apps no longer compete over a single stored default
profile.
2026-06-11 19:42:56 +08:00
163 changed files with 2084 additions and 5326 deletions

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
/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
View File

@@ -35,8 +35,6 @@ tests/mail/reports/
# Generated / test artifacts # Generated / test artifacts
.hammer/ .hammer/
.lark-slides/ .lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json internal/registry/meta_data.json
cmd/api/download.bin cmd/api/download.bin
app.log app.log

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo - forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError. # errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete. # 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/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/) - 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/)
text: errs-typed-only text: errs-typed-only
linters: linters:
- forbidigo - forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final # errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/, # errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught. # cmd/config/ still have residual fmt.Errorf and must not be caught.
- 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/) - 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/)
text: errs-no-bare-wrap text: errs-no-bare-wrap
linters: linters:
- forbidigo - forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save # errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors. # helpers have migrated to typed final errors.
- 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/) - 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/)
text: errs-no-legacy-helper text: errs-no-legacy-helper
linters: linters:
- forbidigo - forbidigo

View File

@@ -17,7 +17,6 @@ builds:
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- riscv64
archives: archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,7 +11,7 @@
```bash ```bash
make build # Build (runs fetch_meta first) make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64) make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration make test # Full: vet + unit + integration
``` ```

View File

@@ -2,49 +2,6 @@
All notable changes to this project will be documented in this file. 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 ## [v1.0.51] - 2026-06-10
### Features ### Features
@@ -1149,8 +1106,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - 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.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.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49 [v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49

View File

@@ -8,13 +8,6 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local 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 .PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test all: test
@@ -41,7 +34,7 @@ fmt-check:
# ./extension/... keeps the public plugin SDK in the default test matrix. # ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \ go test -race -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/... ./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this # examples-build keeps the shipped plugin-SDK examples compilable. If this

View File

@@ -91,29 +91,6 @@ 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) { func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -132,27 +109,6 @@ 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) { func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -170,27 +126,6 @@ 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) { func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,29 +145,6 @@ 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) { func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -355,32 +267,6 @@ 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) { func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,7 +19,6 @@ import (
type CheckOptions struct { type CheckOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Scope string Scope string
JSON bool
} }
// NewCmdAuthCheck creates the auth check subcommand. // NewCmdAuthCheck creates the auth check subcommand.
@@ -38,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
} }
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope") cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")

View File

@@ -18,7 +18,6 @@ import (
// ListOptions holds all inputs for auth list. // ListOptions holds all inputs for auth list.
type ListOptions struct { type ListOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
JSON bool
} }
// NewCmdAuthList creates the auth list subcommand. // NewCmdAuthList creates the auth list subcommand.
@@ -35,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts) return authListRun(opts)
}, },
} }
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd
@@ -46,14 +44,6 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig() multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 { 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" // auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we // branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be // keep the same contract here. We still want the hint to be
@@ -71,14 +61,6 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile) app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 { 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.") fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil return nil
} }

View File

@@ -4,7 +4,6 @@
package auth package auth
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
@@ -35,33 +34,6 @@ 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 // TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent // 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 // in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out) 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())
}
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in // (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json. // service_descriptions.json.
func getShortcutOnlyDomainNames() []string { func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"} return []string{"base", "contact", "docs", "markdown", "apps"}
} }

View File

@@ -9,7 +9,6 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -215,12 +214,6 @@ 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) { func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects() projects := registry.ListFromMetaProjects()
if len(projects) == 0 { if len(projects) == 0 {

View File

@@ -18,7 +18,6 @@ import (
// LogoutOptions holds all inputs for auth logout. // LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct { type LogoutOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
JSON bool
} }
// NewCmdAuthLogout creates the auth logout subcommand. // NewCmdAuthLogout creates the auth logout subcommand.
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts) return authLogoutRun(opts)
}, },
} }
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write") cmdutil.SetRisk(cmd, "write")
return cmd return cmd
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig() multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 { 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.") fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil return nil
} }
app := multi.CurrentAppConfig(f.Invocation.Profile) app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 { 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.") fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil return nil
} }
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users { 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 { 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) fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
} }
} }
app.Users = []core.AppUser{} app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil { if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) 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") output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil return nil
} }

View File

@@ -1,356 +0,0 @@
// 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)
}
}

View File

@@ -19,7 +19,6 @@ type ScopesOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Ctx context.Context Ctx context.Context
Format string Format string
JSON bool
} }
// NewCmdAuthScopes creates the auth scopes subcommand. // NewCmdAuthScopes creates the auth scopes subcommand.
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app", Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context() opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil { if runF != nil {
return runF(opts) return runF(opts)
} }
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
} }
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") 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") cmdutil.SetRisk(cmd, "read")
return cmd return cmd

View File

@@ -17,7 +17,6 @@ import (
type StatusOptions struct { type StatusOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Verify bool Verify bool
JSON bool
} }
// NewCmdAuthStatus creates the auth status subcommand. // NewCmdAuthStatus creates the auth status subcommand.
@@ -36,7 +35,6 @@ 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.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd

View File

@@ -6,14 +6,21 @@ package cmd
import ( import (
"errors" "errors"
"io" "io"
"os"
"strings"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/spf13/pflag" "github.com/spf13/pflag"
) )
// BootstrapInvocationContext extracts global invocation options before // BootstrapInvocationContext extracts global invocation options before
// the real command tree is built, so provider-backed config resolution sees // the real command tree is built, so provider-backed config resolution sees
// the correct profile from the start. // the correct profile from the start.
//
// Profile resolution: --profile flag > CliRuntimeAppID env > "" (defers to
// MultiAppConfig.CurrentApp). The env value flows through FindApp which
// matches by Name first, then by AppId — so callers can pass either form.
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) { func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
var globals GlobalOptions var globals GlobalOptions
@@ -26,5 +33,9 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) { if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err return cmdutil.InvocationContext{}, err
} }
return cmdutil.InvocationContext{Profile: globals.Profile}, nil profile := globals.Profile
if profile == "" {
profile = strings.TrimSpace(os.Getenv(envvars.CliRuntimeAppID))
}
return cmdutil.InvocationContext{Profile: profile}, nil
} }

View File

@@ -3,7 +3,11 @@
package cmd package cmd
import "testing" import (
"testing"
"github.com/larksuite/cli/internal/envvars"
)
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) { func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"}) inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -70,3 +74,25 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target") t.Fatalf("profile = %q, want %q", inv.Profile, "target")
} }
} }
func TestBootstrapInvocationContext_RuntimeAppIDEnv(t *testing.T) {
t.Setenv(envvars.CliRuntimeAppID, " env-app ")
inv, err := BootstrapInvocationContext([]string{"auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "env-app" {
t.Fatalf("profile = %q, want %q (env value, trimmed)", inv.Profile, "env-app")
}
}
func TestBootstrapInvocationContext_ProfileFlagBeatsRuntimeAppIDEnv(t *testing.T) {
t.Setenv(envvars.CliRuntimeAppID, "env-app")
inv, err := BootstrapInvocationContext([]string{"--profile", "flag-app", "auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "flag-app" {
t.Fatalf("profile = %q, want %q (--profile wins over env)", inv.Profile, "flag-app")
}
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"sort"
"strings" "strings"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
@@ -55,6 +56,11 @@ type BindOptions struct {
// configBindRun; downstream branches read this instead of rechecking // configBindRun; downstream branches read this instead of rechecking
// IOStreams.IsTerminal. Do not set from outside — it is overwritten. // IOStreams.IsTerminal. Do not set from outside — it is overwritten.
IsTUI bool IsTUI bool
// All binds every account exposed by the agent source as a multi-app
// config. Hidden flag, currently openclaw source only, mutually
// exclusive with --app-id.
All bool
} }
// NewCmdConfigBind creates the config bind subcommand. // NewCmdConfigBind creates the config bind subcommand.
@@ -107,6 +113,8 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)") cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)") cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().BoolVar(&opts.All, "all", false, "bind every account from the agent source as a multi-app config (currently openclaw source only)")
_ = cmd.Flags().MarkHidden("all")
cmdutil.SetRisk(cmd, "write") cmdutil.SetRisk(cmd, "write")
return cmd return cmd
@@ -124,6 +132,10 @@ func configBindRun(opts *BindOptions) error {
// opts.IsTUI instead of re-checking IOStreams.IsTerminal. // opts.IsTUI instead of re-checking IOStreams.IsTerminal.
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
if opts.All {
return configBindAllRun(opts)
}
source, err := finalizeSource(opts) source, err := finalizeSource(opts)
if err != nil { if err != nil {
return err return err
@@ -632,6 +644,15 @@ func validateBindFlags(opts *BindOptions) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity") return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
} }
} }
if opts.All {
if opts.AppID != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all is mutually exclusive with --app-id").WithParam("--all")
}
src := strings.TrimSpace(strings.ToLower(opts.Source))
if src != "" && src != "openclaw" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all only supports --source openclaw").WithParam("--all")
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang) lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil { if err != nil {
return err return err
@@ -677,3 +698,173 @@ func tuiSelectIdentity(opts *BindOptions) (string, error) {
} }
return value, nil return value, nil
} }
// configBindAllRun binds every account exposed by the agent source as a
// multi-app config. Flag-mode only; bot-only identity by default;
// silently overwrites prior bindings.
func configBindAllRun(opts *BindOptions) error {
// --all is flag-mode only: resolve the source through the same flag/env
// reconciliation as a single bind (including the explicit-vs-detected
// mismatch guard), but never prompt.
opts.IsTUI = false
source, err := finalizeSource(opts)
if err != nil {
return err
}
if source != "openclaw" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--all only supports --source openclaw").WithParam("--all")
}
core.SetCurrentWorkspace(core.Workspace(source))
targetConfigPath := core.GetConfigPath()
previousConfigBytes, _ := vfs.ReadFile(targetConfigPath)
binder, err := newBinder(source, opts)
if err != nil {
return err
}
candidates, err := binder.ListCandidates()
if err != nil {
return err
}
if len(candidates) == 0 {
return errs.NewConfigError(errs.SubtypeNotConfigured,
"no Feishu accounts found in openclaw.json").
WithHint("configure channels.feishu.accounts first")
}
if opts.Identity == "" {
opts.Identity = "bot-only"
}
if err := warnIdentityEscalation(opts, previousConfigBytes); err != nil {
return err
}
noticeUserDefaultRisk(opts)
prior := priorLang(previousConfigBytes)
oc, ok := binder.(*openclawBinder)
if !ok {
return errs.NewInternalError(errs.SubtypeSDKError, "internal: --all requires openclawBinder, got %T", binder)
}
apps, err := oc.BuildAll()
if err != nil {
return err
}
for i := range apps {
applyPreferences(&apps[i], opts, prior)
}
sortAppsDefaultFirst(apps)
apps = dedupAndOrderApps(apps)
return commitMultiAppBinding(opts, apps, previousConfigBytes, source, targetConfigPath)
}
// sortAppsDefaultFirst orders the bound apps deterministically: the "default"
// alias first, then named accounts alphabetically. Account enumeration
// upstream follows Go map order, and CurrentApp is taken from the first
// entry — without a stable order the selected default app would vary
// between runs of the same bind.
func sortAppsDefaultFirst(apps []core.AppConfig) {
sort.SliceStable(apps, func(i, j int) bool {
di, dj := strings.EqualFold(apps[i].Name, "default"), strings.EqualFold(apps[j].Name, "default")
if di != dj {
return di
}
return apps[i].Name < apps[j].Name
})
}
// dedupAndOrderApps collapses entries that share an AppId. Per OpenClaw
// semantics, "default" is an implicit fallback alias that inherits the
// top-level appId; when an explicit named account exposes the same appId,
// "default" is redundant and discarded. Insertion order is preserved.
func dedupAndOrderApps(apps []core.AppConfig) []core.AppConfig {
byID := make(map[string]int, len(apps))
out := make([]core.AppConfig, 0, len(apps))
for _, a := range apps {
if idx, seen := byID[a.AppId]; seen {
if strings.EqualFold(out[idx].Name, "default") && !strings.EqualFold(a.Name, "default") {
out[idx] = a
}
continue
}
byID[a.AppId] = len(out)
out = append(out, a)
}
return out
}
// commitMultiAppBinding writes a MultiAppConfig containing every bound
// account. CurrentApp defaults to the first entry so existing single-app
// callers keep their behaviour; callers select another profile with
// --profile or LARKSUITE_CLI_RUNTIME_APP_ID.
func commitMultiAppBinding(opts *BindOptions, apps []core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{
CurrentApp: apps[0].ProfileName(),
Apps: apps,
}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
}
data, err := json.MarshalIndent(multi, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
}
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
}
replaced := previousConfigBytes != nil
if replaced {
cleanupKeychainFromDataMulti(opts.Factory.Keychain, previousConfigBytes, apps)
}
appIDs := make([]string, len(apps))
for i := range apps {
appIDs[i] = apps[i].AppId
}
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+
fmt.Sprintf("bound %d Feishu apps: %s", len(appIDs), strings.Join(appIDs, ", ")))
envelope := map[string]interface{}{
"ok": true,
"workspace": source,
"app_ids": appIDs,
"config_path": configPath,
"replaced": replaced,
"identity": opts.Identity,
"all": true,
}
resultJSON, _ := json.Marshal(envelope)
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
return nil
}
// cleanupKeychainFromDataMulti is cleanupKeychainFromData with a keep set,
// used when re-binding multiple apps in one pass.
func cleanupKeychainFromDataMulti(kc keychain.KeychainAccess, data []byte, keep []core.AppConfig) {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return
}
keepIDs := make(map[string]struct{}, len(keep))
for i := range keep {
ref := keep[i].AppSecret.Ref
if ref != nil && ref.Source == "keychain" {
keepIDs[ref.ID] = struct{}{}
}
}
for _, app := range multi.Apps {
if app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" {
if _, ok := keepIDs[app.AppSecret.Ref.ID]; ok {
continue
}
}
core.RemoveSecretStore(app.AppSecret, kc)
}
}

View File

@@ -1907,3 +1907,270 @@ func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want) t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
} }
} }
// ── --all flag tests (hidden multi-account bind) ──
func TestConfigBindCmd_AllFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *BindOptions
cmd := NewCmdConfigBind(f, func(opts *BindOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--source", "openclaw", "--all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.All {
t.Error("expected All=true")
}
// --all is hidden but still parseable.
flag := cmd.Flags().Lookup("all")
if flag == nil {
t.Fatal("--all flag must be registered")
}
if !flag.Hidden {
t.Error("--all must be hidden")
}
}
// requireAllValidationParam asserts err is a typed validation error of
// subtype invalid_argument carrying the named param, locking the contract
// beyond the prose message.
func requireAllValidationParam(t *testing.T, err error, wantParam string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype)
}
if ve.Param != wantParam {
t.Fatalf("param = %q, want %q", ve.Param, wantParam)
}
}
func TestConfigBindRun_AllRejectsAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "openclaw",
AppID: "cli_x",
All: true,
})
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--all is mutually exclusive with --app-id") {
t.Errorf("unexpected error: %v", err)
}
requireAllValidationParam(t, err, "--all")
}
func TestDedupAndOrderApps_NamedWinsOverDefault(t *testing.T) {
// OpenClaw semantics: "default" is an implicit fallback alias; when a
// named account exposes the same appId, default is dropped.
apps := []core.AppConfig{
{Name: "acct-a", AppId: "cli_main"},
{Name: "acct-b", AppId: "cli_product"},
{Name: "default", AppId: "cli_main"},
}
got := dedupAndOrderApps(apps)
if len(got) != 2 {
t.Fatalf("len=%d, want 2", len(got))
}
if got[0].Name != "acct-a" || got[0].AppId != "cli_main" {
t.Errorf("apps[0]=%+v, want acct-a cli_main", got[0])
}
if got[1].Name != "acct-b" {
t.Errorf("apps[1]=%+v, want acct-b", got[1])
}
}
func TestDedupAndOrderApps_DefaultFirstThenNamedOverwrites(t *testing.T) {
// default arrives before its named alias — named still wins.
apps := []core.AppConfig{
{Name: "default", AppId: "cli_main"},
{Name: "acct-a", AppId: "cli_main"},
}
got := dedupAndOrderApps(apps)
if len(got) != 1 {
t.Fatalf("len=%d, want 1", len(got))
}
if got[0].Name != "acct-a" {
t.Errorf("apps[0]=%+v, want acct-a", got[0])
}
}
func TestDedupAndOrderApps_NoDuplicatesPreservesOrder(t *testing.T) {
apps := []core.AppConfig{
{Name: "acct-a", AppId: "cli_main"},
{Name: "acct-b", AppId: "cli_product"},
{Name: "acct-c", AppId: "cli_extra"},
}
got := dedupAndOrderApps(apps)
if len(got) != 3 {
t.Fatalf("len=%d, want 3", len(got))
}
want := []string{"acct-a", "acct-b", "acct-c"}
for i := range got {
if got[i].Name != want[i] {
t.Errorf("apps[%d]=%q, want %q", i, got[i].Name, want[i])
}
}
}
func TestDedupAndOrderApps_DefaultAlreadyFirst(t *testing.T) {
apps := []core.AppConfig{
{Name: "default", AppId: "cli_main"},
{Name: "acct-b", AppId: "cli_product"},
}
got := dedupAndOrderApps(apps)
if len(got) != 2 || got[0].Name != "default" {
t.Fatalf("got %+v, want default first", got)
}
}
func TestConfigBindRun_AllRejectsNonOpenClawSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
All: true,
})
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--all only supports --source openclaw") {
t.Errorf("unexpected error: %v", err)
}
requireAllValidationParam(t, err, "--all")
}
func TestConfigBindRun_AllRejectsSourceEnvMismatch(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
t.Setenv("HERMES_HOME", t.TempDir()) // env says hermes, flag says openclaw
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", All: true})
if err == nil {
t.Fatal("expected source/env mismatch error")
}
if !strings.Contains(err.Error(), "does not match detected Agent environment") {
t.Errorf("unexpected error: %v", err)
}
requireAllValidationParam(t, err, "--source")
}
func TestConfigBindRun_AllBindsEveryAccount(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
clearAgentEnv(t)
openclawHome := t.TempDir()
t.Setenv("OPENCLAW_HOME", openclawHome)
openclawDir := filepath.Join(openclawHome, ".openclaw")
if err := os.MkdirAll(openclawDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Top-level credentials surface as a "default" candidate aliasing the
// "work" account; dedup must collapse them and keep the named entry.
cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu","accounts":{"work":{"appId":"cli_main","appSecret":"sec_main"},"personal":{"appId":"cli_personal","appSecret":"sec_personal"}}}}}`
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil {
t.Fatalf("write: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, All: true})
if err != nil {
t.Fatalf("expected success, got %v", err)
}
configPath := filepath.Join(configDir, "openclaw", "config.json")
assertEnvelope(t, stdout.Bytes(), map[string]any{
"ok": true,
"workspace": "openclaw",
"app_ids": []any{"cli_main", "cli_personal"},
"config_path": configPath,
"replaced": false,
"identity": "bot-only",
"all": true,
})
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read written config: %v", err)
}
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
t.Fatalf("written config is not a MultiAppConfig: %v", err)
}
if len(multi.Apps) != 2 {
t.Fatalf("apps = %d, want 2", len(multi.Apps))
}
// The account aliased by top-level "default" stays first and becomes
// the initial CurrentApp.
if multi.CurrentApp != "work" {
t.Errorf("currentApp = %q, want %q", multi.CurrentApp, "work")
}
if multi.Apps[0].Name != "work" || multi.Apps[1].Name != "personal" {
t.Errorf("app order = [%q, %q], want [work, personal]", multi.Apps[0].Name, multi.Apps[1].Name)
}
}
func TestConfigBindRun_AllEscalationRequiresForce(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
clearAgentEnv(t)
openclawHome := t.TempDir()
t.Setenv("OPENCLAW_HOME", openclawHome)
openclawDir := filepath.Join(openclawHome, ".openclaw")
if err := os.MkdirAll(openclawDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu"}}}`
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil {
t.Fatalf("write: %v", err)
}
// Pre-existing binding locked to bot-only.
ocDir := filepath.Join(configDir, "openclaw")
if err := os.MkdirAll(ocDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
locked := `{"apps":[{"appId":"cli_old","strictMode":"bot"}]}`
if err := os.WriteFile(filepath.Join(ocDir, "config.json"), []byte(locked), 0600); err != nil {
t.Fatalf("write: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, All: true, Identity: "user-default"})
if err == nil {
t.Fatal("expected confirmation-required error without --force")
}
var ce *errs.ConfirmationRequiredError
if !errors.As(err, &ce) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T (%v)", err, err)
}
// Same escalation with --force proceeds.
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, All: true, Identity: "user-default", Force: true}); err != nil {
t.Fatalf("expected --force to allow escalation, got %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if envelope["replaced"] != true || envelope["identity"] != "user-default" {
t.Errorf("envelope = %v, want replaced=true identity=user-default", envelope)
}
}

View File

@@ -172,40 +172,54 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil { if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
} }
var selected *binding.CandidateApp
for i := range b.rawApps { for i := range b.rawApps {
if b.rawApps[i].AppID == appID { if b.rawApps[i].AppID == appID {
selected = &b.rawApps[i] return b.buildFromCandidate(&b.rawApps[i])
break
} }
} }
if selected == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) }
}
if selected.AppSecret.IsZero() { // BuildAll builds every candidate without appID lookup so duplicate-appID
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path). // entries (e.g. OpenClaw's "default" alias of a named account) keep their own
// Label. --all callers must use this; Build(appID) collapses duplicates.
func (b *openclawBinder) BuildAll() ([]core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: BuildAll called before ListCandidates")
}
out := make([]core.AppConfig, 0, len(b.rawApps))
for i := range b.rawApps {
cfg, err := b.buildFromCandidate(&b.rawApps[i])
if err != nil {
return nil, err
}
out = append(out, *cfg)
}
return out, nil
}
func (b *openclawBinder) buildFromCandidate(c *binding.CandidateApp) (*core.AppConfig, error) {
if c.AppSecret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", c.AppID, b.path).
WithHint("configure channels.feishu.appSecret in openclaw.json") WithHint("configure channels.feishu.appSecret in openclaw.json")
} }
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) secret, err := binding.ResolveSecretInput(c.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil { if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err). return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", c.AppID, err).
WithHint("check appSecret configuration in %s", b.path). WithHint("check appSecret configuration in %s", b.path).
WithCause(err) WithCause(err)
} }
stored, err := core.ForStorage(c.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil { if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain"). WithHint("use file: reference in config to bypass keychain").
WithCause(err) WithCause(err)
} }
return &core.AppConfig{ return &core.AppConfig{
AppId: selected.AppID, Name: c.Label,
AppId: c.AppID,
AppSecret: stored, AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(selected.Brand)), Brand: core.LarkBrand(normalizeBrand(c.Brand)),
}, nil }, nil
} }

View File

@@ -33,16 +33,15 @@ const probeTimeout = 3 * time.Second
// //
// 1. A TAT request using the just-saved credentials. credential.FetchTAT // 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode) // returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the unified Token Endpoint deterministically rejected the // only when the server deterministically rejected the credentials — a
// credentials — an OAuth2 invalid_client / unauthorized_client classified as // non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That // (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// typed error is propagated so the root dispatcher renders the canonical // so the root dispatcher renders the canonical envelope and `config init`
// envelope and `config init` exits non-zero — identical to how every other // exits non-zero — identical to how every other token-resolving command
// token-resolving command reports the same bad credentials. Ambiguous // reports the same bad credentials. Ambiguous failures (transport errors,
// failures (transport errors, transient 5xx/server_error, JSON parse errors, // HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// timeouts) come back as raw untyped errors and are swallowed (return nil), // errors and are swallowed (return nil), so valid configurations are never
// so valid configurations are never disturbed by upstream noise. // disturbed by upstream noise. errs.IsTyped is the discriminator.
// errs.IsTyped is the discriminator.
// //
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of // 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 // that call (success, server error, timeout, parse failure) is always

View File

@@ -31,10 +31,10 @@ type fakeRT struct {
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) { func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch { switch {
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"): case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
f.tatCalls++ f.tatCalls++
if f.tatHandler == nil { if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
} }
return f.tatHandler(req) return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"): case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
@@ -84,15 +84,14 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
} }
// assertConfigRejection asserts runProbe propagated a deterministic credential // assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This // rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// is the same typed error every other token-resolving command returns for the // the expected upstream code. This is the same typed error every other
// same bad credentials, and nothing is written to stderr (the root dispatcher // token-resolving command returns for the same bad credentials, and nothing is
// renders the envelope). The numeric code is not asserted: the unified v3 Token // written to stderr (the root dispatcher renders the envelope).
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code. func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper() t.Helper()
if err == nil { if err == nil {
t.Fatal("expected *errs.ConfigError, got nil") t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
@@ -104,6 +103,9 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", 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 { if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
} }
@@ -121,13 +123,11 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
} }
} }
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token // 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient, func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)
@@ -137,27 +137,28 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
if rt.probeCalls != 0 { if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails") t.Error("probe endpoint must not be called when TAT fails")
} }
assertConfigRejection(t, err, errBuf) assertConfigRejection(t, err, errBuf, 10003)
} }
// unauthorized_client is treated as the same credential rejection, propagated. // 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) { // the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
} }
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls // Any non-zero body code is a deterministic rejection and propagates (typed).
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces // An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// it rather than swallowing — but is not a credential (ConfigError) rejection. // typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) { func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)

View File

@@ -80,7 +80,6 @@ const (
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read) 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) SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
// Generic untyped error lifted to InternalError uses SubtypeUnknown. // Generic untyped error lifted to InternalError uses SubtypeUnknown.
) )

View File

@@ -47,7 +47,6 @@ type DeviceFlowResult struct {
// OAuthEndpoints contains the OAuth endpoint URLs. // OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct { type OAuthEndpoints struct {
DeviceAuthorization string DeviceAuthorization string
Revoke string
Token string Token string
} }
@@ -56,7 +55,6 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand) ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{ return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization, DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
Token: ep.Open + PathOAuthTokenV2, Token: ep.Open + PathOAuthTokenV2,
} }
} }

View File

@@ -31,9 +31,6 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) 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" { if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token) t.Errorf("Token = %q", ep.Token)
} }
@@ -45,9 +42,6 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) 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" { if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token) t.Errorf("Token = %q", ep.Token)
} }

View File

@@ -7,8 +7,6 @@ package auth
const ( const (
// PathDeviceAuthorization is the endpoint for device authorization. // PathDeviceAuthorization is the endpoint for device authorization.
PathDeviceAuthorization = "/oauth/v1/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 is the endpoint for application registration.
PathAppRegistration = "/oauth/v1/app/registration" PathAppRegistration = "/oauth/v1/app/registration"
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2). // PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).

View File

@@ -1,131 +0,0 @@
// 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
}

View File

@@ -1,207 +0,0 @@
// 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)
}
}

View File

@@ -22,12 +22,6 @@ func ParseBrand(value string) LarkBrand {
return BrandFeishu 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. // Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct { type Endpoints struct {
Open string // e.g. "https://open.feishu.cn" Open string // e.g. "https://open.feishu.cn"

View File

@@ -42,11 +42,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
if ep.Open != "https://open.feishu.cn" { if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open) 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) { func TestResolveOpenBaseURL(t *testing.T) {

View File

@@ -19,44 +19,33 @@ import (
extcred "github.com/larksuite/cli/extension/credential" extcred "github.com/larksuite/cli/extension/credential"
) )
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the // classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint // canonical typed error. The TAT mint endpoint reports invalid credentials
// reports failures using the OAuth 2.0 model — an `error` string plus an // with two distinct codes:
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
// //
// invalid_client / unauthorized_client mean the configured app_id/app_secret // - 10003: bad app_id format or non-existent app_id ("invalid param")
// cannot mint a token; from the user's perspective that is the same actionable // - 10014: invalid app_secret ("app secret invalid")
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced. //
// Every other deterministic error falls through to BuildAPIError, which still // Both surface as CategoryConfig/InvalidClient from the user's perspective —
// yields a typed error so probe callers (errs.IsTyped) surface it rather than // the configured credentials cannot mint a tenant access token. 10014 is
// swallowing it. Transient/server-side failures (5xx / server_error) are // globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
// filtered out by FetchTAT before this is called, so they stay untyped. // 10003 is NOT globally mapped because in other Lark endpoints it carries
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error { // unrelated semantics (e.g. task API uses 10003 for permission denied), so
msg := errDesc // the override stays local to this TAT call site instead of leaking into the
if msg == "" { // shared codemeta table.
msg = oauthErr func classifyTATResponseCode(code int, msg, brand, appID string) error {
} if code == 10003 {
switch oauthErr {
case "invalid_client", "unauthorized_client":
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg). return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code). WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient)) WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
} }
if err := errclass.BuildAPIError(map[string]any{ return errclass.BuildAPIError(map[string]any{
"code": code, "code": code,
"msg": msg, "msg": msg,
}, errclass.ClassifyContext{ }, errclass.ClassifyContext{
Brand: brand, Brand: brand,
AppID: appID, 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. // DefaultAccountProvider resolves account from config.json via keychain.
@@ -79,6 +68,8 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return nil, core.NotConfiguredError() return nil, core.NotConfiguredError()
} }
// p.profile already incorporates --profile / CliRuntimeAppID precedence
// (resolved in cmd/bootstrap.go before Factory construction).
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile) cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -157,8 +148,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
return &TokenResult{Token: token, Scopes: scopes}, nil return &TokenResult{Token: token, Scopes: scopes}, nil
} }
// resolveTAT resolves a tenant access token. The result is cached after the first // resolveTAT resolves a tenant access token. Result is cached after first call.
// call via sync.Once — only the context from the first call is used. // NOTE: Uses sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) { func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() { p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx) p.tatResult, p.tatErr = p.doResolveTAT(ctx)

View File

@@ -19,16 +19,18 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{} var _ DefaultAccountResolver = &DefaultAccountProvider{}
} }
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the // TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
// unified Token Endpoint's OAuth2 invalid_client error surfaces as // endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a // Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
// tenant access token, the same actionable failure the legacy 10003/10014 codes // which from the user's perspective is the same actionable failure as 10014
// produced. The numeric code is intentionally not asserted: the v3 endpoint may // ("app secret invalid") — both mean the configured credentials cannot mint a
// return invalid_client with no Lark code (code defaults to 0). // tenant access token. The global codemeta intentionally does not map 10003
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) { // because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x") // 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")
if err == nil { if err == nil {
t.Fatal("expected non-nil error for invalid_client") t.Fatal("expected non-nil error for code=10003")
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
@@ -40,16 +42,22 @@ func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T)
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", 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 == "" { if cfgErr.Hint == "" {
t.Error("Hint must be non-empty so the user gets a recovery action") t.Error("Hint must be non-empty so the user gets a recovery action")
} }
} }
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that // TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
// unauthorized_client is treated as the same credential failure as // goes through the global BuildAPIError path (codemeta entry) so the override
// invalid_client. // for 10003 does not regress the existing mapping.
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) { func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x") err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10014")
}
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
@@ -57,38 +65,21 @@ func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testi
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", 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_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 invalid_scope")
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
} }
} }
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0 // TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no // the credential set fall through to the generic BuildAPIError fallback
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError // (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
// returns nil for code 0 (Feishu's success convention); without the backstop, func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
// 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 { if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)") t.Fatal("expected non-nil error for unmapped code")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) { if errors.As(err, &cfgErr) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err) t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
} }
} }

View File

@@ -4,47 +4,46 @@
package credential package credential
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
) )
// FetchTAT performs a single HTTP POST to mint a tenant access token via the // FetchTAT performs a single HTTP POST to mint a tenant access token with the
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the // given credentials. It does not read configuration or keychain, so callers
// client_credentials grant with client_secret_post authentication. It does not // that already hold plaintext credentials (e.g. the post-`config init` probe)
// read configuration or keychain, so callers that already hold plaintext // can validate them without a second keychain round-trip.
// credentials (e.g. the post-`config init` probe) can validate them without a
// second keychain round-trip.
// //
// A deterministic client-side rejection (e.g. invalid_client) returns the // A non-zero TAT response code means the server inspected the payload and
// canonical typed error from classifyTATResponseCode — the SAME classification // rejected the credentials; FetchTAT returns the canonical typed error from
// doResolveTAT (and thus every token-resolving command) produces, so callers // classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// see one consistent envelope. Transport failures, unreadable/unparseable // every token-resolving command) produces, so callers see one consistent
// bodies, and transient server-side failures (5xx / server_error) are returned // envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a // Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// deterministic credential rejection apart from upstream/transport noise. // 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. // The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) { func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand) ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + core.OAuthTokenV3Path url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
form := url.Values{} body, err := json.Marshal(map[string]string{
form.Set("grant_type", "client_credentials") "app_id": appID,
form.Set("client_id", appID) "app_secret": appSecret,
form.Set("client_secret", appSecret) })
if err != nil {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
@@ -52,51 +51,20 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK {
if err != nil { return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
return "", fmt.Errorf("failed to read TAT response: %w", err)
} }
var result struct { var result struct {
Code int `json:"code"` Code int `json:"code"`
AccessToken string `json:"access_token"` Msg string `json:"msg"`
Error string `json:"error"` TenantAccessToken string `json:"tenant_access_token"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
} }
if err := json.Unmarshal(body, &result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// An unparseable body is ambiguous (covers non-JSON error pages and return "", fmt.Errorf("failed to parse TAT response: %w", err)
// 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 {
if result.Code == 0 && result.AccessToken != "" { return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
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)
} }

View File

@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
func TestFetchTAT_Success(t *testing.T) { func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{ rt := &stubRoundTripper{
respCode: 200, respCode: 200,
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`, respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
} }
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
@@ -55,33 +55,24 @@ func TestFetchTAT_Success(t *testing.T) {
if token != "t-abc" { if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token) t.Errorf("token = %q, want t-abc", token)
} }
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" { if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
t.Errorf("url = %s", rt.gotReq.URL.String()) t.Errorf("url = %s", rt.gotReq.URL.String())
} }
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct) t.Errorf("request body missing credentials: %s", rt.gotBody)
}
// 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)
}
} }
} }
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a // 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// deterministic client-side rejection that FetchTAT routes to
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same // classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns. // typed error doResolveTAT (and thus every token-resolving command) returns.
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
// secret → code 20002, unknown app → code 20048). rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
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} hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil { if err == nil {
t.Fatal("expected error for invalid_client") t.Fatal("expected error for code 10003")
} }
if token != "" { if token != "" {
t.Errorf("token = %q, want empty", token) t.Errorf("token = %q, want empty", token)
@@ -96,115 +87,52 @@ func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", 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)
}
} }
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still // 10014 ("app secret invalid") — the most common real-world rejection (real
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller // app_id + wrong secret) — is globally mapped in codemeta to
// surfaces it rather than silently swallowing it — but is NOT classified as a // CategoryConfig / SubtypeInvalidClient via BuildAPIError.
// credential (invalid_client) problem. func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
func TestFetchTAT_OtherClientError_Typed(t *testing.T) { rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for invalid_scope")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err) 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)
} }
} }
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to // Any non-zero body code is a deterministic server-side rejection, so it
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair. // always yields a typed error (errs.IsTyped). An unrecognized code falls back
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil // to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// for code 0, which would otherwise swallow this rejection into an empty-token success. // caller still surfaces it rather than silently swallowing.
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) { func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`} rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
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} hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil { if err == nil {
t.Fatal("expected error for {code, msg} response") t.Fatal("expected error for code 99999")
} }
if !errs.IsTyped(err) { if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err) t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
} }
if !strings.Contains(err.Error(), "app ticket invalid") { var apiErr *errs.APIError
t.Errorf("typed error must carry the Lark msg, got: %v", err) if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
} }
} }
// Transient server-side failures (5xx / server_error) are NOT deterministic // Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// credential rejections — they must stay UNTYPED so a probe caller treats them // must stay UNTYPED so a probe caller treats it as upstream noise and stays
// as upstream noise and stays silent (and retryers can back off). // silent.
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) { func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} { for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`} rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
@@ -254,12 +182,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
brand core.LarkBrand brand core.LarkBrand
wantURL string wantURL string
}{ }{
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"}, {core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"}, {core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) { t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`} rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil { if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -4,8 +4,11 @@
package envvars package envvars
const ( const (
CliAppID = "LARKSUITE_CLI_APP_ID" CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET" CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
// CliRuntimeAppID pins a profile by appId when --profile is unset.
// Selector only; credentials still come from config.json / keychain.
CliRuntimeAppID = "LARKSUITE_CLI_RUNTIME_APP_ID"
CliBrand = "LARKSUITE_CLI_BRAND" CliBrand = "LARKSUITE_CLI_BRAND"
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN" CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN" CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"

View File

@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
// CategoryConfig // CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API) 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}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client) 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
// CategoryPolicy // CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired}, 21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -35,12 +35,9 @@ const (
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// "Wrong app credentials" code from the LEGACY TAT endpoint // TAT-endpoint variant of the "wrong app credentials" condition.
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret // /open-apis/auth/v3/tenant_access_token/internal returns code 10014
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints // ("app secret invalid") instead of 99991543 when the secret is wrong.
// 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 LarkErrTATInvalidSecret = 10014
// Rate limit. // Rate limit.

View File

@@ -47,10 +47,6 @@
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
}, },
"note": {
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
},
"sheets": { "sheets": {
"en": { "title": "Sheets", "description": "Spreadsheet operations" }, "en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" } "zh": { "title": "电子表格", "description": "电子表格操作" }

View File

@@ -18,18 +18,15 @@ var migratedCommonHelperPaths = []string{
"cmd/event/", "cmd/event/",
"events/", "events/",
"internal/event/consume/", "internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/", "shortcuts/base/",
"shortcuts/calendar/", "shortcuts/calendar/",
"shortcuts/contact/", "shortcuts/contact/",
"shortcuts/doc/", "shortcuts/doc/",
"shortcuts/drive/", "shortcuts/drive/",
"shortcuts/event/", "shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/", "shortcuts/mail/",
"shortcuts/markdown/", "shortcuts/markdown/",
"shortcuts/minutes/", "shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/", "shortcuts/okr/",
"shortcuts/sheets/", "shortcuts/sheets/",
"shortcuts/slides/", "shortcuts/slides/",

View File

@@ -19,18 +19,15 @@ var migratedEnvelopePaths = []string{
"cmd/event/", "cmd/event/",
"events/", "events/",
"internal/event/consume/", "internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/", "shortcuts/base/",
"shortcuts/calendar/", "shortcuts/calendar/",
"shortcuts/contact/", "shortcuts/contact/",
"shortcuts/doc/", "shortcuts/doc/",
"shortcuts/drive/", "shortcuts/drive/",
"shortcuts/event/", "shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/", "shortcuts/mail/",
"shortcuts/markdown/", "shortcuts/markdown/",
"shortcuts/minutes/", "shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/", "shortcuts/okr/",
"shortcuts/sheets/", "shortcuts/sheets/",
"shortcuts/slides/", "shortcuts/slides/",
@@ -38,6 +35,7 @@ var migratedEnvelopePaths = []string{
"shortcuts/vc/", "shortcuts/vc/",
"shortcuts/whiteboard/", "shortcuts/whiteboard/",
"shortcuts/wiki/", "shortcuts/wiki/",
"shortcuts/im/",
} }
// legacyOutputImportPath is the import path of the package that declares the // legacyOutputImportPath is the import path of the package that declares the

View File

@@ -953,7 +953,6 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
paths := []string{ paths := []string{
"shortcuts/doc/docs_fetch_v2.go", "shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go", "shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go", "shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go", "shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go", "shortcuts/okr/okr_progress_create.go",
@@ -989,18 +988,6 @@ 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) { func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar src := `package calendar

View File

@@ -1,6 +1,6 @@
{ {
"name": "@larksuite/cli", "name": "@larksuite/cli",
"version": "1.0.53", "version": "1.0.51",
"description": "The official CLI for Lark/Feishu open platform", "description": "The official CLI for Lark/Feishu open platform",
"bin": { "bin": {
"lark-cli": "scripts/run.js" "lark-cli": "scripts/run.js"
@@ -15,8 +15,7 @@
], ],
"cpu": [ "cpu": [
"x64", "x64",
"arm64", "arm64"
"riscv64"
], ],
"engines": { "engines": {
"node": ">=16" "node": ">=16"

View File

@@ -33,7 +33,6 @@ build_target darwin arm64
build_target linux amd64 build_target linux amd64
build_target darwin amd64 build_target darwin amd64
build_target linux arm64 build_target linux arm64
build_target linux riscv64
build_target windows amd64 build_target windows amd64
build_target windows arm64 build_target windows arm64
@@ -56,7 +55,6 @@ const platformMap = {
const archMap = { const archMap = {
x64: "amd64", x64: "amd64",
arm64: "arm64", arm64: "arm64",
riscv64: "riscv64",
}; };
const platform = platformMap[process.platform]; const platform = platformMap[process.platform];

View File

@@ -30,7 +30,6 @@ const PLATFORM_MAP = {
const ARCH_MAP = { const ARCH_MAP = {
x64: "amd64", x64: "amd64",
arm64: "arm64", arm64: "arm64",
riscv64: "riscv64",
}; };
const platform = PLATFORM_MAP[process.platform]; const platform = PLATFORM_MAP[process.platform];

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -31,7 +32,7 @@ var AppsAccessScopeGet = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return nil return nil
}, },

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -44,7 +45,7 @@ var AppsAccessScopeSet = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return validateAccessScopeFlags(rctx) return validateAccessScopeFlags(rctx)
}, },
@@ -89,42 +90,36 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
switch scope { switch scope {
case "specific": case "specific":
if targets == "" { if targets == "" {
return appsValidationParamError("--targets", "--targets is required when --scope=specific") return output.ErrValidation("--targets is required when --scope=specific")
} }
if err := validateTargetsJSON(targets); err != nil { if err := validateTargetsJSON(targets); err != nil {
return err return err
} }
if approver != "" && !applyEnabled { if approver != "" && !applyEnabled {
return appsValidationParamError("--approver", "--approver requires --apply-enabled") return output.ErrValidation("--approver requires --apply-enabled")
} }
if requireLogin { if requireLogin {
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific") return output.ErrValidation("--require-login is not allowed when --scope=specific")
} }
case "public": case "public":
if targets != "" { if targets != "" {
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public") return output.ErrValidation("--targets is not allowed when --scope=public")
} }
if applyEnabled { if applyEnabled {
return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public") return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
} }
if approver != "" { if approver != "" {
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public") return output.ErrValidation("--approver is not allowed when --scope=public")
} }
if !rctx.Cmd.Flags().Changed("require-login") { if !rctx.Cmd.Flags().Changed("require-login") {
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)") return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
} }
case "tenant": case "tenant":
if targets != "" || applyEnabled || approver != "" || requireLogin { if targets != "" || applyEnabled || approver != "" || requireLogin {
return appsValidationError("no extra flags allowed when --scope=tenant"). return output.ErrValidation("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: default:
return appsValidationParamError("--scope", "--scope must be specific / public / tenant") return output.ErrValidation("--scope must be specific / public / tenant")
} }
return nil return nil
} }
@@ -132,18 +127,18 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
func validateTargetsJSON(targetsJSON string) error { func validateTargetsJSON(targetsJSON string) error {
var items []map[string]interface{} var items []map[string]interface{}
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil { if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err) return output.ErrValidation("--targets is not valid JSON: %v", err)
} }
if len(items) == 0 { if len(items) == 0 {
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids") return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
} }
for i, t := range items { for i, t := range items {
typ, _ := t["type"].(string) typ, _ := t["type"].(string)
if !allowedAccessTargetTypes[typ] { if !allowedAccessTargetTypes[typ] {
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ) return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
} }
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" { if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
return appsValidationParamError("--targets", "--targets[%d].id is empty", i) return output.ErrValidation("--targets[%d].id is empty", i)
} }
} }
return nil return nil
@@ -162,7 +157,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
scope := rctx.Str("scope") scope := rctx.Str("scope")
enum, ok := scopeStringToServerEnum[scope] enum, ok := scopeStringToServerEnum[scope]
if !ok { if !ok {
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope) return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
} }
body := map[string]interface{}{"scope": enum} body := map[string]interface{}{"scope": enum}
@@ -171,7 +166,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
// 用户传统一格式 [{type:user|department|chat, id:...}]body 里拆 3 个并列数组发后端。 // 用户传统一格式 [{type:user|department|chat, id:...}]body 里拆 3 个并列数组发后端。
var targets []map[string]interface{} var targets []map[string]interface{}
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil { if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err) return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
} }
users, departments, chats := splitAccessScopeTargets(targets) users, departments, chats := splitAccessScopeTargets(targets)
if len(users) > 0 { if len(users) > 0 {

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -42,14 +43,14 @@ var AppsChat = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
if strings.TrimSpace(rctx.Str("session-id")) == "" { if strings.TrimSpace(rctx.Str("session-id")) == "" {
return appsValidationParamError("--session-id", "--session-id is required") return output.ErrValidation("--session-id is required")
} }
// Do not echo --message content in the error (spec §4 redaction). // Do not echo --message content in the error (spec §4 redaction).
if strings.TrimSpace(rctx.Str("message")) == "" { if strings.TrimSpace(rctx.Str("message")) == "" {
return appsValidationParamError("--message", "--message is required") return output.ErrValidation("--message is required")
} }
return nil return nil
}, },

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -35,7 +36,7 @@ var AppsCreate = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" { if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required") return output.ErrValidation("--name is required")
} }
return nil return nil
}, },

View File

@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -48,31 +47,6 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
return parent.ExecuteContext(context.Background()) 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 测试 // +create 测试
func TestAppsCreate_Success(t *testing.T) { func TestAppsCreate_Success(t *testing.T) {

View File

@@ -31,9 +31,8 @@ import (
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示 // - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
// //
// 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵 // 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后按 partial failure 上报exit 非 0stdout 输出 ok:false 数据,带 results / // 后升级成 typed api_errorexit 非 0、detail 带 statement_index / completed / rolled_back
// statement_index / error_code / error_message / rolled_back / note避免 agent 误判 // 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit
// ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit
// 落地,故 rolled_back=false真机 boe 实证)。 // 落地,故 rolled_back=false真机 boe 实证)。
// //
// JSON envelope成功路径CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。 // JSON envelope成功路径CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
@@ -69,27 +68,19 @@ var AppsDBExecute = common.Shortcut{
sql := strings.TrimSpace(rctx.Str("sql")) sql := strings.TrimSpace(rctx.Str("sql"))
file := strings.TrimSpace(rctx.Str("file")) file := strings.TrimSpace(rctx.Str("file"))
if sql != "" && file != "" { if sql != "" && file != "" {
return appsValidationError("--sql and --file are mutually exclusive"). return output.ErrValidation("--sql and --file are mutually exclusive")
WithParams(
appsInvalidParam("--sql", "mutually exclusive with --file"),
appsInvalidParam("--file", "mutually exclusive with --sql"),
)
} }
if file != "" { if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil { if err != nil {
return appsValidationParamError("--file", "--file: %v", err).WithCause(err) return output.ErrValidation("--file: %v", err)
} }
// 归一化:把文件内容写回 --sql下游DryRun/Execute统一从 sql 取。 // 归一化:把文件内容写回 --sql下游DryRun/Execute统一从 sql 取。
rctx.Cmd.Flags().Set("sql", string(data)) rctx.Cmd.Flags().Set("sql", string(data))
sql = strings.TrimSpace(string(data)) sql = strings.TrimSpace(string(data))
} }
if sql == "" { if sql == "" {
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)"). return output.ErrValidation("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 return nil
}, },
@@ -122,15 +113,13 @@ var AppsDBExecute = common.Shortcut{
data := map[string]interface{}{"results": stmts} data := map[string]interface{}{"results": stmts}
// 多语句 / 单语句失败server 仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。 // 多语句 / 单语句失败server 仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。
// 已落地的前序语句 + 失败语句构成 partial failure逐条结果作为 ok:false 数据 // 升级成 typed api_errorexit 非 0别让 agent 误判 ok:true 假成功。
// 留在 stdout机器可读+ 非零退出信号,别让 agent 误判 ok:true 假成功 // pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→stderr
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope仅返回退出信号。
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed { if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
if rctx.Format == "pretty" { if rctx.Format == "pretty" {
renderSQLPretty(rctx.IO().Out, stmts) renderSQLPretty(rctx.IO().Out, stmts)
return output.PartialFailure(output.ExitAPI)
} }
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil) return sqlStatementError(stmts, errIdx, errStmt)
} }
rctx.OutFormat(data, nil, func(w io.Writer) { rctx.OutFormat(data, nil, func(w io.Writer) {
@@ -151,28 +140,31 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
return 0, nil, false return 0, nil, false
} }
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据 // sqlStatementError 把 ERROR 哨兵升级成 typed api_error
// //
// CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit // CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果ERROR 哨兵在 // 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句hint 提示用户
// 失败位置note 提示用户别整批重跑(否则会重复写入)。 // 别整批重跑(否则会重复写入)。
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} { func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看 stmtNo := errIdx + 1 // 1-based 给人看
note := "no statements were applied; fix the SQL and re-run." fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
if errIdx > 0 {
note = fmt.Sprintf( apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
"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, "statement_index": errIdx,
"error_code": code, "completed": stmts[:errIdx],
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
"rolled_back": false, "rolled_back": false,
"note": note, })
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."
}
} }
return apiErr
} }
// parseErrorSentinel 解析 ERROR 哨兵的 data`{code,message}` JSON返回数值 code 与 message。 // parseErrorSentinel 解析 ERROR 哨兵的 data`{code,message}` JSON返回数值 code 与 message。

View File

@@ -495,9 +495,9 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
} }
} }
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」: // TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」:
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout // json 默认不再打 ok:true 假成功,而是返回 *output.ExitErrortype=api_error、非零 exit
// 退出信号是 PartialFailureError非零 exit。rolled_back=false 因 CLI 永远 DBA 模式 // detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
// (真机 boe 实证:失败前的语句已落地)。 // (真机 boe 实证:失败前的语句已落地)。
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) { func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t) factory, stdout, reg := newAppsExecuteFactory(t)
@@ -518,64 +518,45 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"}, []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout) factory, stdout)
if err == nil { if err == nil {
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String()) t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
} }
// json 失败路径不得打成功 envelope。 // json 失败路径不得打成功 envelope。
if strings.Contains(stdout.String(), `"ok": true`) { if strings.Contains(stdout.String(), `"ok": true`) {
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String()) t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
} }
var pfErr *output.PartialFailureError var exitErr *output.ExitError
if !errors.As(err, &pfErr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
} }
if pfErr.Code != output.ExitAPI { if exitErr.Detail.Type != "api_error" {
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
} }
payload := decodePartialFailureData(t, stdout.String()) if exitErr.Detail.Code != 1300002 {
if got := payload["statement_index"]; got != float64(1) { t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
t.Errorf("statement_index = %v, want 1", got)
} }
if got := payload["error_code"]; got != float64(1300002) { if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
t.Errorf("error_code = %v, want 1300002", got) t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
} }
msg, _ := payload["error_message"].(string) if output.ExitCodeOf(err) != output.ExitAPI {
if !strings.Contains(msg, "(at statement 2 of 2)") { t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
t.Errorf("error_message missing statement locator: %q", msg)
} }
if got := payload["rolled_back"]; got != false { detail, ok := exitErr.Detail.Detail.(map[string]interface{})
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got) if !ok {
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
} }
results, _ := payload["results"].([]interface{}) if detail["statement_index"] != 1 {
if len(results) != 2 { t.Errorf("statement_index = %v, want 1", detail["statement_index"])
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
} }
note, _ := payload["note"].(string) if detail["rolled_back"] != false {
if !strings.Contains(note, "already applied") { t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
t.Errorf("note should warn prior statements persisted, got %q", note)
} }
} if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
// 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 哨兵) // TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败server 也返 code:0 + ERROR 哨兵)
// 同样走 partial failurestatement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。 // 同样升级成 typed errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) { func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t) factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
@@ -592,23 +573,21 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"}, []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout) factory, stdout)
if err == nil { if err == nil {
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String()) t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
} }
var pfErr *output.PartialFailureError var exitErr *output.ExitError
if !errors.As(err, &pfErr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
} }
payload := decodePartialFailureData(t, stdout.String()) if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
msg, _ := payload["error_message"].(string) t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
if !strings.Contains(msg, "(at statement 1 of 1)") {
t.Errorf("error_message missing locator: %q", msg)
} }
if got := payload["statement_index"]; got != float64(0) { detail, _ := exitErr.Detail.Detail.(map[string]interface{})
t.Errorf("statement_index = %v, want 0", got) if detail["statement_index"] != 0 {
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
} }
note, _ := payload["note"].(string) if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
if !strings.Contains(note, "no statements were applied") { t.Errorf("completed = %v, want empty", detail["completed"])
t.Errorf("note should say nothing was applied, got %q", note)
} }
} }
@@ -816,35 +795,3 @@ 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)
}
}

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -44,7 +45,7 @@ var AppsDBTableGet = common.Shortcut{
return err return err
} }
if strings.TrimSpace(rctx.Str("table")) == "" { if strings.TrimSpace(rctx.Str("table")) == "" {
return appsValidationParamError("--table", "--table is required") return output.ErrValidation("--table is required")
} }
return nil return nil
}, },

View File

@@ -47,11 +47,11 @@ var AppsEnvPull = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
} }
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil { if err != nil {
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err) return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
} }
if err := checkEnvPullTarget(envFile); err != nil { if err := checkEnvPullTarget(envFile); err != nil {
return err return err
@@ -71,7 +71,7 @@ var AppsEnvPull = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id")) appID := strings.TrimSpace(rctx.Str("app-id"))
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil { if err != nil {
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err) return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
} }
if err := checkEnvPullTarget(envFile); err != nil { if err := checkEnvPullTarget(envFile); err != nil {
return err return err
@@ -120,7 +120,7 @@ func resolveEnvPullTarget(projectPath string) (string, string, error) {
if strings.TrimSpace(projectPath) == "" { if strings.TrimSpace(projectPath) == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded. cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
if err != nil { if err != nil {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err) return "", "", fmt.Errorf("cannot determine working directory: %w", err)
} }
projectPath = cwd projectPath = cwd
} }
@@ -137,13 +137,13 @@ func checkEnvPullTarget(envFile string) error {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil return nil
} }
return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err) 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}
} }
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile) 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"}
} }
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return appsValidationParamError("--project-path", "target %s must be a regular file", envFile) 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 nil return nil
} }
@@ -156,7 +156,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
} }
} }
if raw == nil { if raw == nil {
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries") 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"}}
} }
var skippedKeys []string var skippedKeys []string
@@ -203,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
} }
return out, info, skippedKeys, nil return out, info, skippedKeys, nil
default: default:
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries") 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"}}
} }
} }

View File

@@ -1079,28 +1079,3 @@ func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) {
t.Error("MkdirAll over a file component must surface an error") 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)
}
})
}
}

View File

@@ -1,104 +0,0 @@
// 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
}

View File

@@ -1,113 +0,0 @@
// 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)
}
}

View File

@@ -10,7 +10,7 @@ import (
"strings" "strings"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -35,11 +35,11 @@ var AppsHTMLPublish = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
path := strings.TrimSpace(rctx.Str("path")) path := strings.TrimSpace(rctx.Str("path"))
if path == "" { if path == "" {
return appsValidationParamError("--path", "--path is required") return output.ErrValidation("--path is required")
} }
// Block well-known credential files in the publish payload unless the // Block well-known credential files in the publish payload unless the
// caller explicitly opts in. Lives in Validate (not DryRun) so that // 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], ", ") + sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError) fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
} }
return appsValidationParamError("--path", return output.ErrWithHint(output.ExitValidation, "validation",
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample). fmt.Sprintf("--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)") "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 约束。 // maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
@@ -178,14 +178,15 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
return nil return nil
} }
} }
return appsFailedPreconditionParamError("--path", "--path is missing index.html"). return output.ErrWithHint(output.ExitAPI, "validation",
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") "--path 中缺少 index.html",
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html单文件形态把文件命名为 index.html")
} }
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) { func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
candidates, err := walkHTMLPublishCandidates(fio, spec.Path) candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
if err != nil { if err != nil {
return nil, err return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
} }
if err := ensureIndexHTML(candidates); err != nil { if err := ensureIndexHTML(candidates); err != nil {
return nil, err return nil, err
@@ -195,24 +196,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
rawTotal += c.Size rawTotal += c.Size
} }
if rawTotal > maxHTMLPublishRawBytes { if rawTotal > maxHTMLPublishRawBytes {
return nil, appsValidationParamError("--path", return nil, output.ErrWithHint(output.ExitAPI, "validation",
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes). fmt.Sprintf("--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") "在 tar+gzip 进入内存前拦截,避免 OOM精简 --path 内容或选择更小的子目录")
} }
tarball, err := buildHTMLPublishTarball(fio, candidates) tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil { if err != nil {
return nil, err return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
} }
if tarball.Size > maxHTMLPublishTarballBytes { if tarball.Size > maxHTMLPublishTarballBytes {
return nil, appsValidationParamError("--path", return nil, output.ErrWithHint(output.ExitAPI, "validation",
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes). fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
WithHint("reduce --path contents, remove unrelated large files, then retry") "请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
} }
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball) resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil { if err != nil {
return nil, client.WrapDoAPIError(err) return nil, err
} }
out := map[string]interface{}{} out := map[string]interface{}{}

View File

@@ -10,6 +10,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/internal/output"
) )
type fakeAppsHTMLPublishClient struct { type fakeAppsHTMLPublishClient struct {
@@ -103,11 +105,17 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected error for missing index.html") t.Fatalf("expected error for missing index.html")
} }
problem := requireAppsValidationProblem(t, err) var exitErr *output.ExitError
if !strings.Contains(problem.Message, "index.html") { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("message missing 'index.html': %v", problem.Message) t.Fatalf("expected ExitError with detail, got %v", err)
} }
if problem.Hint == "" { 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 == "" {
t.Fatalf("expected non-empty hint") t.Fatalf("expected non-empty hint")
} }
if len(fake.calls) != 0 { if len(fake.calls) != 0 {
@@ -145,7 +153,10 @@ func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)") t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
} }
requireAppsValidationProblem(t, err) 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)
}
if len(fake.calls) != 0 { if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing") t.Fatalf("client must not be called when index.html missing")
} }
@@ -188,11 +199,17 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected oversize error") t.Fatalf("expected oversize error")
} }
problem := requireAppsValidationProblem(t, err) var exitErr *output.ExitError
if !strings.Contains(problem.Message, "exceeds") { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("message missing 'exceeds': %v", problem.Message) t.Fatalf("expected ExitError with detail, got %v", err)
} }
if problem.Hint == "" { 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 == "" {
t.Fatalf("expected non-empty hint") t.Fatalf("expected non-empty hint")
} }
if len(fake.calls) != 0 { if len(fake.calls) != 0 {
@@ -320,12 +337,18 @@ func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("dry-run with sensitive file should fail") t.Fatalf("dry-run with sensitive file should fail")
} }
problem := requireAppsValidationProblem(t, err) var exitErr *output.ExitError
if !strings.Contains(problem.Message, ".env") { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("error message should list the offending file, got %q", problem.Message) t.Fatalf("expected ExitError with detail, got %v", err)
} }
if !strings.Contains(problem.Hint, "--allow-sensitive") { if exitErr.Detail.Type != "validation" {
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint) 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)
} }
} }
@@ -415,9 +438,15 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing
if err == nil { if err == nil {
t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName) t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName)
} }
problem := requireAppsValidationProblem(t, err) var exitErr *output.ExitError
if !strings.Contains(problem.Message, tc.wantSubstr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("error message should name the leaked file, got %q", problem.Message) 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)
} }
}) })
} }
@@ -451,9 +480,15 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
if err == nil { if err == nil {
t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success") t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success")
} }
problem := requireAppsValidationProblem(t, err) var exitErr *output.ExitError
if !strings.Contains(problem.Message, "credentials") { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("error message should name the leaked file, got %q", problem.Message) 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)
} }
} }
@@ -463,7 +498,11 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
func TestSensitiveCandidatesError_Truncation(t *testing.T) { func TestSensitiveCandidatesError_Truncation(t *testing.T) {
hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"} hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"}
err := sensitiveCandidatesError(hits) err := sensitiveCandidatesError(hits)
msg := requireAppsValidationProblem(t, err).Message 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
if !strings.Contains(msg, "7 credential file(s)") { if !strings.Contains(msg, "7 credential file(s)") {
t.Fatalf("message should report the full count, got %q", msg) t.Fatalf("message should report the full count, got %q", msg)
} }
@@ -495,9 +534,15 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected raw-size cap to fire") t.Fatalf("expected raw-size cap to fire")
} }
problem := requireAppsValidationProblem(t, err) var exitErr *output.ExitError
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message) 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)
} }
if len(fake.calls) != 0 { if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit") t.Fatalf("client must not be called when raw cap hit")

View File

@@ -14,8 +14,8 @@ import (
"strings" "strings"
"unicode" "unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/charcheck" "github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -72,14 +72,14 @@ var AppsInit = common.Shortcut{
// exit-1 (root.go handleRootError case 4), bypassing the structured // exit-1 (root.go handleRootError case 4), bypassing the structured
// envelope. The spec and the E2E assert exit-2 + a structured // envelope. The spec and the E2E assert exit-2 + a structured
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty // {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
// check lives in Validate (typed validation error -> exit 2). // check lives in Validate (output.ErrValidation -> ExitValidation=2).
{Name: "app-id", Desc: "Miaoda app ID"}, {Name: "app-id", Desc: "Miaoda app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<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"}, {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 { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return nil 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 // path is a log-injection vector); charcheck additionally rejects dangerous
// Unicode (bidi overrides, zero-width) that IsControl does not. // Unicode (bidi overrides, zero-width) that IsControl does not.
if strings.IndexFunc(raw, unicode.IsControl) >= 0 { if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", appsValidationParamError("--dir", "--dir must not contain control characters") return "", output.ErrValidation("--dir must not contain control characters")
} }
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil { if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
return "", appsValidationParamError("--dir", "%v", err).WithCause(err) return "", output.ErrValidation("%v", 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). 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 { if err != nil {
return "", appsValidationParamError("--dir", "--dir cannot be resolved: %v", err) return "", output.ErrValidation("--dir cannot be resolved: %v", err)
} }
return abs, nil return abs, nil
} }
@@ -173,20 +173,20 @@ func ensureEmptyDir(dir string) error {
return nil return nil
} }
if err != nil { if err != nil {
return appsValidationParamError("--dir", "--dir cannot be read: %v", err) return output.ErrValidation("--dir cannot be read: %v", err)
} }
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir) return output.ErrValidation("--dir must not be a symlink: %q", dir)
} }
if !info.IsDir() { if !info.IsDir() {
return appsValidationParamError("--dir", "--dir exists and is not a directory: %q", dir) return output.ErrValidation("--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. 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 { if err != nil {
return appsValidationParamError("--dir", "--dir cannot be read: %v", err) return output.ErrValidation("--dir cannot be read: %v", err)
} }
if len(entries) > 0 { if len(entries) > 0 {
return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir) return output.ErrValidation("target directory %q already exists and is not empty", dir)
} }
return nil return nil
} }
@@ -209,11 +209,11 @@ func ensureMetaAppID(dir, appID string) error {
return nil return nil
} }
if err != nil { if err != nil {
return appsFileIOError(err, "read %s failed: %v", metaRelPath, err) return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
} }
var m map[string]interface{} var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil { if err := json.Unmarshal(b, &m); err != nil {
return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err) return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
} }
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" { if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
return nil return nil
@@ -224,10 +224,10 @@ func ensureMetaAppID(dir, appID string) error {
m["app_id"] = appID m["app_id"] = appID
out, err := json.MarshalIndent(m, "", " ") out, err := json.MarshalIndent(m, "", " ")
if err != nil { if err != nil {
return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err) return output.Errorf(output.ExitAPI, "meta_write", "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. 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 appsFileIOError(err, "write %s failed: %v", metaRelPath, err) return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
} }
return nil return nil
} }
@@ -244,7 +244,7 @@ func hasSteeringSkills(dir string) bool {
func isEmptyRepo(ctx context.Context, dir string) (bool, error) { func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files") stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
if err != nil { if err != nil {
return false, appsExternalToolError(err, "git ls-files failed: %s", gitErr(stderr, err)) return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
} }
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
f := strings.TrimSpace(line) 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 // seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly. // 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 { if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)) return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
} }
return scaffoldKindInit, nil return scaffoldKindInit, nil
} }
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil { if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err)) return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
} }
if err := ensureMetaAppID(dir, appID); err != nil { if err := ensureMetaAppID(dir, appID); err != nil {
return "", err return "", err
} }
if !hasSteeringSkills(dir) { if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil { if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err)) return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
} }
} }
return scaffoldKindUpgrade, nil return scaffoldKindUpgrade, nil
@@ -303,13 +303,13 @@ func parseRepoURLFromEnvelope(stdout string) (string, error) {
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal([]byte(stdout), &env); err != nil { if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err) return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
} }
if !env.OK { if !env.OK {
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure") return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
} }
if strings.TrimSpace(env.Data.RepositoryURL) == "" { if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url") return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
} }
return env.Data.RepositoryURL, nil return env.Data.RepositoryURL, nil
} }
@@ -324,13 +324,13 @@ func parseEnvFileFromEnvelope(stdout string) (string, error) {
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal([]byte(stdout), &env); err != nil { if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err) return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
} }
if !env.OK { if !env.OK {
return "", appsSubprocessEnvelopeError("+env-pull reported failure") return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
} }
if strings.TrimSpace(env.Data.EnvFile) == "" { if strings.TrimSpace(env.Data.EnvFile) == "" {
return "", appsSubprocessEnvelopeError("+env-pull returned no env_file") return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
} }
return env.Data.EnvFile, nil return env.Data.EnvFile, nil
} }
@@ -364,9 +364,7 @@ func validateRepoURLScheme(repoURL string) error {
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
return nil return nil
} }
// The URL comes from the +git-credential-init subprocess response, not user return output.Errorf(output.ExitValidation, "validation",
// 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)) "repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
} }
@@ -417,12 +415,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
} }
if _, err := exec.LookPath("git"); err != nil { if _, err := exec.LookPath("git"); err != nil {
return appsFailedPreconditionError("git executable not found on PATH"). return output.ErrWithHint(output.ExitInternal, "dependency",
WithHint("install git and ensure it is on your PATH") "git executable not found on PATH", "install git and ensure it is on your PATH")
} }
if _, err := exec.LookPath("npx"); err != nil { if _, err := exec.LookPath("npx"); err != nil {
return appsFailedPreconditionError("npx executable not found on PATH"). return output.ErrWithHint(output.ExitInternal, "dependency",
WithHint("install Node.js (which provides npx) and ensure it is on your PATH") "npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
} }
if err := ensureEmptyDir(dir); err != nil { if err := ensureEmptyDir(dir); err != nil {
@@ -440,11 +438,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
initLogf(rctx, "Cloning into %s...", dir) initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil { if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err)) return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
} }
initLogf(rctx, "Checking out %s...", defaultInitBranch) initLogf(rctx, "Checking out %s...", defaultInitBranch)
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil { if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err)) return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
} }
initLogf(rctx, "Initializing app code (running miaoda-cli)...") initLogf(rctx, "Initializing app code (running miaoda-cli)...")
@@ -538,7 +536,7 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) { func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err) return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
} }
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"} args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" { if as := strings.TrimSpace(rctx.Str("as")); as != "" {
@@ -546,9 +544,9 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
} }
stdout, stderr, err := initRunner.Run(ctx, "", self, args...) stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil { if err != nil {
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)). return "", output.ErrWithHint(output.ExitAPI, "credential_init",
WithHint("ensure apps +git-credential-init is available and you are logged in"). fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
WithCause(err) "ensure apps +git-credential-init is available and you are logged in")
} }
return parseRepoURLFromEnvelope(stdout) return parseRepoURLFromEnvelope(stdout)
} }
@@ -562,7 +560,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) { func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain") status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
if runErr != nil { if runErr != nil {
return false, false, appsExternalToolError(runErr, "git status failed: %s", gitErr(stderr, runErr)) return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
} }
if strings.TrimSpace(status) == "" { if strings.TrimSpace(status) == "" {
return false, false, nil return false, false, nil
@@ -597,7 +595,7 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil { if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
return true, false, withAppsHint( return true, false, withAppsHint(
appsExternalToolError(e, "git push failed: %s", gitErr(se, e)), output.Errorf(output.ExitAPI, "git_push", "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") "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 return true, true, nil
@@ -611,10 +609,10 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error { func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
addArgs := append([]string{"add", "-A", "--"}, pathspecs...) addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil { if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
return appsExternalToolError(e, "git add failed: %s", gitErr(se, e)) return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
} }
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil { if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
return appsExternalToolError(e, "git commit failed: %s", gitErr(se, e)) return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
} }
return nil return nil
} }

View File

@@ -17,7 +17,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
@@ -1467,28 +1466,3 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
t.Errorf("Description should mention app code: %q", AppsInit.Description) 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)
}
}

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -32,7 +33,7 @@ var AppsReleaseCreate = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -31,10 +32,10 @@ var AppsReleaseGet = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
if strings.TrimSpace(rctx.Str("release-id")) == "" { if strings.TrimSpace(rctx.Str("release-id")) == "" {
return appsValidationParamError("--release-id", "--release-id is required") return output.ErrValidation("--release-id is required")
} }
return nil return nil
}, },
@@ -57,9 +58,6 @@ var AppsReleaseGet = common.Shortcut{
out := data out := data
if release, ok := data["release"].(map[string]interface{}); ok { if release, ok := data["release"].(map[string]interface{}); ok {
out = release out = release
if el, ok := data["error_logs"]; ok {
out["error_logs"] = el
}
} }
rctx.OutFormat(out, nil, func(w io.Writer) { rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n", fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",

View File

@@ -134,15 +134,13 @@ func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
URL: "/open-apis/spark/v1/apps/app_x/releases/6", URL: "/open-apis/spark/v1/apps/app_x/releases/6",
Body: map[string]interface{}{ Body: map[string]interface{}{
"code": 0, "msg": "", "code": 0, "msg": "",
"data": map[string]interface{}{ "data": map[string]interface{}{"release": map[string]interface{}{
"release": map[string]interface{}{ "release_id": "6", "status": "failed",
"release_id": "6", "status": "failed", "created_at": "1700000000000", "updated_at": "1700000000050",
"created_at": "1700000000000", "updated_at": "1700000000050",
},
"error_logs": []interface{}{ "error_logs": []interface{}{
map[string]interface{}{"step": "build", "error_log": "compile error"}, map[string]interface{}{"step": "build", "error_log": "compile error"},
}, },
}, }},
}, },
}) })
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
@@ -202,13 +200,11 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9", Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
Body: map[string]interface{}{"code": 0, "msg": "", Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{ "data": map[string]interface{}{"release": map[string]interface{}{
"release": map[string]interface{}{ "release_id": "9", "status": "failed",
"release_id": "9", "status": "failed", "created_at": "1700000000000", "updated_at": "1700000000050",
"created_at": "1700000000000", "updated_at": "1700000000050",
},
"error_logs": []interface{}{}, "error_logs": []interface{}{},
}}, }}},
}) })
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err) t.Fatalf("Execute() = %v", err)
@@ -218,69 +214,6 @@ 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) { func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10") rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
rctx.Format = "pretty" rctx.Format = "pretty"

View File

@@ -35,7 +35,7 @@ var AppsReleaseList = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -30,7 +31,7 @@ var AppsSessionCreate = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -32,10 +33,10 @@ var AppsSessionGet = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
if strings.TrimSpace(rctx.Str("session-id")) == "" { if strings.TrimSpace(rctx.Str("session-id")) == "" {
return appsValidationParamError("--session-id", "--session-id is required") return output.ErrValidation("--session-id is required")
} }
return nil return nil
}, },

View File

@@ -32,7 +32,7 @@ var AppsSessionList = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -34,13 +35,13 @@ var AppsSessionStop = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
if strings.TrimSpace(rctx.Str("session-id")) == "" { if strings.TrimSpace(rctx.Str("session-id")) == "" {
return appsValidationParamError("--session-id", "--session-id is required") return output.ErrValidation("--session-id is required")
} }
if strings.TrimSpace(rctx.Str("turn-id")) == "" { if strings.TrimSpace(rctx.Str("turn-id")) == "" {
return appsValidationParamError("--turn-id", "--turn-id is required") return output.ErrValidation("--turn-id is required")
} }
return nil return nil
}, },

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -33,15 +34,11 @@ var AppsUpdate = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
body := buildAppsUpdateBody(rctx) body := buildAppsUpdateBody(rctx)
if len(body) == 0 { if len(body) == 0 {
return appsValidationError("provide at least one of --name or --description"). return output.ErrValidation("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 return nil
}, },

View File

@@ -4,9 +4,11 @@
package apps package apps
import ( import (
"errors"
"strings" "strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
) )
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。 // appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
@@ -21,11 +23,11 @@ const apiBasePath = "/open-apis/spark/v1"
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it. // 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`" 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 typed failure, // withAppsHint attaches an actionable next-step hint to a failure returned by
// preserving its original classification (subtype/code/log_id). A hint already // CallAPI, preserving its original classification (typed subtype/code/log_id or
// present on the error is kept (the upstream wording wins); only an empty hint // legacy detail). A hint already present on the error is kept (the upstream
// is filled in. Mirrors drive.appendDriveExportRecoveryHint. err==nil and // wording wins); only an empty hint is filled in. Mirrors
// untyped errors pass through unchanged. // drive.appendDriveExportRecoveryHint. err==nil passes through.
func withAppsHint(err error, hint string) error { func withAppsHint(err error, hint string) error {
if err == nil { if err == nil {
return nil return nil
@@ -37,5 +39,14 @@ func withAppsHint(err error, hint string) error {
} }
return err 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 return err
} }

View File

@@ -7,7 +7,7 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
) )
func TestWithAppsHint(t *testing.T) { func TestWithAppsHint(t *testing.T) {
@@ -17,40 +17,46 @@ func TestWithAppsHint(t *testing.T) {
} }
}) })
t.Run("empty hint gets filled, classification preserved", func(t *testing.T) { t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
in := errs.NewAPIError(errs.SubtypeNotFound, "boom").WithCode(404) in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
out := withAppsHint(in, "run +release-list") out := withAppsHint(in, "run +release-list")
p, ok := errs.ProblemOf(out) var exitErr *output.ExitError
if !ok { if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not typed: %T", out) t.Fatalf("returned error is not *output.ExitError: %T", out)
} }
if p.Hint != "run +release-list" { if exitErr.Detail.Hint != "run +release-list" {
t.Errorf("Hint = %q, want %q", p.Hint, "run +release-list") t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
} }
if p.Subtype != errs.SubtypeNotFound || p.Code != 404 || p.Message != "boom" { if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
t.Errorf("subtype/code/message mutated: subtype=%q code=%d msg=%q", p.Subtype, p.Code, p.Message) t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
} }
}) })
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) { t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("original hint") in := output.ErrWithHint(1, "api_error", "boom", "original hint")
out := withAppsHint(in, "new hint") out := withAppsHint(in, "new hint")
p, _ := errs.ProblemOf(out) var exitErr *output.ExitError
if p.Hint != "original hint" { if !errors.As(out, &exitErr) {
t.Errorf("Hint = %q, want preserved %q", p.Hint, "original hint") 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")
} }
}) })
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) { t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint(" ") in := output.ErrWithHint(1, "api_error", "boom", " ")
out := withAppsHint(in, "filled hint") out := withAppsHint(in, "filled hint")
p, _ := errs.ProblemOf(out) var exitErr *output.ExitError
if p.Hint != "filled hint" { if !errors.As(out, &exitErr) {
t.Errorf("Hint = %q, want %q", p.Hint, "filled hint") 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")
} }
}) })
t.Run("untyped error returned unchanged, no panic", func(t *testing.T) { t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
in := errors.New("plain") in := errors.New("plain")
out := withAppsHint(in, "ignored") out := withAppsHint(in, "ignored")
if out == nil || out.Error() != "plain" { if out == nil || out.Error() != "plain" {

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
) )
@@ -36,7 +37,7 @@ func appDbEnvCreatePath(appID string) string {
func requireAppID(raw string) (string, error) { func requireAppID(raw string) (string, error) {
id := strings.TrimSpace(raw) id := strings.TrimSpace(raw)
if id == "" { if id == "" {
return "", appsValidationParamError("--app-id", "--app-id is required") return "", output.ErrValidation("--app-id is required")
} }
return id, nil return id, nil
} }

View File

@@ -6,6 +6,7 @@ package apps
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -21,11 +22,10 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/apps/gitcred" "github.com/larksuite/cli/shortcuts/apps/gitcred"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
@@ -53,12 +53,9 @@ var AppsGitCredentialInit = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil { return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
}, },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id")) appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -132,12 +129,9 @@ var AppsGitCredentialRemove = common.Shortcut{
}, },
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" { if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required") return output.ErrValidation("--app-id is required")
} }
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil { return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
}, },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id")) appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -274,7 +268,7 @@ func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.
HttpMethod: http.MethodGet, HttpMethod: http.MethodGet,
ApiPath: issuePath(appID), ApiPath: issuePath(appID),
}) })
data, err := parseIssueCredentialData(resp, err, i.rctx.APIClassifyContext()) data, err := parseIssueCredentialData(resp, err)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -291,8 +285,7 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
return nil, err return nil, err
} }
if cfg.UserOpenId == "" { if cfg.UserOpenId == "" {
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"). return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
} }
ac, err := i.f.NewAPIClientWithConfig(cfg) ac, err := i.f.NewAPIClientWithConfig(cfg)
if err != nil { if err != nil {
@@ -303,11 +296,7 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
ApiPath: issuePath(appID), ApiPath: issuePath(appID),
} }
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser) resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{ data, err := parseIssueCredentialData(resp, err)
Brand: string(cfg.Brand),
AppID: cfg.AppID,
Identity: string(core.AsUser),
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -425,11 +414,13 @@ func gitCredentialLocalError(action string, err error) error {
if err == nil { if err == nil {
return 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 { if _, ok := errs.UnwrapTypedError(err); ok {
return err return err
} }
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return &errs.ConfigError{Problem: errs.Problem{ return &errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig, Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig, Subtype: errs.SubtypeInvalidConfig,
@@ -457,43 +448,64 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
issued.AppID = appID issued.AppID = appID
} }
if issued.GitHTTPURL == "" { if issued.GitHTTPURL == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL") return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
} }
if issued.PAT == "" { if issued.PAT == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token") return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
} }
return issued, nil return issued, nil
} }
// parseIssueCredentialData turns the git-credential issue response into the func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) {
// 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 { if err != nil {
return nil, client.WrapDoAPIError(err) return nil, err
} }
detail := logIDDetail(resp) detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 { if resp == nil || len(resp.RawBody) == 0 {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, return nil, &errs.InternalError{Problem: errs.Problem{
"Issue Miaoda Git credential: empty response body") Category: errs.CategoryInternal,
Subtype: errs.SubtypeUnknown,
Message: "Issue Miaoda Git credential: empty response body",
}}
} }
var result map[string]any var result map[string]any
jsonErr := json.Unmarshal(resp.RawBody, &result) if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil {
_, hasCode := result["code"] return nil, &errs.InternalError{Problem: errs.Problem{
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest { Category: errs.CategoryInternal,
data, cerr := common.ClassifyAPIResponseWith(resp, cc) Subtype: errs.SubtypeUnknown,
if cerr != nil { Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr),
return nil, withAppsHint(cerr, gitCredentialIssueHint) }, Cause: jsonErr}
}
if resp.StatusCode >= http.StatusBadRequest {
msg := firstString(result, "msg", "message")
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
} }
if data != nil { 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 {
result = data 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 { } else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil {
return nil, err return nil, err
} }
@@ -522,11 +534,13 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
if message == "" { if message == "" {
message = "Git credential API returned non-zero BaseResp status" message = "Git credential API returned non-zero BaseResp status"
} }
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code)) return &errs.APIError{Problem: errs.Problem{
if logID != "" { Category: errs.CategoryAPI,
baseErr = baseErr.WithLogID(logID) Subtype: errs.SubtypeUnknown,
} Code: int(code),
return baseErr Message: "Issue Miaoda Git credential: " + message,
LogID: logID,
}}
} }
return nil return nil
} }
@@ -564,9 +578,6 @@ func firstInt64(data map[string]interface{}, keys ...string) int64 {
return int64(v) return int64(v)
case float64: case float64:
return int64(v) return int64(v)
case json.Number:
n, _ := v.Int64()
return n
case string: case string:
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
return n return n

View File

@@ -5,6 +5,7 @@ package apps
import ( import (
"errors" "errors"
"fmt"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -34,7 +35,7 @@ func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return []string{}, nil return []string{}, nil
} }
return nil, appsStorageError(err, "apps storage: read root: %v", err) return nil, fmt.Errorf("apps storage: read root: %w", err)
} }
appIDs := make([]string, 0, len(entries)) appIDs := make([]string, 0, len(entries))
for _, e := range entries { for _, e := range entries {

View File

@@ -25,8 +25,8 @@ import (
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/apps/gitcred" "github.com/larksuite/cli/shortcuts/apps/gitcred"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -213,7 +213,7 @@ func TestParseIssueCredentialDataAcceptsDirectBaseRespShape(t *testing.T) {
"expiredTime":1780050600, "expiredTime":1780050600,
"BaseResp":{"StatusCode":0,"StatusMessage":"ok"} "BaseResp":{"StatusCode":0,"StatusMessage":"ok"}
}`), }`),
}, nil, errclass.ClassifyContext{}) }, nil)
if err != nil { if err != nil {
t.Fatalf("parseIssueCredentialData returned error: %v", err) t.Fatalf("parseIssueCredentialData returned error: %v", err)
} }
@@ -717,9 +717,9 @@ func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
t.Fatalf("typed error was rewrapped: %#v", got) t.Fatalf("typed error was rewrapped: %#v", got)
} }
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app") exitErr := output.ErrValidation("bad app")
if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) { if got := gitCredentialLocalError("action", exitErr); got != exitErr {
t.Fatalf("typed validation error was rewrapped: %#v", got) t.Fatalf("legacy output error was rewrapped: %#v", got)
} }
if got := gitCredentialLocalError("action", nil); got != nil { if got := gitCredentialLocalError("action", nil); got != nil {
@@ -925,43 +925,43 @@ func TestGitCredentialHelpersAndParsers(t *testing.T) {
} }
func TestParseIssueCredentialDataErrors(t *testing.T) { func TestParseIssueCredentialDataErrors(t *testing.T) {
if _, err := parseIssueCredentialData(nil, errors.New("transport failed"), errclass.ClassifyContext{}); err == nil { if _, err := parseIssueCredentialData(nil, errors.New("transport failed")); err == nil {
t.Fatal("parseIssueCredentialData transport error returned nil") t.Fatal("parseIssueCredentialData transport error returned nil")
} }
if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil { if _, err := parseIssueCredentialData(nil, nil); err == nil {
t.Fatal("parseIssueCredentialData nil response returned nil") t.Fatal("parseIssueCredentialData nil response returned nil")
} }
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); err == nil { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil); err == nil {
t.Fatal("parseIssueCredentialData bad json returned nil") t.Fatal("parseIssueCredentialData bad json returned nil")
} }
header := http.Header{"X-Tt-Logid": []string{"log_x"}} 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, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "bad request") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "bad request") {
t.Fatalf("HTTP error = %v", err) t.Fatalf("HTTP error = %v", err)
} }
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "HTTP 500") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("HTTP fallback error = %v", err) t.Fatalf("HTTP fallback error = %v", err)
} }
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") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "failed") {
t.Fatalf("code error = %v", err) t.Fatalf("code error = %v", err)
} }
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil, errclass.ClassifyContext{}) data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil)
if err != nil { if err != nil {
t.Fatalf("code zero without data returned error: %v", err) t.Fatalf("code zero without data returned error: %v", err)
} }
if data["log_id"] != "log_x" { if data["log_id"] != "log_x" {
t.Fatalf("log_id = %v", data["log_id"]) t.Fatalf("log_id = %v", data["log_id"])
} }
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil, errclass.ClassifyContext{}) data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil)
if err != nil { if err != nil {
t.Fatalf("null response with log id returned error: %v", err) t.Fatalf("null response with log id returned error: %v", err)
} }
if data["log_id"] != "log_x" { if data["log_id"] != "log_x" {
t.Fatalf("null response log_id = %v", data["log_id"]) 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, errclass.ClassifyContext{}); 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); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("BaseResp error = %v", err) t.Fatalf("BaseResp error = %v", err)
} }
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") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
t.Fatalf("BaseResp fallback error = %v", err) 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. // credential issuance failure is flagged retryable and carries the developer-access hint.
func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) { func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}} header := http.Header{"X-Tt-Logid": []string{"log_x"}}
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil, errclass.ClassifyContext{}) _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil)
if err == nil { if err == nil {
t.Fatal("expected 503 error, got 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. // non-zero business code (no HTTP status) carries the hint but is not retryable.
func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) { func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}} 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, errclass.ClassifyContext{}) _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil)
if err == nil { if err == nil {
t.Fatal("expected business-code error, got nil") t.Fatal("expected business-code error, got nil")
} }
@@ -1014,10 +1014,11 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
// server msg and assert (a) Message equals that msg exactly, and (b) neither // server msg and assert (a) Message equals that msg exactly, and (b) neither
// Message nor Hint contains any token/secret-shaped string. // Message nor Hint contains any token/secret-shaped string.
// //
// Note: server msg passthrough is the shared classifier's responsibility; // Note: server msg passthrough is the framework's responsibility; apps adds
// apps adds only a static hint. There is no msg redaction in this path, so // only a static hint. There is no msg redaction in this path (verbatim
// this test does not assert a redaction that does not exist — it asserts // passthrough is the existing behavior), so this test does not assert a
// that apps injects nothing sensitive of its own. // redaction that does not exist — it asserts that apps injects nothing
// sensitive of its own.
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) { func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
const serverMsg = "permission denied" const serverMsg = "permission denied"
header := http.Header{"X-Tt-Logid": []string{"log_x"}} header := http.Header{"X-Tt-Logid": []string{"log_x"}}
@@ -1044,7 +1045,7 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
_, err := parseIssueCredentialData(tc.resp, nil, errclass.ClassifyContext{}) _, err := parseIssueCredentialData(tc.resp, nil)
if err == nil { if err == nil {
t.Fatal("expected an error, got nil") t.Fatal("expected an error, got nil")
} }
@@ -1052,12 +1053,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
} }
// (a) The server msg survives into the message. The business-code // (a) The server msg is passed through verbatim.
// path passes it through verbatim; the HTTP-status path reports if p.Message != serverMsg {
// "HTTP <status>: <body>" via the shared classifier, so assert t.Fatalf("Message = %q, want server msg %q (verbatim passthrough)", p.Message, serverMsg)
// 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, // apps adds only the static hint — assert that exact static text,
// proving apps injects no per-request secret into the hint either. // proving apps injects no per-request secret into the hint either.
@@ -1140,45 +1138,3 @@ exit 0
} }
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) 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")
}
})
}

View File

@@ -5,10 +5,10 @@ package gitcred
import ( import (
"context" "context"
"fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
) )
@@ -38,7 +38,7 @@ func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string
return err return err
} }
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) { if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL) return fmt.Errorf("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 { if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
return err return err
@@ -106,7 +106,7 @@ func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
if isGitConfigGetMissing(err) { if isGitConfigGetMissing(err) {
return "", false, nil return "", false, nil
} }
return "", false, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(err) return "", false, fmt.Errorf("get %s: %w", key, err)
} }
func gitConfigUnset(ctx context.Context, key string) error { func gitConfigUnset(ctx context.Context, key string) error {
@@ -114,7 +114,7 @@ func gitConfigUnset(ctx context.Context, key string) error {
if isGitConfigUnsetMissing(err) { if isGitConfigUnsetMissing(err) {
return nil return nil
} }
return errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(err) return fmt.Errorf("unset %s: %w", key, err)
} }
return nil return nil
} }

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "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) { func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
appID = strings.TrimSpace(appID) appID = strings.TrimSpace(appID)
if appID == "" { if appID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") return nil, output.ErrValidation("--app-id is required")
} }
if err := validate.ResourceName(appID, "--app-id"); err != nil { if err := validate.ResourceName(appID, "--app-id"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err) return nil, err
} }
if profile.UserOpenID == "" { if profile.UserOpenID == "" {
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`") return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
} }
unlockApp, err := lockApp(appID) unlockApp, err := lockApp(appID)
if err != nil { if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err) return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
} }
defer unlockApp() defer unlockApp()
if m.Issuer == nil { if m.Issuer == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "git credential issuer is not configured") return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured")
} }
issued, err := m.Issuer.Issue(ctx, appID, profile) issued, err := m.Issuer.Issue(ctx, appID, profile)
if err != nil { 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) { func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
appID = strings.TrimSpace(appID) appID = strings.TrimSpace(appID)
if appID == "" { if appID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") return nil, output.ErrValidation("--app-id is required")
} }
if err := validate.ResourceName(appID, "--app-id"); err != nil { if err := validate.ResourceName(appID, "--app-id"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err) return nil, err
} }
unlockApp, err := lockApp(appID) unlockApp, err := lockApp(appID)
if err != nil { if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err) return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
} }
defer unlockApp() defer unlockApp()
records, err := m.Store.FindByAppID(appID, ProfileContext{}) records, err := m.Store.FindByAppID(appID, ProfileContext{})
@@ -335,7 +335,7 @@ func (m *Manager) Erase(r io.Reader) error {
} }
unlockApp, err := lockApp(record.AppID) unlockApp, err := lockApp(record.AppID)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err) return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err)
} }
defer unlockApp() defer unlockApp()
record, err = m.Store.FindByURL(url) record, err = m.Store.FindByURL(url)
@@ -360,8 +360,7 @@ func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialR
return CredentialRecord{}, "", false, err return CredentialRecord{}, "", false, err
} }
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID { if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
return CredentialRecord{}, "", false, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential"). 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)
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) pat, err := m.Secrets.Get(record.PATRef)
if err != nil { if err != nil {
@@ -424,7 +423,7 @@ func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
func parseNormalizedForInput(raw string) (CredentialInput, error) { func parseNormalizedForInput(raw string) (CredentialInput, error) {
parts := strings.SplitN(raw, "://", 2) parts := strings.SplitN(raw, "://", 2)
if len(parts) != 2 { if len(parts) != 2 {
return CredentialInput{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid credential URL") return CredentialInput{}, output.ErrValidation("invalid credential URL")
} }
hostPath := parts[1] hostPath := parts[1]
idx := strings.Index(hostPath, "/") idx := strings.Index(hostPath, "/")
@@ -458,19 +457,19 @@ func defaultUsername(username string) string {
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error { func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
if issued == nil { if issued == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential") return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential")
} }
if issued.AppID != "" && issued.AppID != appID { if issued.AppID != "" && 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) 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)
} }
if normalizedURL == "" { if normalizedURL == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL") return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
} }
if strings.TrimSpace(issued.PAT) == "" { if strings.TrimSpace(issued.PAT) == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token") return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
} }
if issued.ExpiresAt <= now { if issued.ExpiresAt <= now {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future") return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future")
} }
return nil return nil
} }

View File

@@ -5,12 +5,12 @@ package gitcred
import ( import (
"errors" "errors"
"fmt"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sync" "sync"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile" "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. "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) { func lockApp(appID string) (func(), error) {
dir := filepath.Join(core.GetConfigDir(), "locks") dir := filepath.Join(core.GetConfigDir(), "locks")
if err := vfs.MkdirAll(dir, 0700); err != nil { if err := vfs.MkdirAll(dir, 0700); err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err) return nil, fmt.Errorf("create Git credential lock dir: %w", err)
} }
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock" name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock"
lock := lockfile.New(filepath.Join(dir, filepath.Base(name))) lock := lockfile.New(filepath.Join(dir, filepath.Base(name)))

View File

@@ -12,17 +12,17 @@ import (
"path" "path"
"strings" "strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
) )
func NormalizeGitHTTPURL(raw string) (string, error) { func NormalizeGitHTTPURL(raw string) (string, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty") return "", output.ErrValidation("git_http_url is empty")
} }
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil { if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err) return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err)
} }
return normalizeParsedURL(u) return normalizeParsedURL(u)
} }
@@ -31,7 +31,7 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
protocol := strings.TrimSpace(input.Protocol) protocol := strings.TrimSpace(input.Protocol)
host := strings.TrimSpace(input.Host) host := strings.TrimSpace(input.Host)
if protocol == "" || host == "" { if protocol == "" || host == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential input must include protocol and host") return "", output.ErrValidation("git credential input must include protocol and host")
} }
u := &url.URL{ u := &url.URL{
Scheme: protocol, Scheme: protocol,
@@ -44,11 +44,11 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
func normalizeParsedURL(u *url.URL) (string, error) { func normalizeParsedURL(u *url.URL) (string, error) {
scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
if scheme != "http" && scheme != "https" { if scheme != "http" && scheme != "https" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs") return "", output.ErrValidation("git credential only supports http/https URLs")
} }
host := normalizeHost(scheme, u.Host) host := normalizeHost(scheme, u.Host)
if host == "" { if host == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url host is empty") return "", output.ErrValidation("git_http_url host is empty")
} }
cleanPath := cleanURLPath(u.EscapedPath()) cleanPath := cleanURLPath(u.EscapedPath())
normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String() normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String()

View File

@@ -6,13 +6,13 @@ package apps
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -39,18 +39,28 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar
Body: fd, Body: fd,
}, larkcore.WithFileUpload()) }, larkcore.WithFileUpload())
if err != nil { if err != nil {
return nil, client.WrapDoAPIError(err) return nil, err
} }
data, err := api.runtime.ClassifyAPIResponse(apiResp) return parseHTMLPublishResponse(apiResp.RawBody)
if err != nil { }
return nil, enrichHTMLPublishAPIError(err)
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"`
} }
url, _ := data["url"].(string) if err := json.Unmarshal(raw, &envelope); err != nil {
if url == "" { return nil, fmt.Errorf("decode html-publish response: %w", err)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"html-publish response is missing the published app url")
} }
return &htmlPublishResponse{URL: url}, nil 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
} }
// OAPI business error codes returned by the Miaoda // OAPI business error codes returned by the Miaoda
@@ -64,9 +74,9 @@ const (
func buildHTMLPublishFailureHint(code int) string { func buildHTMLPublishFailureHint(code int) string {
switch code { switch code {
case errCodeBuildFailed: case errCodeBuildFailed:
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" return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
case errCodeAppNotFound: case errCodeAppNotFound:
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)" return "应用不存在或无权访问;请用户确认 app_id从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx /app/ 后面提取,或直接给 app_xxx 字符串)"
default: default:
return "" return ""
} }

View File

@@ -6,21 +6,21 @@ package apps
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"mime" "mime"
"mime/multipart" "mime/multipart"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper() t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{ cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()), AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret", AppSecret: "test-secret",
@@ -94,57 +94,15 @@ func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected error") t.Fatalf("expected error")
} }
problem := requireAppsAPIProblem(t, err) var exitErr *output.ExitError
if problem.Code != errCodeBuildFailed { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed) t.Fatalf("expected ExitError with detail, got %v", err)
} }
if problem.Hint == "" { if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001") t.Fatalf("expected non-empty hint on code 90001")
} }
if !strings.Contains(problem.Message, "build failed") { if !strings.Contains(exitErr.Detail.Message, "build failed") {
t.Fatalf("missing failure message: %v", problem.Message) t.Fatalf("missing failure message: %v", exitErr.Detail.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)
} }
} }
@@ -180,18 +138,8 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing
} }
} }
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) { func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) {
rctx, reg := newAppsClientRuntime(t) if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil {
reg.Register(&httpmock.Stub{ t.Error("malformed html-publish response must surface a decode error")
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)
} }
} }

View File

@@ -9,9 +9,10 @@ import (
"compress/gzip" "compress/gzip"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt"
"io" "io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
) )
@@ -25,7 +26,7 @@ type htmlPublishTarball struct {
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) { func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
if len(candidates) == 0 { if len(candidates) == 0 {
return nil, appsValidationParamError("--path", "no files to pack") return nil, errors.New("no files to pack")
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -44,10 +45,10 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
if err := tw.Close(); err != nil { if err := tw.Close(); err != nil {
_ = gz.Close() _ = gz.Close()
return nil, appsFileIOError(err, "tar close: %v", err) return nil, fmt.Errorf("tar close: %w", err)
} }
if err := gz.Close(); err != nil { if err := gz.Close(); err != nil {
return nil, appsFileIOError(err, "gzip close: %v", err) return nil, fmt.Errorf("gzip close: %w", err)
} }
return &htmlPublishTarball{ return &htmlPublishTarball{
@@ -59,12 +60,12 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error { func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
if isUnsafeRelPath(c.RelPath) { if isUnsafeRelPath(c.RelPath) {
return errs.NewInternalError(errs.SubtypeUnknown, "invalid tar entry name %q", c.RelPath) return fmt.Errorf("invalid tar entry name %q", c.RelPath)
} }
src, err := fio.Open(c.AbsPath) src, err := fio.Open(c.AbsPath)
if err != nil { if err != nil {
return appsInputPathEntryError(c.AbsPath, err) return fmt.Errorf("open %s: %w", c.AbsPath, err)
} }
defer src.Close() defer src.Close()
@@ -75,10 +76,10 @@ func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCa
Typeflag: tar.TypeReg, Typeflag: tar.TypeReg,
} }
if err := tw.WriteHeader(hdr); err != nil { if err := tw.WriteHeader(hdr); err != nil {
return appsFileIOError(err, "write header %s: %v", c.RelPath, err) return fmt.Errorf("write header %s: %w", c.RelPath, err)
} }
if _, err := io.Copy(tw, src); err != nil { if _, err := io.Copy(tw, src); err != nil {
return appsFileIOError(err, "copy %s: %v", c.RelPath, err) return fmt.Errorf("copy %s: %w", c.RelPath, err)
} }
return nil return nil
} }

View File

@@ -5,11 +5,11 @@ package apps
import ( import (
"errors" "errors"
"fmt"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate" "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. "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. // can traverse out of the storage directory.
func checkSeg(name, what string) error { func checkSeg(name, what string) error {
if err := validate.ResourceName(name, what); err != nil { if err := validate.ResourceName(name, what); err != nil {
return appsStorageError(err, "apps storage: %v", err) return fmt.Errorf("apps storage: %w", err)
} }
if name == "." { if name == "." {
return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what) return fmt.Errorf("apps storage: %s must not be \".\"", what)
} }
return nil return nil
} }
@@ -60,7 +60,7 @@ func Read(appID, key string) ([]byte, error) {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil, nil return nil, nil
} }
return nil, appsStorageError(err, "apps storage: read: %v", err) return nil, fmt.Errorf("apps storage: read: %w", err)
} }
return data, nil return data, nil
} }
@@ -79,10 +79,10 @@ func Write(appID, key string, data []byte) error {
return err return err
} }
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil { if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
return appsStorageError(err, "apps storage: create dir: %v", err) return fmt.Errorf("apps storage: create dir: %w", err)
} }
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil { if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
return appsStorageError(err, "apps storage: write: %v", err) return fmt.Errorf("apps storage: write: %w", err)
} }
return nil return nil
} }
@@ -96,7 +96,7 @@ func Delete(appID, key string) error {
return err return err
} }
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) { if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
return appsStorageError(err, "apps storage: delete: %v", err) return fmt.Errorf("apps storage: delete: %w", err)
} }
return nil return nil
} }
@@ -113,7 +113,7 @@ func List(appID string) ([]string, error) {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return []string{}, nil return []string{}, nil
} }
return nil, appsStorageError(err, "apps storage: read dir: %v", err) return nil, fmt.Errorf("apps storage: read dir: %w", err)
} }
keys := make([]string, 0, len(entries)) keys := make([]string, 0, len(entries))
for _, e := range entries { for _, e := range entries {

View File

@@ -4,11 +4,11 @@
package apps package apps
import ( import (
"fmt"
"io/fs" "io/fs"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
) )
@@ -40,7 +40,7 @@ func isUnsafeRelPath(rel string) bool {
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) { func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
stat, err := fio.Stat(rootPath) stat, err := fio.Stat(rootPath)
if err != nil { if err != nil {
return nil, appsInputPathError(err) return nil, fmt.Errorf("stat %s: %w", rootPath, err)
} }
if !stat.IsDir() { if !stat.IsDir() {
return []htmlPublishCandidate{{ 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. //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 { err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return appsInputPathEntryError(path, walkErr) return walkErr
} }
// Skip a stray git repo: a directory named .git skips the whole subtree, // 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 // 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() info, err := d.Info()
if err != nil { if err != nil {
return appsInputPathEntryError(path, err) return err
} }
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。 // 只接受 regular file —— symlink / device / pipe / socket 都跳过。
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。 // 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) rel, err := filepath.Rel(rootPath, path)
if err != nil { if err != nil {
return appsFileIOError(err, "resolve relative path for %s: %v", path, err) return err
} }
relSlash := filepath.ToSlash(rel) relSlash := filepath.ToSlash(rel)
// Defense in depth: WalkDir + Rel inside rootPath should never yield a // 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. // filesystem layout shouldn't be able to inject one into RelPath.
// Mirrors the same guard at tar entry write time. // Mirrors the same guard at tar entry write time.
if isUnsafeRelPath(relSlash) { if isUnsafeRelPath(relSlash) {
return errs.NewInternalError(errs.SubtypeUnknown, "walker produced unsafe relative path %q for %s", relSlash, path) return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
} }
out = append(out, htmlPublishCandidate{ out = append(out, htmlPublishCandidate{
RelPath: relSlash, RelPath: relSlash,

View File

@@ -298,14 +298,6 @@ 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 // carry fields a caller needs on failure (e.g. the file_token an overwrite
// returned, for token-stability handling). // returned, for token-stability handling).
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) { 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) logID, _ := logIDFromHeader(resp)["log_id"].(string)
result, parseErr := client.ParseJSONResponse(resp) result, parseErr := client.ParseJSONResponse(resp)
@@ -329,7 +321,7 @@ func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext
} }
} }
out, _ := resultMap["data"].(map[string]interface{}) out, _ := resultMap["data"].(map[string]interface{})
if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil { if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
return out, apiErr return out, apiErr
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {

View File

@@ -841,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
"page-size": "10", "page-size": "10",
}, nil) }, nil)
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime)) 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) 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) { t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{ runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id", "user-id-type": "open_id",
"sort": "create_time", "sort-type": "ByCreateTimeAsc",
}, nil) }, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime)) got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) { if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {

View File

@@ -48,8 +48,7 @@ var ImChatList = common.Shortcut{
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ 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: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}}, {Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{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: "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-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"}, {Name: "page-token", Desc: "pagination token for next page"},
@@ -267,16 +266,9 @@ func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to // CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default). // omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} { 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{}{ params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"), "user_id_type": runtime.Str("user-id-type"),
"sort_type": sortType, "sort_type": runtime.Str("sort-type"),
} }
if n := runtime.Int("page-size"); n > 0 { if n := runtime.Int("page-size"); n > 0 {
params["page_size"] = n params["page_size"] = n

View File

@@ -611,85 +611,3 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"]) 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)
}
}

View File

@@ -32,8 +32,7 @@ var ImChatMessageList = common.Shortcut{
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"}, {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: "start", Desc: "start time (ISO 8601)"},
{Name: "end", Desc: "end time (ISO 8601)"}, {Name: "end", Desc: "end time (ISO 8601)"},
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "sort", Default: "desc", Desc: "sort order", 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-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"}, {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)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
@@ -210,11 +209,7 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
} }
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) { func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
dir := runtime.Str("order") params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId)
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 != "" { if startFlag := runtime.Str("start"); startFlag != "" {
startTime, err := common.ParseTime(startFlag) startTime, err := common.ParseTime(startFlag)

View File

@@ -1,98 +0,0 @@
// 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)
}
}

View File

@@ -35,8 +35,7 @@ var ImChatSearch = common.Shortcut{
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"}, {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: "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: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}}, {Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{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-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"}, {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"}, {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"},
@@ -210,8 +209,8 @@ var ImChatSearch = common.Shortcut{
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search // buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
// from the runtime flag values. The query string is normalized via // from the runtime flag values. The query string is normalized via
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object // normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
// is omitted when no filter flags are set; "sorter" is omitted when --sort // is omitted when no filter flags are set; "sorter" is omitted when --sort-by
// (and its hidden alias --sort-by) is unset. // is empty.
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} { func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{} body := map[string]interface{}{}
@@ -257,18 +256,9 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
body["filter"] = filter body["filter"] = filter
} }
// Build sorter (always descending). --sort maps field -> field_desc; the hidden // Build sorters (always descending)
// --sort-by alias is already the upstream value (pass-through). Omitted when unset. if sortBy := runtime.Str("sort-by"); sortBy != "" {
sorter := map[string]string{ body["sorter"] = sortBy
"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 return body

View File

@@ -1,102 +0,0 @@
// 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)
}
}

View File

@@ -31,8 +31,7 @@ var ImThreadsMessagesList = common.Shortcut{
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true}, {Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}}, {Name: "sort", Default: "asc", Desc: "sort order", 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-size", Default: "50", Desc: "page size (1-500)"},
{Name: "page-token", Desc: "page token"}, {Name: "page-token", Desc: "page token"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
@@ -40,10 +39,15 @@ var ImThreadsMessagesList = common.Shortcut{
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread") threadFlag := runtime.Str("thread")
dir := resolveThreadsOrder(runtime) sortFlag := runtime.Str("sort")
pageSizeStr := runtime.Str("page-size") pageSizeStr := runtime.Str("page-size")
pageToken := runtime.Str("page-token") pageToken := runtime.Str("page-token")
sortType := "ByCreateTimeAsc"
if sortFlag == "desc" {
sortType = "ByCreateTimeDesc"
}
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
d := common.NewDryRunAPI() d := common.NewDryRunAPI()
@@ -53,12 +57,21 @@ var ImThreadsMessagesList = common.Shortcut{
containerID = "<resolved_thread_id>" containerID = "<resolved_thread_id>"
} }
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken) 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
}
d = d. d = d.
GET("/open-apis/im/v1/messages"). GET("/open-apis/im/v1/messages").
Params(toDryParams(params)). Params(params).
Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr) Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
if !runtime.Bool("no-reactions") { if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). 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.") Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
@@ -84,12 +97,26 @@ var ImThreadsMessagesList = common.Shortcut{
if err != nil { if err != nil {
return err return err
} }
dir := resolveThreadsOrder(runtime) sortFlag := runtime.Str("sort")
pageToken := runtime.Str("page-token") pageToken := runtime.Str("page-token")
sortType := "ByCreateTimeAsc"
if sortFlag == "desc" {
sortType = "ByCreateTimeDesc"
}
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken) 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}
}
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
if err != nil { if err != nil {
@@ -161,45 +188,3 @@ var ImThreadsMessagesList = common.Shortcut{
return nil 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
}

View File

@@ -1,81 +0,0 @@
// 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)
}
}

View File

@@ -1,18 +0,0 @@
// 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
}

View File

@@ -1,53 +0,0 @@
// 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)
}
})
}
}

View File

@@ -9,19 +9,19 @@ import (
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
// MailMessage is the `+message` shortcut: fetch full content of one email // MailMessage is the `+message` shortcut: fetch full content of a single
// by one message ID (normalized body + attachments / inline metadata). // email by message ID (normalized body + attachments / inline metadata).
var MailMessage = common.Shortcut{ var MailMessage = common.Shortcut{
Service: "mail", Service: "mail",
Command: "+message", Command: "+message",
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.", Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
Risk: "read", 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"}, 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"}, AuthTypes: []string{"user", "bot"},
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true}, {Name: "message-id", Desc: "Required. Email message ID", Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {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)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
}, },

View File

@@ -18,19 +18,19 @@ type mailMessagesOutput struct {
} }
// MailMessages is the `+messages` shortcut: batch-fetch full content for // MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking requests into batches of 20 while preserving // multiple message IDs, chunking backend calls into batches of 20 while
// request order. // preserving request order.
var MailMessages = common.Shortcut{ var MailMessages = common.Shortcut{
Service: "mail", Service: "mail",
Command: "+messages", Command: "+messages",
Description: "Use when reading full content for multiple emails by message ID. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output while preserving request order.", Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.",
Risk: "read", 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"}, 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"}, AuthTypes: []string{"user", "bot"},
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output. Example: "<id1>,<id2>,<id3>"`, Required: true}, {Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {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)"}, {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
}, },
@@ -52,7 +52,7 @@ var MailMessages = common.Shortcut{
body["message_ids"] = messageIDs body["message_ids"] = messageIDs
} }
return common.NewDryRunAPI(). return common.NewDryRunAPI().
Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output"). Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
POST(mailboxPath(mailboxID, "messages", "batch_get")). POST(mailboxPath(mailboxID, "messages", "batch_get")).
Body(body) Body(body)
}, },

Some files were not shown because too many files have changed in this diff Show More