Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
Expand Down
1 change: 1 addition & 0 deletions errs/subtypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const (
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
)
Expand Down
1 change: 1 addition & 0 deletions lint/errscontract/rule_no_legacy_common_helper_call.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
Expand Down
1 change: 1 addition & 0 deletions lint/errscontract/rule_no_legacy_envelope_literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
Expand Down
3 changes: 1 addition & 2 deletions shortcuts/apps/apps_access_scope_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"io"
"strings"

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

Check warning on line 34 in shortcuts/apps/apps_access_scope_get.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_access_scope_get.go#L34

Added line #L34 was not covered by tests
}
return nil
},
Expand Down
39 changes: 22 additions & 17 deletions shortcuts/apps/apps_access_scope_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"io"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
Expand Down Expand Up @@ -45,7 +44,7 @@
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")

Check warning on line 47 in shortcuts/apps/apps_access_scope_set.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_access_scope_set.go#L47

Added line #L47 was not covered by tests
}
return validateAccessScopeFlags(rctx)
},
Expand Down Expand Up @@ -90,55 +89,61 @@
switch scope {
case "specific":
if targets == "" {
return output.ErrValidation("--targets is required when --scope=specific")
return appsValidationParamError("--targets", "--targets is required when --scope=specific")
}
if err := validateTargetsJSON(targets); err != nil {
return err
}
if approver != "" && !applyEnabled {
return output.ErrValidation("--approver requires --apply-enabled")
return appsValidationParamError("--approver", "--approver requires --apply-enabled")
}
if requireLogin {
return output.ErrValidation("--require-login is not allowed when --scope=specific")
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific")

Check warning on line 101 in shortcuts/apps/apps_access_scope_set.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_access_scope_set.go#L101

Added line #L101 was not covered by tests
}
case "public":
if targets != "" {
return output.ErrValidation("--targets is not allowed when --scope=public")
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public")

Check warning on line 105 in shortcuts/apps/apps_access_scope_set.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_access_scope_set.go#L105

Added line #L105 was not covered by tests
}
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")

Check warning on line 108 in shortcuts/apps/apps_access_scope_set.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_access_scope_set.go#L108

Added line #L108 was not covered by tests
}
if approver != "" {
return output.ErrValidation("--approver is not allowed when --scope=public")
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public")
}
if !rctx.Cmd.Flags().Changed("require-login") {
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
}
case "tenant":
if targets != "" || applyEnabled || approver != "" || requireLogin {
return output.ErrValidation("no extra flags allowed when --scope=tenant")
return appsValidationError("no extra flags allowed when --scope=tenant").
WithParams(
appsInvalidParam("--targets", "not allowed when --scope=tenant"),
appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"),
appsInvalidParam("--approver", "not allowed when --scope=tenant"),
appsInvalidParam("--require-login", "not allowed when --scope=tenant"),
)
}
default:
return output.ErrValidation("--scope must be specific / public / tenant")
return appsValidationParamError("--scope", "--scope must be specific / public / tenant")

Check warning on line 127 in shortcuts/apps/apps_access_scope_set.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_access_scope_set.go#L127

Added line #L127 was not covered by tests
}
return nil
}

func validateTargetsJSON(targetsJSON string) error {
var items []map[string]interface{}
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
return output.ErrValidation("--targets is not valid JSON: %v", err)
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
}
if len(items) == 0 {
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
}
for i, t := range items {
typ, _ := t["type"].(string)
if !allowedAccessTargetTypes[typ] {
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ)
}
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
return output.ErrValidation("--targets[%d].id is empty", i)
return appsValidationParamError("--targets", "--targets[%d].id is empty", i)
}
}
return nil
Expand All @@ -157,7 +162,7 @@
scope := rctx.Str("scope")
enum, ok := scopeStringToServerEnum[scope]
if !ok {
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope)
}
body := map[string]interface{}{"scope": enum}

Expand All @@ -166,7 +171,7 @@
// 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。
var targets []map[string]interface{}
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
}
users, departments, chats := splitAccessScopeTargets(targets)
if len(users) > 0 {
Expand Down
7 changes: 3 additions & 4 deletions shortcuts/apps/apps_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"io"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)

