Compare commits

...

22 Commits

Author SHA1 Message Date
liangshuo-1
09d5c5d99b docs(lark-shared): restructure into prioritized rules + on-demand references
Rewrite the always-loaded SKILL.md from a 168-line monolith into a slim
core: a positioning line plus 7 mental-model "通用准则" ordered by agent
attention priority (silent/proactive rules first; loud-triggered and
low-frequency ones last), and a short routing list. Mechanics and edge
cases move into on-demand references/ (loaded only when relevant).

References (named with the lark-shared- prefix, matching the per-domain
convention):
- lark-shared-auth-split-flow.md     split-flow steps (marked must-read)
- lark-shared-high-risk-approval.md  exit-10 envelope forms + predict/preview
- lark-shared-identity-and-permissions.md  identity model + scope recovery
- lark-shared-config-init.md         first-run config (blocking, no split-flow)
- lark-shared-update-notice.md       _notice handling (update/skills/deprecated)

Fix doc-vs-implementation drift confirmed against the code:
- exit-10 keys on exit code 10, not the type string; covers both the flat
  (type=confirmation_required) and typed (type=confirmation + subtype)
  envelopes, and reads the confirm flag from hint (--yes / --force).
- distinguish permission_violations (raw API) vs missing_scopes (CLI error).
- complete _notice keys (update / skills / deprecated_command).
- identity failure is silent-or-loud per command, not always empty.

Switch description to Chinese; bump version 1.0.0 -> 1.1.0.

Change-Id: I2dff478ecdc05a13f2d750944f637ed2374961e7
2026-06-13 17:43:27 +08:00
liangshuo-1
e1af7e3018 chore: release v1.0.53 (#1443)
]
2026-06-12 20:03:08 +08:00
bubbmon233
693e299589 docs(mail): clarify message read shortcuts (#1261)
* docs(mail): clarify message read shortcuts

Update mail read shortcut help, docs, and triage guidance so single-message and multi-message reads are routed to the right commands.

Add focused tests for help text, dry-run copy, triage stderr hints, and batch_get chunking behavior.

sprint: S1

* docs(mail): align batch_get limit with gateway config

* docs(mail): use shell-safe batch message id examples

* docs(mail): trim batch_get pagination wording

* docs(mail): use placeholder style for message ids

* docs(mail): hide batch_get internals from help
2026-06-12 19:52:36 +08:00
Yuxuan Zhao
69f335be7c test(calendar): drop flaky calendar list e2e checks (#1441) 2026-06-12 19:00:09 +08:00
JackZhao10086
d1a0926dd6 feat/revoke token (#1434) 2026-06-12 17:49:33 +08:00
syh-cpdsss
008bdda861 docs(whiteboard): optimize whiteboard skill (#1371)
* docs(whiteboard): optimize whiteboard skill

Change-Id: Iabcbe9f4e309ae9f467ceec265320cea6cdfa81b

* fix: PR issue

Change-Id: I96d99037b3ba74a3ea9964991b67cdf15fb985be
2026-06-12 17:46:55 +08:00
syh-cpdsss
f1da8c274b docs(okr): optimize okr skill (#1368)
Change-Id: I095a3a7a935e4f84459d1be24015f59cd9e324a6
2026-06-12 17:46:27 +08:00
AlbertSun
842be3fdc5 feat(token): mint TAT via unified OAuth v3 Token Endpoint (#1408) 2026-06-12 17:44:07 +08:00
raistlin042
1cd7a88597 fix: read release error_logs from data.error_logs in apps +release-get (#1436) 2026-06-12 16:58:47 +08:00
max
7c64e63b9d feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain

* fix: address note transcript review comments

* fix: stabilize empty note detail detection
2026-06-12 16:30:41 +08:00
luozhixiong01
8e60f01474 feat(im): unify sort flags into --sort field and --order direction (#1302)
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
2026-06-12 15:27:54 +08:00
JackZhao10086
465c789f7c feat: add --json flag support to auth subcommands (#1431)
* feat: add --json flag support to auth subcommands

* feat(auth/logout): add json output support for logout command

* feat(auth/list): add json output support for auth list command
2026-06-12 15:04:14 +08:00
Yuxuan Zhao
2a7e9c7d0d test(drive): retry duplicate-remote push in live E2E (#1403) 2026-06-12 13:48:19 +08:00
liangshuo-1
76ba6fad4f chore: add CODEOWNERS for internal/ and new skills domains (#1420) 2026-06-12 11:19:25 +08:00
liangshuo-1
510545f1e5 refactor(vc): consolidate note handling back into the vc domain (#1417) 2026-06-12 00:44:35 +08:00
max
c11cf3b716 feat: split note domain (#1345)
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling.

Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
2026-06-11 22:38:29 +08:00
liangshuo-1
ee2c93efeb chore: release v1.0.52 (#1412) 2026-06-11 22:05:51 +08:00
wangweiming-01
33e459a4de docs: optimize lark-drive skill routing (#1284)
* docs: optimize lark-drive skill routing

Change-Id: I79cebaa3e52b9291c89bdeffb50426e8f0f3bb2b

* docs: refine lark-drive skill guidance

Change-Id: I628291d6d2b60b0baa7202dddbb9a34138a27a3d
2026-06-11 20:19:07 +08:00
dc-bytedance
5aeae2db65 fix: harden riscv64 -race guard and restore Makefile newline
The cherry-picked riscv64 commit derived RACE_FLAG from `go env GOARCH`
via a grep pipeline, which ignores a GOARCH passed on the make command
line (e.g. `make GOARCH=riscv64 unit-test`) since command-line make
variables are not visible to $(shell ...). Switch to a make-native
filter that honors both, and restore the trailing newline the same
commit dropped.
2026-06-11 19:18:33 +08:00
Rocky Zhang
9b39d10203 feat: support riscv64 prebuilt binaries in release and install pipeline 2026-06-11 19:18:33 +08:00
Rocky Zhang
8572a58fda fix: support riscv64 by making -race flag arch-conditional 2026-06-11 19:18:33 +08:00
evandance
9bc66cc445 feat(apps): emit typed error envelopes across the apps domain (#1288) 2026-06-11 19:04:34 +08:00
157 changed files with 5317 additions and 1561 deletions

30
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,30 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ 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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only 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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) - path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap 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/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/) - path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper text: errs-no-legacy-helper
linters: linters:
- forbidigo - forbidigo

View File

@@ -17,6 +17,7 @@ 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) make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration make test # Full: vet + unit + integration
``` ```

View File

@@ -2,6 +2,49 @@
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
@@ -1106,6 +1149,8 @@ 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,6 +8,13 @@ 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
@@ -34,7 +41,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 -gcflags="all=-N -l" -count=1 \ go test $(RACE_FLAG) -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,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) { func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) { func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) { 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,
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) { 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,
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) { 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,6 +19,7 @@ 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.
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
} }
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") cmd.Flags().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,6 +18,7 @@ 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.
@@ -34,6 +35,7 @@ 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
@@ -44,6 +46,14 @@ 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
@@ -61,6 +71,14 @@ 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,6 +4,7 @@
package auth package auth
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
} }
} }
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the // 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
@@ -57,3 +85,48 @@ 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"} return []string{"base", "contact", "docs", "markdown", "apps", "note"}
} }

View File

@@ -9,6 +9,7 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
} }
} }
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) { func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects() projects := registry.ListFromMetaProjects()
if len(projects) == 0 { if len(projects) == 0 {

View File

@@ -18,6 +18,7 @@ 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.
@@ -34,6 +35,7 @@ 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
@@ -44,25 +46,65 @@ 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
} }

356
cmd/auth/logout_test.go Normal file
View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,6 +19,7 @@ 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.
@@ -30,6 +31,9 @@ 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)
} }
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
} }
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") cmd.Flags().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,6 +17,7 @@ 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.
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
} }
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") cmd.Flags().BoolVar(&opts.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

@@ -33,15 +33,16 @@ 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 server deterministically rejected the credentials — a // only when the unified Token Endpoint deterministically rejected the
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient // credentials — an OAuth2 invalid_client / unauthorized_client classified as
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated // CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// so the root dispatcher renders the canonical envelope and `config init` // typed error is propagated so the root dispatcher renders the canonical
// exits non-zero — identical to how every other token-resolving command // envelope and `config init` exits non-zero — identical to how every other
// reports the same bad credentials. Ambiguous failures (transport errors, // token-resolving command reports the same bad credentials. Ambiguous
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped // failures (transport errors, transient 5xx/server_error, JSON parse errors,
// errors and are swallowed (return nil), so valid configurations are never // timeouts) come back as raw untyped errors and are swallowed (return nil),
// disturbed by upstream noise. errs.IsTyped is the discriminator. // so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
// //
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of // 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, "/auth/v3/tenant_access_token/internal"): case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++ f.tatCalls++
if f.tatHandler == nil { if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
} }
return f.tatHandler(req) 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,14 +84,15 @@ 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) with // rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
// the expected upstream code. This is the same typed error every other // is the same typed error every other token-resolving command returns for the
// token-resolving command returns for the same bad credentials, and nothing is // same bad credentials, and nothing is written to stderr (the root dispatcher
// written to stderr (the root dispatcher renders the envelope). // renders the envelope). The numeric code is not asserted: the unified v3 Token
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) { // Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper() t.Helper()
if err == nil { if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode) t.Fatal("expected *errs.ConfigError, got nil")
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
if cfgErr.Subtype != errs.SubtypeInvalidClient { 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())
} }
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
} }
} }
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated. // invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) { // Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_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, 10003) assertConfigRejection(t, err, errBuf)
} }
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta — // unauthorized_client is treated as the same credential rejection, propagated.
// the most common real-world rejection, propagated. func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
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(200, `{"code":10014,"msg":"app secret invalid"}`), nil return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014) assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
} }
// Any non-zero body code is a deterministic rejection and propagates (typed). // Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still // back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// typed, so the probe still surfaces it rather than swallowing. // it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) { func TestRunProbe_TATOtherClientError_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(200, `{"code":99999,"msg":"future-unknown"}`), nil return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)

View File

@@ -80,6 +80,7 @@ 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,6 +47,7 @@ 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
} }
@@ -55,6 +56,7 @@ 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,6 +31,9 @@ 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)
} }
@@ -42,6 +45,9 @@ 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,6 +7,8 @@ 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).

131
internal/auth/revoke.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// RevokeToken revokes a previously issued OAuth token.
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
endpoints := ResolveOAuthEndpoints(brand)
form := url.Values{}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
form.Set("token", token)
if tokenTypeHint != "" {
form.Set("token_type_hint", tokenTypeHint)
}
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
}
if resp.StatusCode >= 400 {
return revokeHTTPStatusError(resp.StatusCode, body)
}
if len(body) == 0 {
return nil
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
if code := getInt(data, "code", 0); code != 0 {
msg := getStr(data, "msg")
if msg == "" {
msg = getStr(data, "message")
}
if msg == "" {
msg = "unknown error"
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
WithCode(code).
WithCause(errors.New(msg))
}
if errStr := getStr(data, "error"); errStr != "" {
msg := getStr(data, "error_description")
if msg == "" {
msg = errStr
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
WithCause(errors.New(msg))
}
return nil
}
func revokeHTTPStatusError(status int, body []byte) error {
msg := formatOAuthErrorBody(body)
cause := errors.New(strings.TrimSpace(string(body)))
if strings.TrimSpace(string(body)) == "" {
cause = errors.New(msg)
}
if status >= http.StatusInternalServerError {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithRetryable().
WithCause(cause)
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithCause(cause)
}
func formatOAuthErrorBody(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return "empty response"
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return trimmed
}
if msg := getStr(data, "error_description"); msg != "" {
return msg
}
if msg := getStr(data, "msg"); msg != "" {
return msg
}
if msg := getStr(data, "message"); msg != "" {
return msg
}
if msg := getStr(data, "error"); msg != "" {
return msg
}
return trimmed
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type errReadCloser struct {
err error
}
func (r errReadCloser) Read(_ []byte) (int, error) {
return 0, r.err
}
func (r errReadCloser) Close() error {
return nil
}
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stub := &httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_a" &&
values.Get("client_secret") == "secret_b" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
}
reg.Register(stub)
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err != nil {
t.Fatalf("RevokeToken() error = %v", err)
}
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q", got)
}
}
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
sentinel := errors.New("transport down")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, sentinel
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %#v, want network/transport", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
}
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Status: 400,
Body: map[string]interface{}{"error": "invalid_token"},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 400 {
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
}
if !strings.Contains(err.Error(), "invalid_token") {
t.Fatalf("expected invalid_token error, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"code": 12345,
"msg": "invalid revoke state",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 12345 {
t.Fatalf("problem = %#v, want api error with code 12345", p)
}
if !strings.Contains(err.Error(), "invalid revoke state") {
t.Fatalf("expected oauth error message, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"error": "invalid_token",
"error_description": "token already expired",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("problem = %#v, want api error", p)
}
if !strings.Contains(err.Error(), "token already expired") {
t.Fatalf("expected oauth error_description, got %v", err)
}
}
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
sentinel := errors.New("read failed")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: errReadCloser{err: sentinel},
Header: make(http.Header),
}, nil
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
if !strings.Contains(err.Error(), "token revoke read error") {
t.Fatalf("expected read error message, got %v", err)
}
if _, ok := err.(*errs.InternalError); !ok {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
}

View File

@@ -22,6 +22,12 @@ 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,6 +42,11 @@ 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,33 +19,44 @@ import (
extcred "github.com/larksuite/cli/extension/credential" extcred "github.com/larksuite/cli/extension/credential"
) )
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the // classifyTATResponseCode wraps a deterministic (non-transient) failure from the
// canonical typed error. The TAT mint endpoint reports invalid credentials // unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
// with two distinct codes: // reports failures using the OAuth 2.0 model — an `error` string plus an
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
// //
// - 10003: bad app_id format or non-existent app_id ("invalid param") // invalid_client / unauthorized_client mean the configured app_id/app_secret
// - 10014: invalid app_secret ("app secret invalid") // cannot mint a token; from the user's perspective that is the same actionable
// // CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
// Both surface as CategoryConfig/InvalidClient from the user's perspective — // Every other deterministic error falls through to BuildAPIError, which still
// the configured credentials cannot mint a tenant access token. 10014 is // yields a typed error so probe callers (errs.IsTyped) surface it rather than
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543). // swallowing it. Transient/server-side failures (5xx / server_error) are
// 10003 is NOT globally mapped because in other Lark endpoints it carries // filtered out by FetchTAT before this is called, so they stay untyped.
// unrelated semantics (e.g. task API uses 10003 for permission denied), so func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
// the override stays local to this TAT call site instead of leaking into the msg := errDesc
// shared codemeta table. if msg == "" {
func classifyTATResponseCode(code int, msg, brand, appID string) error { msg = oauthErr
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))
} }
return errclass.BuildAPIError(map[string]any{ if err := 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.
@@ -146,8 +157,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. Result is cached after first call. // resolveTAT resolves a tenant access token. The result is cached after the first
// NOTE: Uses sync.Once — only the context from the first call is used. // call via 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,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{} var _ DefaultAccountResolver = &DefaultAccountProvider{}
} }
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT // TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient. // unified Token Endpoint's OAuth2 invalid_client error surfaces as
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint, // CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
// which from the user's perspective is the same actionable failure as 10014 // tenant access token, the same actionable failure the legacy 10003/10014 codes
// ("app secret invalid") — both mean the configured credentials cannot mint a // produced. The numeric code is intentionally not asserted: the v3 endpoint may
// tenant access token. The global codemeta intentionally does not map 10003 // return invalid_client with no Lark code (code defaults to 0).
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
// API uses it for permission denied), so the override is local to this site. err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
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 code=10003") t.Fatal("expected non-nil error for invalid_client")
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_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_10014_RoutesViaCodeMeta pins that 10014 still // TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
// goes through the global BuildAPIError path (codemeta entry) so the override // unauthorized_client is treated as the same credential failure as
// for 10003 does not regress the existing mapping. // invalid_client.
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) { func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x") err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "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)
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(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 != 10014 {
t.Errorf("Code = %d, want 10014", cfgErr.Code)
}
} }
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside // TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
// the credential set fall through to the generic BuildAPIError fallback // outside the credential set fall through to the generic BuildAPIError fallback
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional. // — still typed, but not a ConfigError. The mapping is narrow and intentional.
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) { func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x") err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
if err == nil { if err == nil {
t.Fatal("expected non-nil error for unmapped code") t.Fatal("expected non-nil error for invalid_scope")
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) { if errors.As(err, &cfgErr) {
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err) t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
}
}
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
// returns nil for code 0 (Feishu's success convention); without the backstop,
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
// with no error.
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
} }
} }

View File

@@ -4,46 +4,47 @@
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 with the // FetchTAT performs a single HTTP POST to mint a tenant access token via the
// given credentials. It does not read configuration or keychain, so callers // unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
// that already hold plaintext credentials (e.g. the post-`config init` probe) // client_credentials grant with client_secret_post authentication. It does not
// can validate them without a second keychain round-trip. // read configuration or keychain, so callers that already hold plaintext
// credentials (e.g. the post-`config init` probe) can validate them without a
// second keychain round-trip.
// //
// A non-zero TAT response code means the server inspected the payload and // A deterministic client-side rejection (e.g. invalid_client) returns the
// rejected the credentials; FetchTAT returns the canonical typed error from // canonical typed error from classifyTATResponseCode — the SAME classification
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus // doResolveTAT (and thus every token-resolving command) produces, so callers
// every token-resolving command) produces, so callers see one consistent // see one consistent envelope. Transport failures, unreadable/unparseable
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.). // bodies, and transient server-side failures (5xx / server_error) are returned
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped), // raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic // deterministic credential rejection apart from upstream/transport noise.
// 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)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal" endpoint := ep.Accounts + core.OAuthTokenV3Path
body, err := json.Marshal(map[string]string{ form := url.Values{}
"app_id": appID, form.Set("grant_type", "client_credentials")
"app_secret": appSecret, form.Set("client_id", appID)
}) form.Set("client_secret", appSecret)
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
}
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/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
@@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode) if err != nil {
return "", fmt.Errorf("failed to read TAT response: %w", err)
} }
var result struct { var result struct {
Code int `json:"code"` Code int `json:"code"`
Msg string `json:"msg"` AccessToken string `json:"access_token"`
TenantAccessToken string `json:"tenant_access_token"` Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err) // An unparseable body is ambiguous (covers non-JSON error pages and
// truncated payloads); stay untyped so probe callers treat it as noise.
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
} }
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID) if result.Code == 0 && result.AccessToken != "" {
return result.AccessToken, nil
} }
return result.TenantAccessToken, nil
// Transient/server-side failures stay untyped so probe callers stay silent and
// retryers can back off; only deterministic client rejections are typed. Covers
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
// behavior so a rate-limited probe is not surfaced as a hard credential error.
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
result.Error == "slow_down" {
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
}
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
if result.Code == 0 && result.Error == "" {
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
}
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
// non-empty typed message instead of a bare "API error: [code]".
desc := result.ErrorDescription
if desc == "" {
desc = result.Msg
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
} }

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

@@ -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}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543) 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
// CategoryPolicy // CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired}, 21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -35,9 +35,12 @@ 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
// TAT-endpoint variant of the "wrong app credentials" condition. // "Wrong app credentials" code from the LEGACY TAT endpoint
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014 // (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
// ("app secret invalid") instead of 99991543 when the secret is wrong. // invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
// are retained as a defensive fallback should 10014 still arrive.
LarkErrTATInvalidSecret = 10014 LarkErrTATInvalidSecret = 10014
// Rate limit. // Rate limit.

View File

@@ -47,6 +47,10 @@
"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,15 +18,18 @@ 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,15 +19,18 @@ 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/",
@@ -35,7 +38,6 @@ 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,6 +953,7 @@ 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",
@@ -988,6 +989,18 @@ common.` + helper + `()
} }
} }
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
for _, path := range migratedCommonHelperPaths {
commonPaths[path] = struct{}{}
}
for _, path := range migratedEnvelopePaths {
if _, ok := commonPaths[path]; !ok {
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
}
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) { 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.51", "version": "1.0.53",
"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,7 +15,8 @@
], ],
"cpu": [ "cpu": [
"x64", "x64",
"arm64" "arm64",
"riscv64"
], ],
"engines": { "engines": {
"node": ">=16" "node": ">=16"

View File

@@ -33,6 +33,7 @@ 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
@@ -55,6 +56,7 @@ 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,6 +30,7 @@ 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,7 +9,6 @@ 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 +31,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return nil return nil
}, },

View File

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

View File

@@ -9,7 +9,6 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -43,14 +42,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
if strings.TrimSpace(rctx.Str("session-id")) == "" { if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required") return appsValidationParamError("--session-id", "--session-id is required")
} }
// Do not echo --message content in the error (spec §4 redaction). // Do not echo --message content in the error (spec §4 redaction).
if strings.TrimSpace(rctx.Str("message")) == "" { if strings.TrimSpace(rctx.Str("message")) == "" {
return output.ErrValidation("--message is required") return appsValidationParamError("--message", "--message is required")
} }
return nil return nil
}, },

View File

@@ -9,7 +9,6 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -36,7 +35,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 output.ErrValidation("--name is required") return appsValidationParamError("--name", "--name is required")
} }
return nil return nil
}, },

View File

@@ -12,6 +12,7 @@ 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"
@@ -47,6 +48,31 @@ 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,8 +31,9 @@ import (
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示 // - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
// //
// 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵 // 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后升级成 typed api_errorexit 非 0、detail 带 statement_index / completed / rolled_back // 后按 partial failure 上报exit 非 0stdout 输出 ok:false 数据,带 results /
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit // statement_index / error_code / error_message / rolled_back / note避免 agent 误判
// 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` 数组。
@@ -68,19 +69,27 @@ 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 output.ErrValidation("--sql and --file are mutually exclusive") return appsValidationError("--sql and --file are mutually exclusive").
WithParams(
appsInvalidParam("--sql", "mutually exclusive with --file"),
appsInvalidParam("--file", "mutually exclusive with --sql"),
)
} }
if file != "" { if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil { if err != nil {
return output.ErrValidation("--file: %v", err) return appsValidationParamError("--file", "--file: %v", err).WithCause(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 output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)") return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
WithParams(
appsInvalidParam("--sql", "one of --sql or --file is required"),
appsInvalidParam("--file", "one of --sql or --file is required"),
)
} }
return nil return nil
}, },
@@ -113,13 +122,15 @@ 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。
// 升级成 typed api_errorexit 非 0别让 agent 误判 ok:true 假成功。 // 已落地的前序语句 + 失败语句构成 partial failure逐条结果作为 ok:false 数据
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→stderr // 留在 stdout机器可读+ 非零退出信号,别让 agent 误判 ok:true 假成功
// 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 sqlStatementError(stmts, errIdx, errStmt) return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
} }
rctx.OutFormat(data, nil, func(w io.Writer) { rctx.OutFormat(data, nil, func(w io.Writer) {
@@ -140,31 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
return 0, nil, false return 0, nil, false
} }
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error // sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据
// //
// CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit // CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句hint 提示用户 // 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果ERROR 哨兵在
// 别整批重跑(否则会重复写入)。 // 失败位置note 提示用户别整批重跑(否则会重复写入)。
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error { func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看 stmtNo := errIdx + 1 // 1-based 给人看
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)) note := "no statements were applied; fix the SQL and re-run."
if errIdx > 0 {
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{ note = fmt.Sprintf(
"statement_index": errIdx, "statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
"completed": stmts[:errIdx], errIdx, stmtNo)
"rolled_back": false, }
}) return map[string]interface{}{
if apiErr.Detail != nil { "results": stmts,
if errIdx > 0 { "statement_index": errIdx,
apiErr.Detail.Hint = fmt.Sprintf( "error_code": code,
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.", "error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
errIdx, stmtNo) "rolled_back": false,
} else { "note": note,
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 钉死「多语句失败 → typed api_error」: // TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitErrortype=api_error、非零 exit // 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式 // 退出信号是 PartialFailureError非零 exit。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,45 +518,64 @@ 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 typed error; stdout:\n%s", stdout.String()) t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
} }
// json 失败路径不得打成功 envelope。 // 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 exitErr *output.ExitError var pfErr *output.PartialFailureError
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !errors.As(err, &pfErr) {
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err) t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
} }
if exitErr.Detail.Type != "api_error" { if pfErr.Code != output.ExitAPI {
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type) t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
} }
if exitErr.Detail.Code != 1300002 { payload := decodePartialFailureData(t, stdout.String())
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code) if got := payload["statement_index"]; got != float64(1) {
t.Errorf("statement_index = %v, want 1", got)
} }
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") { if got := payload["error_code"]; got != float64(1300002) {
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message) t.Errorf("error_code = %v, want 1300002", got)
} }
if output.ExitCodeOf(err) != output.ExitAPI { msg, _ := payload["error_message"].(string)
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI) if !strings.Contains(msg, "(at statement 2 of 2)") {
t.Errorf("error_message missing statement locator: %q", msg)
} }
detail, ok := exitErr.Detail.Detail.(map[string]interface{}) if got := payload["rolled_back"]; got != false {
if !ok { t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
} }
if detail["statement_index"] != 1 { results, _ := payload["results"].([]interface{})
t.Errorf("statement_index = %v, want 1", detail["statement_index"]) if len(results) != 2 {
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
} }
if detail["rolled_back"] != false { note, _ := payload["note"].(string)
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"]) if !strings.Contains(note, "already applied") {
} 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 哨兵)
// 同样升级成 typed errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。 // 同样走 partial failurestatement_index=0、note 说明无语句落地、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{
@@ -573,21 +592,23 @@ 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 typed error; stdout:\n%s", stdout.String()) t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
} }
var exitErr *output.ExitError var pfErr *output.PartialFailureError
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !errors.As(err, &pfErr) {
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err) t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
} }
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") { payload := decodePartialFailureData(t, stdout.String())
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message) msg, _ := payload["error_message"].(string)
if !strings.Contains(msg, "(at statement 1 of 1)") {
t.Errorf("error_message missing locator: %q", msg)
} }
detail, _ := exitErr.Detail.Detail.(map[string]interface{}) if got := payload["statement_index"]; got != float64(0) {
if detail["statement_index"] != 0 { t.Errorf("statement_index = %v, want 0", got)
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
} }
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 { note, _ := payload["note"].(string)
t.Errorf("completed = %v, want empty", detail["completed"]) if !strings.Contains(note, "no statements were applied") {
t.Errorf("note should say nothing was applied, got %q", note)
} }
} }
@@ -795,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
}) })
} }
} }
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
// contract on a statement failure: stdout carries only the per-statement
// human summary (no JSON envelope stacked after it), and the command still
// exits non-zero via the partial-failure signal.
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
out := stdout.String()
if !strings.Contains(out, "✗") {
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
}
if strings.Contains(out, `"ok"`) {
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
}
}