Expand Down Expand Up @@ -43,14 +42,14 @@
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
return appsValidationParamError("--session-id", "--session-id is required")

Check warning on line 48 in shortcuts/apps/apps_chat.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_chat.go#L48

Added line #L48 was not covered by tests
}
// Do not echo --message content in the error (spec §4 redaction).
if strings.TrimSpace(rctx.Str("message")) == "" {
return output.ErrValidation("--message is required")
return appsValidationParamError("--message", "--message is required")
}
return nil
},
Expand Down
3 changes: 1 addition & 2 deletions shortcuts/apps/apps_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"io"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)

Expand All @@ -36,7 +35,7 @@
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" {
return output.ErrValidation("--name is required")
return appsValidationParamError("--name", "--name is required")

Check warning on line 38 in shortcuts/apps/apps_create.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_create.go#L38

Added line #L38 was not covered by tests
}
return nil
},
Expand Down
26 changes: 26 additions & 0 deletions shortcuts/apps/apps_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/spf13/cobra"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
Expand Down Expand Up @@ -47,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
return parent.ExecuteContext(context.Background())
}

func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem {
t.Helper()
if err == nil {
t.Fatalf("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != category {
t.Fatalf("error category = %q, want %q", p.Category, category)
}
return p
}

func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem {
t.Helper()
return requireAppsProblem(t, err, errs.CategoryValidation)
}

func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem {
t.Helper()
return requireAppsProblem(t, err, errs.CategoryAPI)
}

// +create 测试

func TestAppsCreate_Success(t *testing.T) {
Expand Down
60 changes: 34 additions & 26 deletions shortcuts/apps/apps_db_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
//
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后升级成 typed api_error(exit 非 0、detail 带 statement_index / completed / rolled_back),
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
// 落地,故 rolled_back=false(真机 boe 实证)。
//
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
Expand Down Expand Up @@ -68,19 +69,27 @@
sql := strings.TrimSpace(rctx.Str("sql"))
file := strings.TrimSpace(rctx.Str("file"))
if sql != "" && file != "" {
return output.ErrValidation("--sql and --file are mutually exclusive")
return appsValidationError("--sql and --file are mutually exclusive").
WithParams(
appsInvalidParam("--sql", "mutually exclusive with --file"),
appsInvalidParam("--file", "mutually exclusive with --sql"),
)
}
if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return output.ErrValidation("--file: %v", err)
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)

Check warning on line 81 in shortcuts/apps/apps_db_execute.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/apps_db_execute.go#L81

Added line #L81 was not covered by tests
}
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
rctx.Cmd.Flags().Set("sql", string(data))
sql = strings.TrimSpace(string(data))
}
if sql == "" {
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
WithParams(
appsInvalidParam("--sql", "one of --sql or --file is required"),
appsInvalidParam("--file", "one of --sql or --file is required"),
)
}
return nil
},
Expand Down Expand Up @@ -113,13 +122,15 @@
data := map[string]interface{}{"results": stmts}

// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
if rctx.Format == "pretty" {
renderSQLPretty(rctx.IO().Out, stmts)
return output.PartialFailure(output.ExitAPI)
}
return sqlStatementError(stmts, errIdx, errStmt)
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
}

rctx.OutFormat(data, nil, func(w io.Writer) {
Expand All @@ -140,31 +151,28 @@
return 0, nil, false
}

// sqlStatementError 把 ERROR 哨兵升级成 typed api_error
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据
//
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,hint 提示用户
// 别整批重跑(否则会重复写入)。
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在
// 失败位置),note 提示用户别整批重跑(否则会重复写入)。
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))

apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
note := "no statements were applied; fix the SQL and re-run."
if errIdx > 0 {
note = fmt.Sprintf(
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
errIdx, stmtNo)
}
return map[string]interface{}{
"results": stmts,
"statement_index": errIdx,
"completed": stmts[:errIdx],
"error_code": code,
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
"rolled_back": false,
})
if apiErr.Detail != nil {
if errIdx > 0 {
apiErr.Detail.Hint = fmt.Sprintf(
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
errIdx, stmtNo)
} else {
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
}
"note": note,
}
return apiErr
}

// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
Expand Down
Loading
Loading