View File

@@ -8,7 +8,6 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -45,7 +44,7 @@ var AppsDBTableGet = common.Shortcut{
return err return err
} }
if strings.TrimSpace(rctx.Str("table")) == "" { if strings.TrimSpace(rctx.Str("table")) == "" {
return output.ErrValidation("--table is required") return appsValidationParamError("--table", "--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 &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"} return appsValidationParamError("--app-id", "--app-id is required")
} }
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil { if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err} return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
} }
if err := checkEnvPullTarget(envFile); err != nil { 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 &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err} return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
} }
if err := checkEnvPullTarget(envFile); err != nil { 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 "", "", fmt.Errorf("cannot determine working directory: %w", err) return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(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 &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err} return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err)
} }
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"} return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile)
} }
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"} return appsValidationParamError("--project-path", "target %s must be a regular file", envFile)
} }
return nil 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.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}} return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
} }
var skippedKeys []string 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.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}} return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
} }
} }

View File

@@ -1079,3 +1079,28 @@ 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

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
)
func appsValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func appsValidationParamError(param, format string, args ...any) *errs.ValidationError {
return appsValidationError(format, args...).WithParam(param)
}
func appsInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: name, Reason: reason}
}
func appsFailedPreconditionParamError(param, format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...).WithParam(param)
}
func appsFailedPreconditionError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
}
// appsStorageError classifies a local credential/state storage failure
// (read, write, delete, list) as internal/storage.
func appsStorageError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err)
}
// appsExternalToolError classifies a runtime failure of an external tool the
// CLI shells out to (git, npx) as internal/external_tool. The tool output is
// carried in the message; recovery guidance goes in the hint.
func appsExternalToolError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
}
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}
func appsInputPathError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return appsValidationParamError("--path", "unsafe --path: %s", err).WithCause(err)
}
return appsValidationParamError("--path", "cannot read --path: %s", err).WithCause(err)
}
func appsInputPathEntryError(path string, err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return appsValidationParamError("--path", "unsafe --path entry %s: %s", path, err).WithCause(err)
}
return appsValidationParamError("--path", "cannot read --path entry %s: %s", path, err).WithCause(err)
}
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
// endpoint: refines endpoint-scoped business codes, prefixes the message with
// command context, and attaches endpoint-specific recovery hints. A
// still-untyped error is lifted at the SDK boundary instead.
func enrichHTMLPublishAPIError(err error) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return client.WrapDoAPIError(err)
}
// The HTML publish business codes (90001/90002) are scoped to this
// endpoint, not service-global, so their subtype classification lives
// here instead of the global errclass code table. Only an
// otherwise-unclassified API error is refined; a stronger upstream
// classification is never overridden.
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
p.Subtype = errs.SubtypeNotFound
}
if p.Message != "" {
p.Message = "html-publish failed: " + p.Message
}
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
p.Hint = hint
}
return err
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestAppsInputPathError_ClassifiesPathValidation(t *testing.T) {
cause := errors.New("path escapes working directory")
err := appsInputPathError(&fileio.PathValidationError{Err: cause})
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(problem.Message, "unsafe --path") {
t.Fatalf("message = %q, want unsafe --path context", problem.Message)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--path" {
t.Fatalf("param = %q, want --path", validationErr.Param)
}
if !errors.Is(err, fileio.ErrPathValidation) || !errors.Is(err, cause) {
t.Fatalf("path validation cause chain not preserved: %v", err)
}
}
func TestAppsInputPathEntryError_ClassifiesReadFailure(t *testing.T) {
cause := errors.New("permission denied")
err := appsInputPathEntryError("dist/index.html", cause)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "cannot read --path entry dist/index.html") {
t.Fatalf("message = %q, want entry read context", problem.Message)
}
if !errors.Is(err, cause) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
cause := errors.New("archive writer failed")
err := appsFileIOError(cause, "pack failed: %v", cause)
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFileIO)
}
if !errors.Is(err, cause) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
}
}
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
WithCode(errCodeBuildFailed).
WithLogID("logid-build-failed")
got := enrichHTMLPublishAPIError(err)
if got != err {
t.Fatalf("typed error should be enriched in place")
}
problem := requireAppsAPIProblem(t, got)
if problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
}
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.LogID != "logid-build-failed" {
t.Fatalf("log_id = %q, want preserved", problem.LogID)
}
if !strings.Contains(problem.Message, "html-publish failed") {
t.Fatalf("message = %q, want html-publish context", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected known-code recovery hint")
}
}
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
}
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeRateLimit {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
}
}

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/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"
) )
@@ -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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
path := strings.TrimSpace(rctx.Str("path")) path := strings.TrimSpace(rctx.Str("path"))
if path == "" { if path == "" {
return output.ErrValidation("--path is required") return appsValidationParamError("--path", "--path is required")
} }
// Block well-known credential files in the publish payload unless the // 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 output.ErrWithHint(output.ExitValidation, "validation", return appsValidationParamError("--path",
fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample), "--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
"remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)") WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
} }
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。 // maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
@@ -178,15 +178,14 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
return nil return nil
} }
} }
return output.ErrWithHint(output.ExitAPI, "validation", return appsFailedPreconditionParamError("--path", "--path is missing index.html").
"--path 中缺少 index.html", WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html单文件形态把文件命名为 index.html")
} }
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) { func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
candidates, err := walkHTMLPublishCandidates(fio, spec.Path) candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
if err != nil { if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err) return nil, err
} }
if err := ensureIndexHTML(candidates); err != nil { if err := ensureIndexHTML(candidates); err != nil {
return nil, err return nil, err
@@ -196,24 +195,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPubli
rawTotal += c.Size rawTotal += c.Size
} }
if rawTotal > maxHTMLPublishRawBytes { if rawTotal > maxHTMLPublishRawBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation", return nil, appsValidationParamError("--path",
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes), "--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
"在 tar+gzip 进入内存前拦截,避免 OOM精简 --path 内容或选择更小的子目录") WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
} }
tarball, err := buildHTMLPublishTarball(fio, candidates) tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil { if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err) return nil, err
} }
if tarball.Size > maxHTMLPublishTarballBytes { if tarball.Size > maxHTMLPublishTarballBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation", return nil, appsValidationParamError("--path",
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes), "packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB") WithHint("reduce --path contents, remove unrelated large files, then retry")
} }
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball) resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil { if err != nil {
return nil, err return nil, client.WrapDoAPIError(err)
} }
out := map[string]interface{}{} out := map[string]interface{}{}

View File

@@ -10,8 +10,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/internal/output"
) )
type fakeAppsHTMLPublishClient struct { type fakeAppsHTMLPublishClient struct {
@@ -105,17 +103,11 @@ 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")
} }
var exitErr *output.ExitError problem := requireAppsValidationProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !strings.Contains(problem.Message, "index.html") {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("message missing 'index.html': %v", problem.Message)
} }
if exitErr.Detail.Type != "validation" { if problem.Hint == "" {
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 {
@@ -153,10 +145,7 @@ 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)")
} }
var exitErr *output.ExitError requireAppsValidationProblem(t, err)
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")
} }
@@ -199,17 +188,11 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected oversize error") t.Fatalf("expected oversize error")
} }
var exitErr *output.ExitError problem := requireAppsValidationProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !strings.Contains(problem.Message, "exceeds") {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("message missing 'exceeds': %v", problem.Message)
} }
if exitErr.Detail.Type != "validation" { if problem.Hint == "" {
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 {
@@ -337,18 +320,12 @@ 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")
} }
var exitErr *output.ExitError problem := requireAppsValidationProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !strings.Contains(problem.Message, ".env") {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("error message should list the offending file, got %q", problem.Message)
} }
if exitErr.Detail.Type != "validation" { if !strings.Contains(problem.Hint, "--allow-sensitive") {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint)
}
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)
} }
} }
@@ -438,15 +415,9 @@ 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)
} }
var exitErr *output.ExitError problem := requireAppsValidationProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !strings.Contains(problem.Message, tc.wantSubstr) {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("error message should name the leaked file, got %q", problem.Message)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, tc.wantSubstr) {
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
} }
}) })
} }
@@ -480,15 +451,9 @@ 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")
} }
var exitErr *output.ExitError problem := requireAppsValidationProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !strings.Contains(problem.Message, "credentials") {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("error message should name the leaked file, got %q", problem.Message)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "credentials") {
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
} }
} }
@@ -498,11 +463,7 @@ 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)
var exitErr *output.ExitError msg := requireAppsValidationProblem(t, err).Message
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)
} }
@@ -534,15 +495,9 @@ 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")
} }
var exitErr *output.ExitError problem := requireAppsValidationProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "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 (output.ErrValidation -> ExitValidation=2). // check lives in Validate (typed validation error -> exit 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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--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 "", output.ErrValidation("--dir must not contain control characters") return "", appsValidationParamError("--dir", "--dir must not contain control characters")
} }
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil { if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
return "", output.ErrValidation("%v", err) return "", appsValidationParamError("--dir", "%v", err).WithCause(err)
} }
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths). 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 "", output.ErrValidation("--dir cannot be resolved: %v", err) return "", appsValidationParamError("--dir", "--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 output.ErrValidation("--dir cannot be read: %v", err) return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
} }
if info.Mode()&os.ModeSymlink != 0 { if info.Mode()&os.ModeSymlink != 0 {
return output.ErrValidation("--dir must not be a symlink: %q", dir) return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir)
} }
if !info.IsDir() { if !info.IsDir() {
return output.ErrValidation("--dir exists and is not a directory: %q", dir) return appsValidationParamError("--dir", "--dir exists and is not a directory: %q", dir)
} }
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir. 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 output.ErrValidation("--dir cannot be read: %v", err) return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
} }
if len(entries) > 0 { if len(entries) > 0 {
return output.ErrValidation("target directory %q already exists and is not empty", dir) return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir)
} }
return nil return nil
} }
@@ -209,11 +209,11 @@ func ensureMetaAppID(dir, appID string) error {
return nil return nil
} }
if err != nil { if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err) return appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
} }
var m map[string]interface{} var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil { if err := json.Unmarshal(b, &m); err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err) return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
} }
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" { 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 output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err) return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err)
} }
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths. if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err) return appsFileIOError(err, "write %s failed: %v", metaRelPath, err)
} }
return nil 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, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err)) return false, appsExternalToolError(err, "git ls-files failed: %s", gitErr(stderr, err))
} }
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { 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 "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err)) return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
} }
return scaffoldKindInit, nil 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 "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err)) return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
} }
if err := ensureMetaAppID(dir, appID); err != nil { 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 "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err)) return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
} }
} }
return scaffoldKindUpgrade, nil 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 "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err) return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
} }
if !env.OK { if !env.OK {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure") return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
} }
if strings.TrimSpace(env.Data.RepositoryURL) == "" { if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url") return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
} }
return env.Data.RepositoryURL, nil 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 "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err) return "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err)
} }
if !env.OK { if !env.OK {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure") return "", appsSubprocessEnvelopeError("+env-pull reported failure")
} }
if strings.TrimSpace(env.Data.EnvFile) == "" { if strings.TrimSpace(env.Data.EnvFile) == "" {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file") return "", appsSubprocessEnvelopeError("+env-pull returned no env_file")
} }
return env.Data.EnvFile, nil return env.Data.EnvFile, nil
} }
@@ -364,7 +364,9 @@ 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
} }
return output.Errorf(output.ExitValidation, "validation", // The URL comes from the +git-credential-init subprocess response, not user
// input, so a non-http(s) scheme is a broken upstream contract.
return appsSubprocessEnvelopeError(
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL)) "repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
} }
@@ -415,12 +417,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
} }
if _, err := exec.LookPath("git"); err != nil { if _, err := exec.LookPath("git"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency", return appsFailedPreconditionError("git executable not found on PATH").
"git executable not found on PATH", "install git and ensure it is on your PATH") WithHint("install git and ensure it is on your PATH")
} }
if _, err := exec.LookPath("npx"); err != nil { if _, err := exec.LookPath("npx"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency", return appsFailedPreconditionError("npx executable not found on PATH").
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH") WithHint("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 {
@@ -438,11 +440,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 output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err)) return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
} }
initLogf(rctx, "Checking out %s...", defaultInitBranch) 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 output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err)) return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
} }
initLogf(rctx, "Initializing app code (running miaoda-cli)...") initLogf(rctx, "Initializing app code (running miaoda-cli)...")
@@ -536,7 +538,7 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) { 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 "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err) return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
} }
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"} 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 != "" {
@@ -544,9 +546,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 "", output.ErrWithHint(output.ExitAPI, "credential_init", return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)), WithHint("ensure apps +git-credential-init is available and you are logged in").
"ensure apps +git-credential-init is available and you are logged in") WithCause(err)
} }
return parseRepoURLFromEnvelope(stdout) return parseRepoURLFromEnvelope(stdout)
} }
@@ -560,7 +562,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) { 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, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr)) return false, false, appsExternalToolError(runErr, "git status failed: %s", gitErr(stderr, runErr))
} }
if strings.TrimSpace(status) == "" { if strings.TrimSpace(status) == "" {
return false, false, nil return false, false, nil
@@ -595,7 +597,7 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil { if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
return true, false, withAppsHint( return true, false, withAppsHint(
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)), appsExternalToolError(e, "git push failed: %s", gitErr(se, e)),
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded") "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
@@ -609,10 +611,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 output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e)) return appsExternalToolError(e, "git add failed: %s", gitErr(se, e))
} }
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil { if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e)) return appsExternalToolError(e, "git commit failed: %s", gitErr(se, e))
} }
return nil return nil
} }

View File

@@ -17,6 +17,7 @@ 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"
@@ -1466,3 +1467,28 @@ 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,7 +9,6 @@ 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,7 +32,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,7 +9,6 @@ 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 +31,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
if strings.TrimSpace(rctx.Str("release-id")) == "" { if strings.TrimSpace(rctx.Str("release-id")) == "" {
return output.ErrValidation("--release-id is required") return appsValidationParamError("--release-id", "--release-id is required")
} }
return nil return nil
}, },
@@ -58,6 +57,9 @@ 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,13 +134,15 @@ 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{}{"release": map[string]interface{}{ "data": map[string]interface{}{
"release_id": "6", "status": "failed", "release": map[string]interface{}{
"created_at": "1700000000000", "updated_at": "1700000000050", "release_id": "6", "status": "failed",
"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 {
@@ -200,11 +202,13 @@ 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{}{"release": map[string]interface{}{ "data": map[string]interface{}{
"release_id": "9", "status": "failed", "release": map[string]interface{}{
"created_at": "1700000000000", "updated_at": "1700000000050", "release_id": "9", "status": "failed",
"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)
@@ -214,6 +218,69 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
} }
} }
func TestAppsReleaseGetJSONErrorLogsPassthrough(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/6",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{
"release": map[string]interface{}{
"release_id": "6", "status": "failed",
"created_at": "1700000000000", "updated_at": "1700000000050",
},
"error_logs": []interface{}{
map[string]interface{}{"step": "build", "error_log": "compile error"},
},
}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
}
logs, ok := env.Data["error_logs"].([]interface{})
if !ok || len(logs) != 1 {
t.Fatalf("JSON must passthrough data.error_logs, got: %v", env.Data["error_logs"])
}
first, _ := logs[0].(map[string]interface{})
if first["step"] != "build" || first["error_log"] != "compile error" {
t.Errorf("error_logs content mismatch: %v", logs[0])
}
// flattened release fields must still be present alongside error_logs
if env.Data["release_id"] != "6" || env.Data["status"] != "failed" {
t.Errorf("flattened release fields missing: %v", env.Data)
}
}
func TestAppsReleaseGetJSONNoErrorLogsKeyWhenAbsent(t *testing.T) {
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
Body: map[string]interface{}{"code": 0, "msg": "",
"data": map[string]interface{}{"release": map[string]interface{}{
"release_id": "5", "status": "finished",
"created_at": "1700000000000", "updated_at": "1700000000001",
}}},
})
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
}
if _, present := env.Data["error_logs"]; present {
t.Errorf("error_logs key must be absent when API omits it, got: %v", env.Data)
}
}
func TestAppsReleaseGetPrettyCommitID(t *testing.T) { 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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,7 +9,6 @@ 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 +30,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,7 +9,6 @@ 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,10 +32,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
if strings.TrimSpace(rctx.Str("session-id")) == "" { if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required") return appsValidationParamError("--session-id", "--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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return nil return nil
}, },

View File

@@ -9,7 +9,6 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -35,13 +34,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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
if strings.TrimSpace(rctx.Str("session-id")) == "" { if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required") return appsValidationParamError("--session-id", "--session-id is required")
} }
if strings.TrimSpace(rctx.Str("turn-id")) == "" { if strings.TrimSpace(rctx.Str("turn-id")) == "" {
return output.ErrValidation("--turn-id is required") return appsValidationParamError("--turn-id", "--turn-id is required")
} }
return nil return nil
}, },

View File

@@ -9,7 +9,6 @@ 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"
) )
@@ -34,11 +33,15 @@ 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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
body := buildAppsUpdateBody(rctx) body := buildAppsUpdateBody(rctx)
if len(body) == 0 { if len(body) == 0 {
return output.ErrValidation("provide at least one of --name or --description") return appsValidationError("provide at least one of --name or --description").
WithParams(
appsInvalidParam("--name", "provide at least one of --name or --description"),
appsInvalidParam("--description", "provide at least one of --name or --description"),
)
} }
return nil return nil
}, },

View File

@@ -4,11 +4,9 @@
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 ...)。
@@ -23,11 +21,11 @@ const apiBasePath = "/open-apis/spark/v1"
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it. // 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 failure returned by // withAppsHint attaches an actionable next-step hint to a typed failure,
// CallAPI, preserving its original classification (typed subtype/code/log_id or // preserving its original classification (subtype/code/log_id). A hint already
// legacy detail). A hint already present on the error is kept (the upstream // present on the error is kept (the upstream wording wins); only an empty hint
// wording wins); only an empty hint is filled in. Mirrors // is filled in. Mirrors drive.appendDriveExportRecoveryHint. err==nil and
// drive.appendDriveExportRecoveryHint. err==nil passes through. // untyped errors pass through unchanged.
func withAppsHint(err error, hint string) error { func withAppsHint(err error, hint string) error {
if err == nil { if err == nil {
return nil return nil
@@ -39,14 +37,5 @@ 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/internal/output" "github.com/larksuite/cli/errs"
) )
func TestWithAppsHint(t *testing.T) { func TestWithAppsHint(t *testing.T) {
@@ -17,46 +17,40 @@ func TestWithAppsHint(t *testing.T) {
} }
}) })
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) { t.Run("empty hint gets filled, classification preserved", func(t *testing.T) {
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}} in := errs.NewAPIError(errs.SubtypeNotFound, "boom").WithCode(404)
out := withAppsHint(in, "run +release-list") out := withAppsHint(in, "run +release-list")
var exitErr *output.ExitError p, ok := errs.ProblemOf(out)
if !errors.As(out, &exitErr) { if !ok {
t.Fatalf("returned error is not *output.ExitError: %T", out) t.Fatalf("returned error is not typed: %T", out)
} }
if exitErr.Detail.Hint != "run +release-list" { if p.Hint != "run +release-list" {
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list") t.Errorf("Hint = %q, want %q", p.Hint, "run +release-list")
} }
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" { if p.Subtype != errs.SubtypeNotFound || p.Code != 404 || p.Message != "boom" {
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message) t.Errorf("subtype/code/message mutated: subtype=%q code=%d msg=%q", p.Subtype, p.Code, p.Message)
} }
}) })
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) { t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
in := output.ErrWithHint(1, "api_error", "boom", "original hint") in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("original hint")
out := withAppsHint(in, "new hint") out := withAppsHint(in, "new hint")
var exitErr *output.ExitError p, _ := errs.ProblemOf(out)
if !errors.As(out, &exitErr) { if p.Hint != "original hint" {
t.Fatalf("returned error is not *output.ExitError: %T", out) t.Errorf("Hint = %q, want preserved %q", p.Hint, "original hint")
}
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 := output.ErrWithHint(1, "api_error", "boom", " ") in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint(" ")
out := withAppsHint(in, "filled hint") out := withAppsHint(in, "filled hint")
var exitErr *output.ExitError p, _ := errs.ProblemOf(out)
if !errors.As(out, &exitErr) { if p.Hint != "filled hint" {
t.Fatalf("returned error is not *output.ExitError: %T", out) t.Errorf("Hint = %q, want %q", p.Hint, "filled hint")
}
if exitErr.Detail.Hint != "filled hint" {
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
} }
}) })
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) { t.Run("untyped error returned unchanged, no panic", func(t *testing.T) {
in := errors.New("plain") 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,7 +7,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
) )
@@ -37,7 +36,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 "", output.ErrValidation("--app-id is required") return "", appsValidationParamError("--app-id", "--app-id is required")
} }
return id, nil return id, nil
} }

View File

@@ -6,7 +6,6 @@ package apps
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -22,10 +21,11 @@ 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,9 +53,12 @@ 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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id") if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
}, },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id")) appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -129,9 +132,12 @@ 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 output.ErrValidation("--app-id is required") return appsValidationParamError("--app-id", "--app-id is required")
} }
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id") if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
}, },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id")) appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -268,7 +274,7 @@ func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.
HttpMethod: http.MethodGet, HttpMethod: http.MethodGet,
ApiPath: issuePath(appID), ApiPath: issuePath(appID),
}) })
data, err := parseIssueCredentialData(resp, err) data, err := parseIssueCredentialData(resp, err, i.rctx.APIClassifyContext())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -285,7 +291,8 @@ 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, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`") return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
} }
ac, err := i.f.NewAPIClientWithConfig(cfg) ac, err := i.f.NewAPIClientWithConfig(cfg)
if err != nil { if err != nil {
@@ -296,7 +303,11 @@ 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) data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
Brand: string(cfg.Brand),
AppID: cfg.AppID,
Identity: string(core.AsUser),
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -414,13 +425,11 @@ 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,
@@ -448,64 +457,43 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
issued.AppID = appID issued.AppID = appID
} }
if issued.GitHTTPURL == "" { if issued.GitHTTPURL == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL") return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
} }
if issued.PAT == "" { if issued.PAT == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token") return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
} }
return issued, nil return issued, nil
} }
func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) { // parseIssueCredentialData turns the git-credential issue response into the
// credential data map. A standard Lark envelope (top-level "code") and any
// HTTP error status route through the shared response classifier, so generic
// codes (missing scope, app not authorized) and 5xx statuses keep their
// canonical category/subtype/retryable classification. The endpoint's
// non-standard success shapes — direct git info or a BaseResp wrapper — are
// handled locally.
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
if err != nil { if err != nil {
return nil, err return nil, client.WrapDoAPIError(err)
} }
detail := logIDDetail(resp) detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 { if resp == nil || len(resp.RawBody) == 0 {
return nil, &errs.InternalError{Problem: errs.Problem{ return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
Category: errs.CategoryInternal, "Issue Miaoda Git credential: empty response body")
Subtype: errs.SubtypeUnknown,
Message: "Issue Miaoda Git credential: empty response body",
}}
} }
var result map[string]any var result map[string]any
if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil { jsonErr := json.Unmarshal(resp.RawBody, &result)
return nil, &errs.InternalError{Problem: errs.Problem{ _, hasCode := result["code"]
Category: errs.CategoryInternal, if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
Subtype: errs.SubtypeUnknown, data, cerr := common.ClassifyAPIResponseWith(resp, cc)
Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr), if cerr != nil {
}, Cause: jsonErr} return nil, withAppsHint(cerr, gitCredentialIssueHint)
}
if resp.StatusCode >= http.StatusBadRequest {
msg := firstString(result, "msg", "message")
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
} }
return nil, &errs.APIError{Problem: errs.Problem{ if data != nil {
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
} }
@@ -534,13 +522,11 @@ 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"
} }
return &errs.APIError{Problem: errs.Problem{ baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code))
Category: errs.CategoryAPI, if logID != "" {
Subtype: errs.SubtypeUnknown, baseErr = baseErr.WithLogID(logID)
Code: int(code), }
Message: "Issue Miaoda Git credential: " + message, return baseErr
LogID: logID,
}}
} }
return nil return nil
} }
@@ -578,6 +564,9 @@ 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,7 +5,6 @@ package apps
import ( import (
"errors" "errors"
"fmt"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -35,7 +34,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, fmt.Errorf("apps storage: read root: %w", err) return nil, appsStorageError(err, "apps storage: read root: %v", 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) }, nil, errclass.ClassifyContext{})
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)
} }
exitErr := output.ErrValidation("bad app") validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app")
if got := gitCredentialLocalError("action", exitErr); got != exitErr { if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) {
t.Fatalf("legacy output error was rewrapped: %#v", got) t.Fatalf("typed validation 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")); err == nil { if _, err := parseIssueCredentialData(nil, errors.New("transport failed"), errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData transport error returned nil") t.Fatal("parseIssueCredentialData transport error returned nil")
} }
if _, err := parseIssueCredentialData(nil, nil); err == nil { if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData nil response returned nil") t.Fatal("parseIssueCredentialData nil response returned nil")
} }
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil); err == nil { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData bad json returned nil") 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); err == nil || !strings.Contains(err.Error(), "bad request") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "bad request") {
t.Fatalf("HTTP error = %v", err) t.Fatalf("HTTP error = %v", err)
} }
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "HTTP 500") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("HTTP fallback error = %v", err) t.Fatalf("HTTP fallback error = %v", err)
} }
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "failed") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "failed") {
t.Fatalf("code error = %v", err) t.Fatalf("code error = %v", err)
} }
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil) data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil, errclass.ClassifyContext{})
if err != nil { 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) data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil, errclass.ClassifyContext{})
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); err == nil || !strings.Contains(err.Error(), "denied") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("BaseResp error = %v", err) t.Fatalf("BaseResp error = %v", err)
} }
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") { if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
t.Fatalf("BaseResp fallback error = %v", err) 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) _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil, errclass.ClassifyContext{})
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) _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil, errclass.ClassifyContext{})
if err == nil { if err == nil {
t.Fatal("expected business-code error, got nil") t.Fatal("expected business-code error, got nil")
} }
@@ -1014,11 +1014,10 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
// server msg and assert (a) Message equals that msg exactly, and (b) neither // 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 framework's responsibility; apps adds // Note: server msg passthrough is the shared classifier's responsibility;
// only a static hint. There is no msg redaction in this path (verbatim // apps adds only a static hint. There is no msg redaction in this path, so
// passthrough is the existing behavior), so this test does not assert a // this test does not assert a redaction that does not exist — it asserts
// redaction that does not exist — it asserts that apps injects nothing // that apps injects nothing sensitive of its own.
// 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"}}
@@ -1045,7 +1044,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) _, err := parseIssueCredentialData(tc.resp, nil, errclass.ClassifyContext{})
if err == nil { if err == nil {
t.Fatal("expected an error, got nil") t.Fatal("expected an error, got nil")
} }
@@ -1053,9 +1052,12 @@ 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 is passed through verbatim. // (a) The server msg survives into the message. The business-code
if p.Message != serverMsg { // path passes it through verbatim; the HTTP-status path reports
t.Fatalf("Message = %q, want server msg %q (verbatim passthrough)", p.Message, serverMsg) // "HTTP <status>: <body>" via the shared classifier, so assert
// containment rather than equality.
if !strings.Contains(p.Message, serverMsg) {
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
} }
// apps adds only the static hint — assert that exact static text, // 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.
@@ -1138,3 +1140,45 @@ 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 fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL) return errs.NewValidationError(errs.SubtypeFailedPrecondition, "git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
} }
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil { 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, fmt.Errorf("get %s: %w", key, err) return "", false, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(err)
} }
func gitConfigUnset(ctx context.Context, key string) error { 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 fmt.Errorf("unset %s: %w", key, err) return errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(err)
} }
return nil return nil
} }

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/errs"
"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, output.ErrValidation("--app-id is required") return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
} }
if err := validate.ResourceName(appID, "--app-id"); err != nil { if err := validate.ResourceName(appID, "--app-id"); err != nil {
return nil, err return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
} }
if profile.UserOpenID == "" { if profile.UserOpenID == "" {
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`") return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
} }
unlockApp, err := lockApp(appID) unlockApp, err := lockApp(appID)
if err != nil { if err != nil {
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err) return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
} }
defer unlockApp() defer unlockApp()
if m.Issuer == nil { if m.Issuer == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured") return nil, errs.NewInternalError(errs.SubtypeUnknown, "git credential issuer is not configured")
} }
issued, err := m.Issuer.Issue(ctx, appID, profile) 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, output.ErrValidation("--app-id is required") return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
} }
if err := validate.ResourceName(appID, "--app-id"); err != nil { if err := validate.ResourceName(appID, "--app-id"); err != nil {
return nil, err return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
} }
unlockApp, err := lockApp(appID) unlockApp, err := lockApp(appID)
if err != nil { if err != nil {
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err) return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
} }
defer unlockApp() 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 fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err) return errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
} }
defer unlockApp() defer unlockApp()
record, err = m.Store.FindByURL(url) record, err = m.Store.FindByURL(url)
@@ -360,7 +360,8 @@ 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, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID) return CredentialRecord{}, "", false, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential").
WithHint(fmt.Sprintf("run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID))
} }
pat, err := m.Secrets.Get(record.PATRef) pat, err := m.Secrets.Get(record.PATRef)
if err != nil { if err != nil {
@@ -423,7 +424,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{}, output.ErrValidation("invalid credential URL") return CredentialInput{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid credential URL")
} }
hostPath := parts[1] hostPath := parts[1]
idx := strings.Index(hostPath, "/") idx := strings.Index(hostPath, "/")
@@ -457,19 +458,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 output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential") return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
} }
if issued.AppID != "" && issued.AppID != appID { if issued.AppID != "" && issued.AppID != appID {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID) return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
} }
if normalizedURL == "" { if normalizedURL == "" {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL") return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
} }
if strings.TrimSpace(issued.PAT) == "" { if strings.TrimSpace(issued.PAT) == "" {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token") return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
} }
if issued.ExpiresAt <= now { if issued.ExpiresAt <= now {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future") return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future")
} }
return nil 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, fmt.Errorf("create Git credential lock dir: %w", err) return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err)
} }
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock" 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/internal/output" "github.com/larksuite/cli/errs"
) )
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 "", output.ErrValidation("git_http_url is empty") return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty")
} }
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil { if err != nil {
return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err) return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err)
} }
return normalizeParsedURL(u) 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 "", output.ErrValidation("git credential input must include protocol and host") return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential input must include protocol and host")
} }
u := &url.URL{ 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 "", output.ErrValidation("git credential only supports http/https URLs") return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs")
} }
host := normalizeHost(scheme, u.Host) host := normalizeHost(scheme, u.Host)
if host == "" { if host == "" {
return "", output.ErrValidation("git_http_url host is empty") return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url host is empty")
} }
cleanPath := cleanURLPath(u.EscapedPath()) 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/internal/output" "github.com/larksuite/cli/errs"
"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,28 +39,18 @@ 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, err return nil, client.WrapDoAPIError(err)
} }
return parseHTMLPublishResponse(apiResp.RawBody) data, err := api.runtime.ClassifyAPIResponse(apiResp)
} 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"`
} }
if err := json.Unmarshal(raw, &envelope); err != nil { url, _ := data["url"].(string)
return nil, fmt.Errorf("decode html-publish response: %w", err) if url == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"html-publish response is missing the published app url")
} }
if envelope.Code != 0 { return &htmlPublishResponse{URL: url}, nil
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
@@ -74,9 +64,9 @@ const (
func buildHTMLPublishFailureHint(code int) string { func buildHTMLPublishFailureHint(code int) string {
switch code { switch code {
case errCodeBuildFailed: case errCodeBuildFailed:
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单" return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound: case errCodeAppNotFound:
return "应用不存在或无权访问;请用户确认 app_id从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx /app/ 后面提取,或直接给 app_xxx 字符串)" return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default: 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,15 +94,57 @@ func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected error") t.Fatalf("expected error")
} }
var exitErr *output.ExitError problem := requireAppsAPIProblem(t, err)
if !errors.As(err, &exitErr) || exitErr.Detail == nil { if problem.Code != errCodeBuildFailed {
t.Fatalf("expected ExitError with detail, got %v", err) t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
} }
if exitErr.Detail.Hint == "" { if problem.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001") t.Fatalf("expected non-empty hint on code 90001")
} }
if !strings.Contains(exitErr.Detail.Message, "build failed") { if !strings.Contains(problem.Message, "build failed") {
t.Fatalf("missing failure message: %v", exitErr.Detail.Message) t.Fatalf("missing failure message: %v", problem.Message)
}
}
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
Body: map[string]interface{}{
"code": errCodeAppNotFound,
"msg": "app not found",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsAPIProblem(t, err)
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
if problem.Hint == "" {
t.Fatalf("expected app-not-found recovery hint")
}
}
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
} }
} }
@@ -138,8 +180,18 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing
} }
} }
func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) { func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil { rctx, reg := newAppsClientRuntime(t)
t.Error("malformed html-publish response must surface a decode error") reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
RawBody: []byte("{not json"),
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
} }
} }

View File

@@ -9,10 +9,9 @@ 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"
) )
@@ -26,7 +25,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, errors.New("no files to pack") return nil, appsValidationParamError("--path", "no files to pack")
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -45,10 +44,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, fmt.Errorf("tar close: %w", err) return nil, appsFileIOError(err, "tar close: %v", err)
} }
if err := gz.Close(); err != nil { if err := gz.Close(); err != nil {
return nil, fmt.Errorf("gzip close: %w", err) return nil, appsFileIOError(err, "gzip close: %v", err)
} }
return &htmlPublishTarball{ return &htmlPublishTarball{
@@ -60,12 +59,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 fmt.Errorf("invalid tar entry name %q", c.RelPath) return errs.NewInternalError(errs.SubtypeUnknown, "invalid tar entry name %q", c.RelPath)
} }
src, err := fio.Open(c.AbsPath) src, err := fio.Open(c.AbsPath)
if err != nil { if err != nil {
return fmt.Errorf("open %s: %w", c.AbsPath, err) return appsInputPathEntryError(c.AbsPath, err)
} }
defer src.Close() defer src.Close()
@@ -76,10 +75,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 fmt.Errorf("write header %s: %w", c.RelPath, err) return appsFileIOError(err, "write header %s: %v", c.RelPath, err)
} }
if _, err := io.Copy(tw, src); err != nil { if _, err := io.Copy(tw, src); err != nil {
return fmt.Errorf("copy %s: %w", c.RelPath, err) return appsFileIOError(err, "copy %s: %v", c.RelPath, err)
} }
return nil 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 fmt.Errorf("apps storage: %w", err) return appsStorageError(err, "apps storage: %v", err)
} }
if name == "." { if name == "." {
return fmt.Errorf("apps storage: %s must not be \".\"", what) return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what)
} }
return nil 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, fmt.Errorf("apps storage: read: %w", err) return nil, appsStorageError(err, "apps storage: read: %v", 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 fmt.Errorf("apps storage: create dir: %w", err) return appsStorageError(err, "apps storage: create dir: %v", err)
} }
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil { if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
return fmt.Errorf("apps storage: write: %w", err) return appsStorageError(err, "apps storage: write: %v", err)
} }
return nil 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 fmt.Errorf("apps storage: delete: %w", err) return appsStorageError(err, "apps storage: delete: %v", 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, fmt.Errorf("apps storage: read dir: %w", err) return nil, appsStorageError(err, "apps storage: read dir: %v", 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, fmt.Errorf("stat %s: %w", rootPath, err) return nil, appsInputPathError(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 walkErr return appsInputPathEntryError(path, 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 err return appsInputPathEntryError(path, 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 err return appsFileIOError(err, "resolve relative path for %s: %v", path, 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 fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path) return errs.NewInternalError(errs.SubtypeUnknown, "walker produced unsafe relative path %q for %s", relSlash, path)
} }
out = append(out, htmlPublishCandidate{ out = append(out, htmlPublishCandidate{
RelPath: relSlash, RelPath: relSlash,

View File

@@ -298,6 +298,14 @@ func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]in
// carry fields a caller needs on failure (e.g. the file_token an overwrite // 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)
@@ -321,7 +329,7 @@ func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[stri
} }
} }
out, _ := resultMap["data"].(map[string]interface{}) out, _ := resultMap["data"].(map[string]interface{})
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil { if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil {
return out, apiErr 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-type": "ByCreateTimeAsc", "sort": "create_time",
}, 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,7 +48,8 @@ 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-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}}, {Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"}, {Name: "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"},
@@ -266,9 +267,16 @@ 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": runtime.Str("sort-type"), "sort_type": sortType,
} }
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,3 +611,85 @@ 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,7 +32,8 @@ var ImChatMessageList = common.Shortcut{
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"}, {Name: "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: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}}, {Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"}, {Name: "page-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)"},
@@ -209,7 +210,11 @@ 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) {
params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId) dir := runtime.Str("order")
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
dir = old // old value is asc/desc -> must go through the same map, never pass through
}
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
if startFlag := runtime.Str("start"); startFlag != "" { if startFlag := runtime.Str("start"); startFlag != "" {
startTime, err := common.ParseTime(startFlag) startTime, err := common.ParseTime(startFlag)

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// newMsgListTestRT registers chat-id (so the builder has a container) plus the
// sort flags under test; only flags present in stringFlags are "set" (Changed).
func newMsgListTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["chat-id"]; !ok {
stringFlags["chat-id"] = "oc_test"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestChatMessagesList_OrderMapping(t *testing.T) {
cases := []struct{ order, want string }{
{"asc", "ByCreateTimeAsc"},
{"desc", "ByCreateTimeDesc"},
}
for _, c := range cases {
t.Run(c.order, func(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"order": c.order})
params, err := buildChatMessageListRequest(rt, "oc_test")
if err != nil {
t.Fatalf("buildChatMessageListRequest() error = %v", err)
}
if got := params["sort_type"][0]; got != c.want {
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, got, c.want)
}
})
}
}
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
// through the SAME table as --order (NOT pass through), producing identical upstream.
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
for _, dir := range []string{"asc", "desc"} {
t.Run(dir, func(t *testing.T) {
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
func TestChatMessagesList_OrderNewWins(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
params, err := buildChatMessageListRequest(rt, "oc_test")
if err != nil {
t.Fatalf("error = %v", err)
}
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
}
}
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
var orderFlag, aliasFlag *common.Flag
for i := range ImChatMessageList.Flags {
switch ImChatMessageList.Flags[i].Name {
case "order":
orderFlag = &ImChatMessageList.Flags[i]
case "sort":
aliasFlag = &ImChatMessageList.Flags[i]
}
}
if orderFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --order and --sort flags declared")
}
if orderFlag.Default != "desc" {
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
}
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--order Enum = %q, want asc,desc", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
}
}

View File

@@ -35,7 +35,8 @@ 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-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}}, {Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, {Name: "page-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"},
@@ -209,8 +210,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-by // is omitted when no filter flags are set; "sorter" is omitted when --sort
// is empty. // (and its hidden alias --sort-by) is unset.
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} { func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{} body := map[string]interface{}{}
@@ -256,9 +257,18 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
body["filter"] = filter body["filter"] = filter
} }
// Build sorters (always descending) // Build sorter (always descending). --sort maps field -> field_desc; the hidden
if sortBy := runtime.Str("sort-by"); sortBy != "" { // --sort-by alias is already the upstream value (pass-through). Omitted when unset.
body["sorter"] = sortBy sorter := map[string]string{
"create_time": "create_time_desc",
"update_time": "update_time_desc",
"member_count": "member_count_desc",
}[runtime.Str("sort")]
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
sorter = old
}
if sorter != "" {
body["sorter"] = sorter
} }
return body return body

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["query"]; !ok {
stringFlags["query"] = "team"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestChatSearch_SortMapping(t *testing.T) {
cases := []struct{ sort, want string }{
{"create_time", "create_time_desc"},
{"update_time", "update_time_desc"},
{"member_count", "member_count_desc"},
}
for _, c := range cases {
t.Run(c.sort, func(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"sort": c.sort})
body := buildSearchChatBody(rt)
if body["sorter"] != c.want {
t.Fatalf("sort=%s -> sorter=%v, want %s", c.sort, body["sorter"], c.want)
}
})
}
}
// TestChatSearch_SortOmittedWhenUnset: no --sort and no --sort-by -> sorter omitted.
func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
rt := newSearchTestRT(t, nil)
body := buildSearchChatBody(rt)
if _, present := body["sorter"]; present {
t.Fatalf("sorter should be omitted when neither --sort nor --sort-by set")
}
}
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
// sorter (pass-through), so it must equal the mapped new --sort body.
func TestChatSearch_SortAliasParity(t *testing.T) {
pairs := []struct{ newVal, oldVal string }{
{"create_time", "create_time_desc"},
{"update_time", "update_time_desc"},
{"member_count", "member_count_desc"},
}
for _, p := range pairs {
t.Run(p.newVal, func(t *testing.T) {
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
if newBody["sorter"] != oldBody["sorter"] {
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
}
})
}
}
func TestChatSearch_SortNewWins(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
body := buildSearchChatBody(rt)
if body["sorter"] != "member_count_desc" {
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
}
}
func TestChatSearch_SortFlagSurface(t *testing.T) {
var sortFlag, aliasFlag *common.Flag
for i := range ImChatSearch.Flags {
switch ImChatSearch.Flags[i].Name {
case "sort":
sortFlag = &ImChatSearch.Flags[i]
case "sort-by":
aliasFlag = &ImChatSearch.Flags[i]
}
}
if sortFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --sort and --sort-by flags declared")
}
if sortFlag.Default != "" {
t.Errorf("--sort must have no default (sorter omitted when unset), got %q", sortFlag.Default)
}
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
t.Errorf("--sort Enum = %q", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort-by must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
t.Errorf("--sort-by Enum = %q", got)
}
}

View File

@@ -31,7 +31,8 @@ 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: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}}, {Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-500)"}, {Name: "page-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)"},
@@ -39,15 +40,10 @@ 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")
sortFlag := runtime.Str("sort") dir := resolveThreadsOrder(runtime)
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()
@@ -57,21 +53,12 @@ var ImThreadsMessagesList = common.Shortcut{
containerID = "<resolved_thread_id>" containerID = "<resolved_thread_id>"
} }
params := map[string]interface{}{ params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
"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(params). Params(toDryParams(params)).
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr) Set("thread", threadFlag).Set("order", dir).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.")
@@ -97,26 +84,12 @@ var ImThreadsMessagesList = common.Shortcut{
if err != nil { if err != nil {
return err return err
} }
sortFlag := runtime.Str("sort") dir := resolveThreadsOrder(runtime)
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 := map[string][]string{ params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
"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 {
@@ -188,3 +161,45 @@ 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

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["thread"]; !ok {
stringFlags["thread"] = "omt_test"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestThreadsMessagesList_OrderMapping(t *testing.T) {
cases := []struct{ order, want string }{
{"asc", "ByCreateTimeAsc"},
{"desc", "ByCreateTimeDesc"},
}
for _, c := range cases {
t.Run(c.order, func(t *testing.T) {
got := buildThreadsMessagesListParams(c.order, "omt_test", 50, "")
if v := got["sort_type"][0]; v != c.want {
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, v, c.want)
}
})
}
}
// TestThreadsMessagesList_OrderAliasParity proves DryRun(--sort dir) == DryRun(--order dir).
// This is the test the refactor exists to make meaningful (single shared mapping).
func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
for _, dir := range []string{"asc", "desc"} {
t.Run(dir, func(t *testing.T) {
newRT := newThreadsTestRT(t, map[string]string{"order": dir})
oldRT := newThreadsTestRT(t, map[string]string{"sort": dir})
a := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
var orderFlag, aliasFlag *common.Flag
for i := range ImThreadsMessagesList.Flags {
switch ImThreadsMessagesList.Flags[i].Name {
case "order":
orderFlag = &ImThreadsMessagesList.Flags[i]
case "sort":
aliasFlag = &ImThreadsMessagesList.Flags[i]
}
}
if orderFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --order and --sort flags declared")
}
if orderFlag.Default != "asc" {
t.Errorf("--order Default = %q, want asc", orderFlag.Default)
}
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--order Enum = %q, want asc,desc", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort must be Hidden")
}
if aliasFlag.Default != "" {
t.Errorf("--sort (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import "github.com/larksuite/cli/shortcuts/common"
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
// alias. It returns (oldValue, true) only when the old flag was explicitly used
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
if rt.Changed(oldName) && !rt.Changed(newName) {
return rt.Str(oldName), true
}
return "", false
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newAliasTestRT registers a new flag (with a default) and an old flag, then
// sets only the flags present in `set` — so Changed() reflects exactly which
// flags were "passed on the command line".
func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(newName, newDefault, "")
cmd.Flags().String(oldName, "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for k, v := range set {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("Set(%q) error = %v", k, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestAliasFlagValue(t *testing.T) {
cases := []struct {
name string
set map[string]string
wantVal string
wantOK bool
}{
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "ByActiveTimeDesc", true},
{"neither set", nil, "", false},
{"only new set", map[string]string{"sort": "active_time"}, "", false},
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newAliasTestRT(t, "sort", "create_time", "sort-type", c.set)
gotVal, gotOK := aliasFlagValue(rt, "sort-type", "sort")
if gotVal != c.wantVal || gotOK != c.wantOK {
t.Fatalf("aliasFlagValue() = (%q, %v), want (%q, %v)", gotVal, gotOK, c.wantVal, c.wantOK)
}
})
}
}

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 a single // MailMessage is the `+message` shortcut: fetch full content of one email
// email by message ID (normalized body + attachments / inline metadata). // by one 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 when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.", Description: "Use only when reading full content for one email by one message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata, including inline images.",
Risk: "read", 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. Email message ID", Required: true}, {Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, {Name: "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 backend calls into batches of 20 while // multiple message IDs, chunking requests into batches of 20 while preserving
// preserving request order. // 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. 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.", 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.",
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. Example: "id1,id2,id3"`, Required: true}, {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: "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 via messages.batch_get (auto-chunked in batches of 20 IDs during execution)"). Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output").
POST(mailboxPath(mailboxID, "messages", "batch_get")). POST(mailboxPath(mailboxID, "messages", "batch_get")).
Body(body) Body(body)
}, },

View File

@@ -0,0 +1,220 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestMailMessageHelpClarifiesSingleMessageOnly(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcutWithCobraOutput(t, MailMessage, []string{"+message", "-h"}, f, stdout)
if err != nil {
t.Fatalf("help returned error: %v", err)
}
help := stdout.String()
for _, want := range []string{
"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",
"Single email message ID only",
"mail +messages --message-ids",
} {
if !strings.Contains(help, want) {
t.Fatalf("help missing %q\n%s", want, help)
}
}
}
func TestMailMessagesHelpClarifiesBatchGetChunkingAndLimits(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcutWithCobraOutput(t, MailMessages, []string{"+messages", "-h"}, f, stdout)
if err != nil {
t.Fatalf("help returned error: %v", err)
}
help := stdout.String()
for _, want := range []string{
"multiple emails by message ID",
"handles them in batches of 20 and merges output",
"Comma-separated email message IDs",
"You may pass more than 20 IDs",
} {
if !strings.Contains(help, want) {
t.Fatalf("help missing %q\n%s", want, help)
}
}
for _, disallowed := range []string{"messages.batch_get", "OAPI Meta", "gateway config", "50 IDs", "50 个"} {
if strings.Contains(help, disallowed) {
t.Fatalf("help must not expose internal wording %q\n%s", disallowed, help)
}
}
}
func TestMailMessagesDryRunMentionsBatchGetChunkingAndMerge(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
messageIDs := []string{
validMessageIDForTest("dry-run-1"),
validMessageIDForTest("dry-run-2"),
}
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(messageIDs, ","), "--dry-run", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run returned error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"chunks every 20 IDs",
"merges output",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run missing %q\n%s", want, out)
}
}
}
func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
errOut := stderr.String()
for _, want := range []string{
"tip: read full content:",
"single message use mail +message --message-id <id>",
"multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>",
} {
if !strings.Contains(errOut, want) {
t.Fatalf("stderr missing %q\n%s", want, errOut)
}
}
}
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
}
}
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
Reusable: true,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"messages": []interface{}{}},
},
}
reg.Register(stub)
ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(fmt.Sprintf("batch-%02d", i+1))
}
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("messages returned error: %v", err)
}
if got := len(stub.CapturedBodies); got != 2 {
t.Fatalf("expected 2 batch_get calls, got %d", got)
}
assertBatchGetMessageIDCount(t, stub.CapturedBodies[0], 20)
assertBatchGetMessageIDCount(t, stub.CapturedBodies[1], 1)
}
func registerTriageReadHintStubs(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/messages",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{"msg_1"},
"has_more": false,
"page_token": "",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": []interface{}{
map[string]interface{}{
"message_id": "msg_1",
"subject": "Quarterly update",
"date": "Thu, 04 Jun 2026 10:00:00 +0800",
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
},
},
},
},
})
}
func assertBatchGetMessageIDCount(t *testing.T, body []byte, want int) {
t.Helper()
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal batch_get body: %v\n%s", err, string(body))
}
if got := len(payload.MessageIDs); got != want {
t.Fatalf("message_ids count mismatch: got %d want %d body=%s", got, want, string(body))
}
}
func runMountedMailShortcutWithCobraOutput(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "test"}
parent.SetOut(stdout)
parent.SetErr(stdout)
shortcut.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
stdout.Reset()
return parent.Execute()
}

View File

@@ -322,9 +322,10 @@ var MailTriage = common.Shortcut{
fmt.Fprintln(runtime.IO().ErrOut, hint.String()) fmt.Fprintln(runtime.IO().ErrOut, hint.String())
} }
if mailbox != "me" { if mailbox != "me" {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content") quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else { } else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content") fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
} }
} }
return nil return nil

209
shortcuts/note/note.go Normal file
View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package note owns the Note domain: querying note detail and the unified
// transcript by a known note_id. The vc domain locates a
// note_id from meeting context and delegates note-detail parsing here, so the
// parsing logic lives in exactly one place.
package note
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// NoNoteReadPermissionCode is returned when the caller lacks read permission
// for the requested note.
const NoNoteReadPermissionCode = 121005
// ErrEmptyDetail identifies note detail responses that do not contain a note
// object. Callers should use errors.Is instead of matching the display message.
var ErrEmptyDetail = errors.New("note detail is empty")
// artifact_type enum from the note detail API.
const (
artifactTypeMainDoc = 1 // main note document
artifactTypeVerbatim = 2 // verbatim transcript
)
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
// a stable string so Agents route on a name, not a magic number.
const (
displayTypeNormal = 1
displayTypeUnified = 2
)
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
type Detail struct {
NoteID string
CreatorID string
CreateTime string
DisplayType string // unknown | normal | unified
NoteDocToken string
VerbatimDocToken string
SharedDocTokens []string
}
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
// object. API errors are returned as typed errs.* values so callers can enrich
// user guidance without downgrading the envelope.
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
if err != nil {
return nil, err
}
noteObj, _ := data["note"].(map[string]any)
if noteObj == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty").WithCause(ErrEmptyDetail)
}
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
return &Detail{
NoteID: noteID,
CreatorID: common.GetString(noteObj, "creator_id"),
CreateTime: common.FormatTime(noteObj["create_time"]),
DisplayType: displayTypeString(displayTypeValue(noteObj)),
NoteDocToken: noteDoc,
VerbatimDocToken: verbatimDoc,
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
}, nil
}
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
// the historical key set (shared_doc_tokens omitted when empty) and adding the
// note_id / note_display_type fields.
func (d *Detail) ToMap() map[string]any {
m := map[string]any{
"note_id": d.NoteID,
"note_display_type": d.DisplayType,
"creator_id": d.CreatorID,
"create_time": d.CreateTime,
"note_doc_token": d.NoteDocToken,
"verbatim_doc_token": d.VerbatimDocToken,
}
if len(d.SharedDocTokens) > 0 {
m["shared_doc_tokens"] = d.SharedDocTokens
}
return m
}
// displayTypeValue reads the display-type field, tolerating either the
// documented note_display_type key or a bare display_type fallback.
func displayTypeValue(note map[string]any) any {
if v, ok := note["note_display_type"]; ok {
return v
}
return note["display_type"]
}
func displayTypeString(v any) string {
switch parseLooseInt(v) {
case displayTypeNormal:
return "normal"
case displayTypeUnified:
return "unified"
default:
return "unknown"
}
}
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
for _, a := range artifacts {
artifact, _ := a.(map[string]any)
if artifact == nil {
continue
}
docToken, _ := artifact["doc_token"].(string)
switch parseLooseInt(artifact["artifact_type"]) {
case artifactTypeMainDoc:
noteDoc = docToken
case artifactTypeVerbatim:
verbatimDoc = docToken
}
}
return
}
// extractDocTokens collects doc_token values from a list of reference objects.
func extractDocTokens(refs []any) []string {
var tokens []string
for _, s := range refs {
source, _ := s.(map[string]any)
if source == nil {
continue
}
if docToken, _ := source["doc_token"].(string); docToken != "" {
tokens = append(tokens, docToken)
}
}
return tokens
}
// parseLooseInt extracts an int from the varying JSON number representations
// DoAPIJSON may yield (json.Number, float64, or int).
func parseLooseInt(v any) int {
switch n := v.(type) {
case json.Number:
i, _ := n.Int64()
return int(i)
case float64:
// Reject fractional values: truncating 1.9 to 1 would silently coerce
// a malformed enum into a valid one.
if n != float64(int64(n)) {
return 0
}
return int(n)
case int:
return n
default:
return 0
}
}
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
// preferred because large JSON numbers lose precision when decoded into any.
func parseLooseCursorID(v any) (string, bool) {
switch n := v.(type) {
case string:
s := strings.TrimSpace(n)
if s == "" || s == "0" {
return "", false
}
return s, true
case json.Number:
i, err := n.Int64()
if err != nil || i <= 0 {
return "", false
}
return strconv.FormatInt(i, 10), true
case float64:
// encoding/json decodes numbers in map[string]any as float64. Accept
// only values that can round-trip safely as an integer cursor.
const maxSafeJSONInteger = 1<<53 - 1
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
return "", false
}
return strconv.FormatInt(int64(n), 10), true
case int64:
if n <= 0 {
return "", false
}
return strconv.FormatInt(n, 10), true
case int:
if n <= 0 {
return "", false
}
return strconv.Itoa(n), true
default:
return "", false
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// note +detail — get note metadata and document tokens by a known note_id.
package note
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// NoteDetail queries note metadata, display type and document tokens by note_id.
var NoteDetail = common.Shortcut{
Service: "note",
Command: "+detail",
Description: "Get note detail (display type, document tokens) by note_id",
Risk: "read",
Scopes: []string{"vc:note:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "note-id", Desc: "note ID", Required: true},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
if noteID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
}
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
}
return nil
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
noteID := strings.TrimSpace(runtime.Str("note-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
detail, err := FetchDetail(ctx, runtime, noteID)
if err != nil {
return mapNoteError(err)
}
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
return nil
},
}
// mapNoteError surfaces the no-permission case explicitly and passes through
// any other typed API error unchanged.
func mapNoteError(err error) error {
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
message := strings.TrimSpace(problem.Message)
if message == "" {
message = "no read permission for this note"
} else if !strings.Contains(message, "no read permission for this note") {
message = fmt.Sprintf("no read permission for this note: %s", message)
}
var permErr *errs.PermissionError
if errors.As(err, &permErr) {
mapped := *permErr
mapped.Problem.Message = message
if mapped.Problem.Hint == "" {
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
}
mapped.Cause = err
return &mapped
}
mappedProblem := *problem
mappedProblem.Category = errs.CategoryAuthorization
mappedProblem.Subtype = errs.SubtypePermissionDenied
mappedProblem.Message = message
if mappedProblem.Hint == "" {
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
}
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
}
return err
}

280
shortcuts/note/note_test.go Normal file
View File

@@ -0,0 +1,280 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package note
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// These tests were relocated from shortcuts/vc/vc_notes_test.go together with
// the note-detail parsing helpers they cover.
func TestParseLooseInt(t *testing.T) {
tests := []struct {
input any
want int
}{
{float64(1), 1},
{float64(2), 2},
{float64(1.9), 0},
{json.Number("3"), 3},
{"unknown", 0},
{nil, 0},
}
for _, tt := range tests {
got := parseLooseInt(tt.input)
if got != tt.want {
t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestParseLooseCursorID(t *testing.T) {
tests := []struct {
name string
in any
want string
ok bool
}{
{name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true},
{name: "trim string", in: " 123 ", want: "123", ok: true},
{name: "empty string", in: "", ok: false},
{name: "zero string", in: "0", ok: false},
{name: "json number", in: json.Number("123"), want: "123", ok: true},
{name: "float safe integer", in: float64(123), want: "123", ok: true},
{name: "float unsafe integer", in: float64(1<<53 + 1), ok: false},
{name: "float fractional", in: float64(1.5), ok: false},
{name: "negative", in: -1, ok: false},
{name: "nil", in: nil, ok: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseLooseCursorID(tt.in)
if got != tt.want || ok != tt.ok {
t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
}
})
}
}
func TestExtractArtifactTokens(t *testing.T) {
artifacts := []any{
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
nil,
}
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
if noteDoc != "main_doc" {
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
}
if verbatimDoc != "verbatim_doc" {
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
}
}
func TestExtractArtifactTokens_Empty(t *testing.T) {
noteDoc, verbatimDoc := extractArtifactTokens(nil)
if noteDoc != "" || verbatimDoc != "" {
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
}
}
func TestExtractDocTokens(t *testing.T) {
refs := []any{
map[string]any{"doc_token": "shared1"},
map[string]any{"doc_token": "shared2"},
map[string]any{"doc_token": ""},
map[string]any{},
nil,
}
tokens := extractDocTokens(refs)
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
}
}
func TestExtractDocTokens_Empty(t *testing.T) {
tokens := extractDocTokens(nil)
if tokens != nil {
t.Errorf("expected nil for nil input, got %v", tokens)
}
}
func TestDetailToMap(t *testing.T) {
detail := &Detail{
NoteID: "note_1",
CreatorID: "creator_1",
CreateTime: "2026-06-09 12:00:00",
DisplayType: "unified",
NoteDocToken: "note_doc",
VerbatimDocToken: "verbatim_doc",
SharedDocTokens: []string{"shared_1", "shared_2"},
}
got := detail.ToMap()
want := map[string]any{
"note_id": "note_1",
"creator_id": "creator_1",
"create_time": "2026-06-09 12:00:00",
"note_display_type": "unified",
"note_doc_token": "note_doc",
"verbatim_doc_token": "verbatim_doc",
"shared_doc_tokens": []string{"shared_1", "shared_2"},
}
for key, wantValue := range want {
gotValue, ok := got[key]
if !ok {
t.Fatalf("ToMap missing key %q in %#v", key, got)
}
if !valuesEqual(gotValue, wantValue) {
t.Fatalf("ToMap[%q] = %#v, want %#v", key, gotValue, wantValue)
}
}
}
func TestDetailToMap_OmitsEmptySharedDocTokens(t *testing.T) {
got := (&Detail{NoteID: "note_1"}).ToMap()
if _, ok := got["shared_doc_tokens"]; ok {
t.Fatalf("ToMap should omit empty shared_doc_tokens, got %#v", got)
}
}
func TestMapNoteError_NoReadPermission(t *testing.T) {
err := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypePermissionDenied,
Code: NoNoteReadPermissionCode,
Message: "upstream permission denied",
LogID: "log_1",
},
MissingScopes: []string{"vc:note:read"},
Identity: "user",
}
got := mapNoteError(err)
problem, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("mapNoteError returned %T, want typed problem", got)
}
if problem.Code != NoNoteReadPermissionCode {
t.Fatalf("mapped code = %d, want %d", problem.Code, NoNoteReadPermissionCode)
}
if !strings.Contains(problem.Message, "no read permission for this note") || !strings.Contains(problem.Message, "upstream permission denied") {
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", problem.Message)
}
if !errors.Is(got, err) {
t.Fatal("mapped error should preserve the original typed error as cause")
}
originalProblem, _ := errs.ProblemOf(err)
if originalProblem.Message != "upstream permission denied" {
t.Fatalf("original message was mutated to %q", originalProblem.Message)
}
var gotPerm *errs.PermissionError
if !errors.As(got, &gotPerm) {
t.Fatalf("mapped error = %T, want PermissionError", got)
}
if gotPerm.LogID != "log_1" {
t.Fatalf("LogID = %q, want preserved log_1", gotPerm.LogID)
}
if len(gotPerm.MissingScopes) != 1 || gotPerm.MissingScopes[0] != "vc:note:read" {
t.Fatalf("MissingScopes = %#v, want preserved vc:note:read", gotPerm.MissingScopes)
}
if gotPerm.Identity != "user" {
t.Fatalf("Identity = %q, want preserved user", gotPerm.Identity)
}
}
func TestMapNoteError_NormalizesNonPermissionTypedError(t *testing.T) {
err := &errs.APIError{
Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: NoNoteReadPermissionCode,
Message: "upstream api error",
LogID: "log_2",
},
}
got := mapNoteError(err)
var gotPerm *errs.PermissionError
if !errors.As(got, &gotPerm) {
t.Fatalf("mapped error = %T, want PermissionError", got)
}
if gotPerm.Category != errs.CategoryAuthorization || gotPerm.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("mapped category/subtype = %q/%q, want authorization/permission_denied", gotPerm.Category, gotPerm.Subtype)
}
if !strings.Contains(gotPerm.Message, "no read permission for this note") || !strings.Contains(gotPerm.Message, "upstream api error") {
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", gotPerm.Message)
}
if gotPerm.Hint == "" {
t.Fatal("mapped hint should not be empty")
}
if gotPerm.LogID != "log_2" {
t.Fatalf("LogID = %q, want preserved log_2", gotPerm.LogID)
}
if !errors.Is(got, err) {
t.Fatal("mapped error should preserve the original typed error as cause")
}
}
func TestMapNoteError_Passthrough(t *testing.T) {
err := errors.New("boom")
if got := mapNoteError(err); got != err {
t.Fatalf("mapNoteError passthrough = %v, want original", got)
}
}
func TestNoteDetailEmptyDetailPreservesSentinelCause(t *testing.T) {
factory, stdout, _, reg := noteShortcutTestFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/notes/note_empty_detail",
Body: map[string]any{
"code": 0,
"data": map[string]any{},
},
})
err := runNoteShortcut(t, NoteDetail, []string{"+detail", "--note-id", "note_empty_detail", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("expected empty detail to fail")
}
if !errors.Is(err, ErrEmptyDetail) {
t.Fatalf("errors.Is(ErrEmptyDetail) = false for %T: %v", err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
}
func TestShortcuts(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 2 {
t.Fatalf("Shortcuts len = %d, want 2", len(shortcuts))
}
if shortcuts[0].Command != "+detail" || shortcuts[1].Command != "+transcript" {
t.Fatalf("Shortcuts commands = %q, %q", shortcuts[0].Command, shortcuts[1].Command)
}
}
func valuesEqual(a, b any) bool {
ab, _ := json.Marshal(a)
bb, _ := json.Marshal(b)
return string(ab) == string(bb)
}

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// note +transcript — fetch the unified note transcript by a
// known note_id. The API is paginated; the CLI walks all pages internally,
// concatenates the content and saves the whole transcript to a local file.
package note
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
transcriptFormatMarkdown = "markdown"
transcriptFormatPlainText = "plain_text"
logPrefix = "[note +transcript]"
// maxTranscriptPages bounds the pagination loop so a misbehaving has_more
// can never spin forever. transcriptPageSize reduces round trips; full
// transcript correctness still depends on has_more/cursor pagination.
maxTranscriptPages = 500
transcriptPageSize = 200
// pageDelay throttles successive page requests to stay gentle on the
// downstream, matching the batch cadence used by `vc +notes`.
pageDelay = 100 * time.Millisecond
// noteArtifactSubdir is the default top-level directory for note-scoped
// artifacts (parallel to the "minutes" layout used by minute artifacts).
noteArtifactSubdir = "notes"
)
// NoteTranscript fetches the full unified transcript and saves it to a file.
var NoteTranscript = common.Shortcut{
Service: "note",
Command: "+transcript",
Description: "Fetch the unified note transcript and save it to a file",
Risk: "read",
Scopes: []string{"vc:note:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "note-id", Desc: "note ID", Required: true},
{Name: "transcript-format", Desc: "transcript content format", Default: transcriptFormatMarkdown, Enum: []string{transcriptFormatMarkdown, transcriptFormatPlainText}},
{Name: "locale", Desc: "transcript locale, e.g. zh_cn, en_us, ja_jp (default follows profile language or brand)"},
{Name: "output", Desc: "output file path (default: ./notes/{note_id}/unified_transcript.{md,txt})"},
{Name: "overwrite", Type: "bool", Desc: "overwrite an existing output file"},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
if noteID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
}
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
}
if out := strings.TrimSpace(runtime.Str("output")); out != "" {
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
return err
}
}
return nil
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
noteID := strings.TrimSpace(runtime.Str("note-id"))
transcriptFormat := runtime.Str("transcript-format")
locale := resolveTranscriptLocale(runtime)
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))).
Desc("[1] Check note_display_type and verbatim_doc_token before transcript fetch").
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))).
Desc("[2] Fetch unified note transcript pages; subsequent pages add cursor_id internally").
Params(map[string]interface{}{
"format": transcriptFormat,
"page_size": transcriptPageSize,
"locale": locale,
}).
Set("transcript_format", transcriptFormat).
Set("locale", locale).
Set("note", "CLI first checks note_display_type via note detail, then paginates internally (cursor_id) and saves the full unified transcript to a file")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
noteID := strings.TrimSpace(runtime.Str("note-id"))
transcriptFormat := runtime.Str("transcript-format")
locale := resolveTranscriptLocale(runtime)
outPath := strings.TrimSpace(runtime.Str("output"))
if outPath == "" {
outPath = defaultTranscriptPath(noteID, transcriptFormat)
}
if !runtime.Bool("overwrite") {
if _, statErr := runtime.FileIO().Stat(outPath); statErr == nil {
precondition := errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s", outPath).
WithHint("Pass --overwrite to replace the existing file")
if strings.TrimSpace(runtime.Str("output")) != "" {
precondition = precondition.WithParam("--output")
}
return precondition
}
}
if err := ensureUnifiedNote(ctx, runtime, noteID); err != nil {
return err
}
content, err := fetchUnifiedTranscript(ctx, runtime, noteID, transcriptFormat, locale)
if err != nil {
return err
}
saved, err := runtime.FileIO().Save(outPath, fileio.SaveOptions{}, bytes.NewReader(content))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
resolved, rerr := runtime.FileIO().ResolvePath(outPath)
if rerr != nil || resolved == "" {
resolved = outPath
}
runtime.OutFormat(map[string]any{
"note_id": noteID,
"transcript_format": transcriptFormat,
"transcript_file": resolved,
"size_bytes": saved.Size(),
}, nil, nil)
return nil
},
}
func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, noteID string) error {
detail, err := FetchDetail(ctx, runtime, noteID)
if err != nil {
return mapNoteError(err)
}
if detail.DisplayType != "unified" {
if detail.VerbatimDocToken != "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
WithHint("Use note +detail to inspect document tokens")
}
return nil
}
// fetchUnifiedTranscript walks every page of the unified transcript and returns
// the concatenated content. Any page error fails the whole call: a partial
// transcript is misleading, so we prefer an explicit error over silent loss.
func fetchUnifiedTranscript(ctx context.Context, runtime *common.RuntimeContext, noteID, transcriptFormat, locale string) ([]byte, error) {
errOut := runtime.IO().ErrOut
apiPath := fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))
var buf bytes.Buffer
var cursor string
seenCursors := map[string]bool{}
for page := 1; ; page++ {
if err := ctx.Err(); err != nil {
return nil, transcriptContextError(err)
}
if page > maxTranscriptPages {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript exceeded %d pages; aborting to avoid an unbounded loop", maxTranscriptPages)
}
query := larkcore.QueryParams{
"format": []string{transcriptFormat},
"locale": []string{locale},
"page_size": []string{strconv.Itoa(transcriptPageSize)},
}
if cursor != "" {
query["cursor_id"] = []string{cursor}
}
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiPath, query, nil)
if err != nil {
return nil, mapNoteError(err)
}
if transcript, _ := data["transcript"].(map[string]any); transcript != nil {
if chunk, _ := transcript[transcriptFormat].(string); chunk != "" {
buf.WriteString(chunk)
}
}
hasMore, _ := data["has_more"].(bool)
if !hasMore {
break
}
next, ok := parseLooseCursorID(data["next_cursor_id"])
if !ok || next == cursor || seenCursors[next] {
fmt.Fprintf(errOut, "%s has_more set but cursor did not advance at page %d\n", logPrefix, page)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript pagination cursor did not advance at page %d; aborting to avoid saving a partial transcript", page)
}
seenCursors[cursor] = true
cursor = next
timer := time.NewTimer(pageDelay)
select {
case <-ctx.Done():
timer.Stop()
return nil, transcriptContextError(ctx.Err())
case <-timer.C:
}
}
if buf.Len() == 0 {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript is empty for note %s in %s format; aborting to avoid saving an empty transcript", noteID, transcriptFormat)
}
return buf.Bytes(), nil
}
func transcriptContextError(err error) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
}
return errs.NewNetworkError(subtype, "transcript fetch interrupted: %s", err).WithCause(err)
}
// defaultTranscriptPath builds the default save path for a note transcript.
func defaultTranscriptPath(noteID, transcriptFormat string) string {
name := "unified_transcript.md"
if transcriptFormat == transcriptFormatPlainText {
name = "unified_transcript.txt"
}
return filepath.Join(noteArtifactSubdir, noteID, name)
}
func resolveTranscriptLocale(runtime *common.RuntimeContext) string {
if explicit := strings.TrimSpace(runtime.Str("locale")); explicit != "" {
return explicit
}
if lang := runtime.Lang(); lang != "" {
return string(lang)
}
if runtime.Config.Brand == core.BrandLark {
return string(i18n.LangEnUS)
}
return string(i18n.LangZhCN)
}

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