mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
14 Commits
fix/skill-
...
docs/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6568abcff4 | ||
|
|
f4e7abc33e | ||
|
|
748c9aaa1e | ||
|
|
7193ca575c | ||
|
|
cff1f28316 | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/internal/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -17,6 +17,7 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
@@ -1106,6 +1130,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
|
||||
9
Makefile
9
Makefile
@@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
@@ -34,7 +41,7 @@ fmt-check:
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
|
||||
@@ -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.
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
|
||||
@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -15,7 +15,8 @@
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
|
||||
@@ -33,6 +33,7 @@ build_target darwin arm64
|
||||
build_target linux amd64
|
||||
build_target darwin amd64
|
||||
build_target linux arm64
|
||||
build_target linux riscv64
|
||||
build_target windows amd64
|
||||
build_target windows arm64
|
||||
|
||||
@@ -55,6 +56,7 @@ const platformMap = {
|
||||
const archMap = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
|
||||
@@ -30,6 +30,7 @@ const PLATFORM_MAP = {
|
||||
const ARCH_MAP = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,7 +31,7 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -45,7 +44,7 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validateAccessScopeFlags(rctx)
|
||||
},
|
||||
@@ -90,36 +89,42 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
switch scope {
|
||||
case "specific":
|
||||
if targets == "" {
|
||||
return output.ErrValidation("--targets is required when --scope=specific")
|
||||
return appsValidationParamError("--targets", "--targets is required when --scope=specific")
|
||||
}
|
||||
if err := validateTargetsJSON(targets); err != nil {
|
||||
return err
|
||||
}
|
||||
if approver != "" && !applyEnabled {
|
||||
return output.ErrValidation("--approver requires --apply-enabled")
|
||||
return appsValidationParamError("--approver", "--approver requires --apply-enabled")
|
||||
}
|
||||
if requireLogin {
|
||||
return output.ErrValidation("--require-login is not allowed when --scope=specific")
|
||||
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific")
|
||||
}
|
||||
case "public":
|
||||
if targets != "" {
|
||||
return output.ErrValidation("--targets is not allowed when --scope=public")
|
||||
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public")
|
||||
}
|
||||
if applyEnabled {
|
||||
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
|
||||
return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public")
|
||||
}
|
||||
if approver != "" {
|
||||
return output.ErrValidation("--approver is not allowed when --scope=public")
|
||||
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public")
|
||||
}
|
||||
if !rctx.Cmd.Flags().Changed("require-login") {
|
||||
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
}
|
||||
case "tenant":
|
||||
if targets != "" || applyEnabled || approver != "" || requireLogin {
|
||||
return output.ErrValidation("no extra flags allowed when --scope=tenant")
|
||||
return appsValidationError("no extra flags allowed when --scope=tenant").
|
||||
WithParams(
|
||||
appsInvalidParam("--targets", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--approver", "not allowed when --scope=tenant"),
|
||||
appsInvalidParam("--require-login", "not allowed when --scope=tenant"),
|
||||
)
|
||||
}
|
||||
default:
|
||||
return output.ErrValidation("--scope must be specific / public / tenant")
|
||||
return appsValidationParamError("--scope", "--scope must be specific / public / tenant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,18 +132,18 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
func validateTargetsJSON(targetsJSON string) error {
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
|
||||
return output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
}
|
||||
for i, t := range items {
|
||||
typ, _ := t["type"].(string)
|
||||
if !allowedAccessTargetTypes[typ] {
|
||||
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
}
|
||||
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
|
||||
return output.ErrValidation("--targets[%d].id is empty", i)
|
||||
return appsValidationParamError("--targets", "--targets[%d].id is empty", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -157,7 +162,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
|
||||
scope := rctx.Str("scope")
|
||||
enum, ok := scopeStringToServerEnum[scope]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
|
||||
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope)
|
||||
}
|
||||
body := map[string]interface{}{"scope": enum}
|
||||
|
||||
@@ -166,7 +171,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
|
||||
// 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。
|
||||
var targets []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
|
||||
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
users, departments, chats := splitAccessScopeTargets(targets)
|
||||
if len(users) > 0 {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -43,14 +42,14 @@ var AppsChat = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
// Do not echo --message content in the error (spec §4 redaction).
|
||||
if strings.TrimSpace(rctx.Str("message")) == "" {
|
||||
return output.ErrValidation("--message is required")
|
||||
return appsValidationParamError("--message", "--message is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -36,7 +35,7 @@ var AppsCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -47,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category {
|
||||
t.Fatalf("error category = %q, want %q", p.Category, category)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem {
|
||||
t.Helper()
|
||||
return requireAppsProblem(t, err, errs.CategoryValidation)
|
||||
}
|
||||
|
||||
func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem {
|
||||
t.Helper()
|
||||
return requireAppsProblem(t, err, errs.CategoryAPI)
|
||||
}
|
||||
|
||||
// +create 测试
|
||||
|
||||
func TestAppsCreate_Success(t *testing.T) {
|
||||
|
||||
@@ -31,8 +31,9 @@ import (
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后升级成 typed api_error(exit 非 0、detail 带 statement_index / completed / rolled_back),
|
||||
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
|
||||
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
|
||||
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
//
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
@@ -68,19 +69,27 @@ var AppsDBExecute = common.Shortcut{
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return output.ErrValidation("--sql and --file are mutually exclusive")
|
||||
return appsValidationError("--sql and --file are mutually exclusive").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "mutually exclusive with --file"),
|
||||
appsInvalidParam("--file", "mutually exclusive with --sql"),
|
||||
)
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--file: %v", err)
|
||||
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
|
||||
}
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
|
||||
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "one of --sql or --file is required"),
|
||||
appsInvalidParam("--file", "one of --sql or --file is required"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -113,13 +122,15 @@ var AppsDBExecute = common.Shortcut{
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
|
||||
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
@@ -140,31 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error。
|
||||
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。
|
||||
//
|
||||
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,hint 提示用户
|
||||
// 别整批重跑(否则会重复写入)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在
|
||||
// 失败位置),note 提示用户别整批重跑(否则会重复写入)。
|
||||
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
|
||||
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
|
||||
stmtNo := errIdx + 1 // 1-based 给人看
|
||||
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
|
||||
|
||||
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
|
||||
"statement_index": errIdx,
|
||||
"completed": stmts[:errIdx],
|
||||
"rolled_back": false,
|
||||
})
|
||||
if apiErr.Detail != nil {
|
||||
if errIdx > 0 {
|
||||
apiErr.Detail.Hint = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
} else {
|
||||
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
|
||||
}
|
||||
note := "no statements were applied; fix the SQL and re-run."
|
||||
if errIdx > 0 {
|
||||
note = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"results": stmts,
|
||||
"statement_index": errIdx,
|
||||
"error_code": code,
|
||||
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
|
||||
"rolled_back": false,
|
||||
"note": note,
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
|
||||
@@ -495,9 +495,9 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitError(type=api_error、非零 exit),
|
||||
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
|
||||
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout,
|
||||
// 退出信号是 PartialFailureError(非零 exit)。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -518,45 +518,64 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail.Code != 1300002 {
|
||||
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
if got := payload["statement_index"]; got != float64(1) {
|
||||
t.Errorf("statement_index = %v, want 1", got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
|
||||
if got := payload["error_code"]; got != float64(1300002) {
|
||||
t.Errorf("error_code = %v, want 1300002", got)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 2 of 2)") {
|
||||
t.Errorf("error_message missing statement locator: %q", msg)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
|
||||
if got := payload["rolled_back"]; got != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
|
||||
}
|
||||
if detail["statement_index"] != 1 {
|
||||
t.Errorf("statement_index = %v, want 1", detail["statement_index"])
|
||||
results, _ := payload["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
|
||||
}
|
||||
if detail["rolled_back"] != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
|
||||
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "already applied") {
|
||||
t.Errorf("note should warn prior statements persisted, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope,返回 data 块。
|
||||
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("envelope.ok = true, want false on partial failure")
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -573,21 +592,23 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 1 of 1)") {
|
||||
t.Errorf("error_message missing locator: %q", msg)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["statement_index"] != 0 {
|
||||
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
|
||||
if got := payload["statement_index"]; got != float64(0) {
|
||||
t.Errorf("statement_index = %v, want 0", got)
|
||||
}
|
||||
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
|
||||
t.Errorf("completed = %v, want empty", detail["completed"])
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "no statements were applied") {
|
||||
t.Errorf("note should say nothing was applied, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
|
||||
// contract on a statement failure: stdout carries only the per-statement
|
||||
// human summary (no JSON envelope stacked after it), and the command still
|
||||
// exits non-zero via the partial-failure signal.
|
||||
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "✗") {
|
||||
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"ok"`) {
|
||||
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -45,7 +44,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return output.ErrValidation("--table is required")
|
||||
return appsValidationParamError("--table", "--table is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -47,11 +47,11 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
@@ -71,7 +71,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
if err != nil {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
@@ -120,7 +120,7 @@ func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
|
||||
}
|
||||
projectPath = cwd
|
||||
}
|
||||
@@ -137,13 +137,13 @@ func checkEnvPullTarget(envFile string) error {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
|
||||
return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
|
||||
return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
|
||||
return appsValidationParamError("--project-path", "target %s must be a regular file", envFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
@@ -203,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
return out, info, skippedKeys, nil
|
||||
default:
|
||||
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,3 +1079,28 @@ func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) {
|
||||
t.Error("MkdirAll over a file component must surface an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse pins that a
|
||||
// response without a usable env_vars field classifies as
|
||||
// internal/invalid_response — a broken upstream payload, not a flag problem
|
||||
// the agent should retry with different arguments.
|
||||
func TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse(t *testing.T) {
|
||||
for name, data := range map[string]map[string]interface{}{
|
||||
"missing": {},
|
||||
"wrong type": {"env_vars": "not-an-object"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, _, err := extractEnvPullVars(data)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %s env_vars", name)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
104
shortcuts/apps/apps_errors.go
Normal file
104
shortcuts/apps/apps_errors.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
func appsValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func appsValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return appsValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func appsInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
|
||||
func appsFailedPreconditionParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func appsFailedPreconditionError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
|
||||
}
|
||||
|
||||
// appsStorageError classifies a local credential/state storage failure
|
||||
// (read, write, delete, list) as internal/storage.
|
||||
func appsStorageError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsExternalToolError classifies a runtime failure of an external tool the
|
||||
// CLI shells out to (git, npx) as internal/external_tool. The tool output is
|
||||
// carried in the message; recovery guidance goes in the hint.
|
||||
func appsExternalToolError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
|
||||
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
|
||||
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
func appsInputPathError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return appsValidationParamError("--path", "unsafe --path: %s", err).WithCause(err)
|
||||
}
|
||||
return appsValidationParamError("--path", "cannot read --path: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func appsInputPathEntryError(path string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return appsValidationParamError("--path", "unsafe --path entry %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
return appsValidationParamError("--path", "cannot read --path entry %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
|
||||
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
|
||||
// endpoint: refines endpoint-scoped business codes, prefixes the message with
|
||||
// command context, and attaches endpoint-specific recovery hints. A
|
||||
// still-untyped error is lifted at the SDK boundary instead.
|
||||
func enrichHTMLPublishAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return client.WrapDoAPIError(err)
|
||||
}
|
||||
// The HTML publish business codes (90001/90002) are scoped to this
|
||||
// endpoint, not service-global, so their subtype classification lives
|
||||
// here instead of the global errclass code table. Only an
|
||||
// otherwise-unclassified API error is refined; a stronger upstream
|
||||
// classification is never overridden.
|
||||
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
}
|
||||
if p.Message != "" {
|
||||
p.Message = "html-publish failed: " + p.Message
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
113
shortcuts/apps/apps_errors_test.go
Normal file
113
shortcuts/apps/apps_errors_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
func TestAppsInputPathError_ClassifiesPathValidation(t *testing.T) {
|
||||
cause := errors.New("path escapes working directory")
|
||||
err := appsInputPathError(&fileio.PathValidationError{Err: cause})
|
||||
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "unsafe --path") {
|
||||
t.Fatalf("message = %q, want unsafe --path context", problem.Message)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--path" {
|
||||
t.Fatalf("param = %q, want --path", validationErr.Param)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) || !errors.Is(err, cause) {
|
||||
t.Fatalf("path validation cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInputPathEntryError_ClassifiesReadFailure(t *testing.T) {
|
||||
cause := errors.New("permission denied")
|
||||
err := appsInputPathEntryError("dist/index.html", cause)
|
||||
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "cannot read --path entry dist/index.html") {
|
||||
t.Fatalf("message = %q, want entry read context", problem.Message)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
|
||||
cause := errors.New("archive writer failed")
|
||||
err := appsFileIOError(cause, "pack failed: %v", cause)
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
|
||||
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
|
||||
WithCode(errCodeBuildFailed).
|
||||
WithLogID("logid-build-failed")
|
||||
|
||||
got := enrichHTMLPublishAPIError(err)
|
||||
if got != err {
|
||||
t.Fatalf("typed error should be enriched in place")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, got)
|
||||
if problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.LogID != "logid-build-failed" {
|
||||
t.Fatalf("log_id = %q, want preserved", problem.LogID)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "html-publish failed") {
|
||||
t.Fatalf("message = %q, want html-publish context", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected known-code recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -35,11 +35,11 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return output.ErrValidation("--path is required")
|
||||
return appsValidationParamError("--path", "--path is required")
|
||||
}
|
||||
// Block well-known credential files in the publish payload unless the
|
||||
// caller explicitly opts in. Lives in Validate (not DryRun) so that
|
||||
@@ -150,9 +150,9 @@ func sensitiveCandidatesError(hits []string) error {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample),
|
||||
"remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
|
||||
@@ -178,15 +178,14 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "validation",
|
||||
"--path 中缺少 index.html",
|
||||
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html")
|
||||
return appsFailedPreconditionParamError("--path", "--path is missing index.html").
|
||||
WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
|
||||
}
|
||||
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
@@ -196,24 +195,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPubli
|
||||
rawTotal += c.Size
|
||||
}
|
||||
if rawTotal > maxHTMLPublishRawBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
|
||||
"在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录")
|
||||
return nil, appsValidationParamError("--path",
|
||||
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
|
||||
WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
|
||||
}
|
||||
tarball, err := buildHTMLPublishTarball(fio, candidates)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
|
||||
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
|
||||
return nil, appsValidationParamError("--path",
|
||||
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
|
||||
WithHint("reduce --path contents, remove unrelated large files, then retry")
|
||||
}
|
||||
|
||||
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
@@ -105,17 +103,11 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
@@ -153,10 +145,7 @@ func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("expected ExitError type=validation, got %v", err)
|
||||
}
|
||||
requireAppsValidationProblem(t, err)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
@@ -199,17 +188,11 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
@@ -337,18 +320,12 @@ func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("dry-run with sensitive file should fail")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, ".env") {
|
||||
t.Fatalf("error message should list the offending file, got %q", problem.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, ".env") {
|
||||
t.Fatalf("error message should list the offending file, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--allow-sensitive") {
|
||||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(problem.Hint, "--allow-sensitive") {
|
||||
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,15 +415,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tc.wantSubstr) {
|
||||
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, tc.wantSubstr) {
|
||||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -480,15 +451,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "credentials") {
|
||||
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "credentials") {
|
||||
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,11 +463,7 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
|
||||
func TestSensitiveCandidatesError_Truncation(t *testing.T) {
|
||||
hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"}
|
||||
err := sensitiveCandidatesError(hits)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
msg := exitErr.Detail.Message
|
||||
msg := requireAppsValidationProblem(t, err).Message
|
||||
if !strings.Contains(msg, "7 credential file(s)") {
|
||||
t.Fatalf("message should report the full count, got %q", msg)
|
||||
}
|
||||
@@ -534,15 +495,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -72,14 +72,14 @@ var AppsInit = common.Shortcut{
|
||||
// exit-1 (root.go handleRootError case 4), bypassing the structured
|
||||
// envelope. The spec and the E2E assert exit-2 + a structured
|
||||
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
|
||||
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
|
||||
// check lives in Validate (typed validation error -> exit 2).
|
||||
{Name: "app-id", Desc: "Miaoda app ID"},
|
||||
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
|
||||
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -152,14 +152,14 @@ func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error
|
||||
// path is a log-injection vector); charcheck additionally rejects dangerous
|
||||
// Unicode (bidi overrides, zero-width) that IsControl does not.
|
||||
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
|
||||
return "", output.ErrValidation("--dir must not contain control characters")
|
||||
return "", appsValidationParamError("--dir", "--dir must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
return "", appsValidationParamError("--dir", "%v", err).WithCause(err)
|
||||
}
|
||||
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
|
||||
return "", appsValidationParamError("--dir", "--dir cannot be resolved: %v", err)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -173,20 +173,20 @@ func ensureEmptyDir(dir string) error {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return output.ErrValidation("--dir must not be a symlink: %q", dir)
|
||||
return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
|
||||
return appsValidationParamError("--dir", "--dir exists and is not a directory: %q", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
|
||||
if err != nil {
|
||||
return output.ErrValidation("--dir cannot be read: %v", err)
|
||||
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return output.ErrValidation("target directory %q already exists and is not empty", dir)
|
||||
return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -209,11 +209,11 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
|
||||
return nil
|
||||
@@ -224,10 +224,10 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
m["app_id"] = appID
|
||||
out, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
|
||||
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
|
||||
return appsFileIOError(err, "write %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func hasSteeringSkills(dir string) bool {
|
||||
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
|
||||
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
|
||||
if err != nil {
|
||||
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
|
||||
return false, appsExternalToolError(err, "git ls-files failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
f := strings.TrimSpace(line)
|
||||
@@ -274,19 +274,19 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
|
||||
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
|
||||
// appear, extend isEmptyRepo's allow-list accordingly.
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
}
|
||||
return scaffoldKindUpgrade, nil
|
||||
@@ -303,13 +303,13 @@ func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
@@ -324,13 +324,13 @@ func parseEnvFileFromEnvelope(stdout string) (string, error) {
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+env-pull reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.EnvFile) == "" {
|
||||
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
|
||||
return "", appsSubprocessEnvelopeError("+env-pull returned no env_file")
|
||||
}
|
||||
return env.Data.EnvFile, nil
|
||||
}
|
||||
@@ -364,7 +364,9 @@ func validateRepoURLScheme(repoURL string) error {
|
||||
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
// The URL comes from the +git-credential-init subprocess response, not user
|
||||
// input, so a non-http(s) scheme is a broken upstream contract.
|
||||
return appsSubprocessEnvelopeError(
|
||||
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
|
||||
}
|
||||
|
||||
@@ -415,12 +417,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"git executable not found on PATH", "install git and ensure it is on your PATH")
|
||||
return appsFailedPreconditionError("git executable not found on PATH").
|
||||
WithHint("install git and ensure it is on your PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "dependency",
|
||||
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
return appsFailedPreconditionError("npx executable not found on PATH").
|
||||
WithHint("install Node.js (which provides npx) and ensure it is on your PATH")
|
||||
}
|
||||
|
||||
if err := ensureEmptyDir(dir); err != nil {
|
||||
@@ -438,11 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
|
||||
@@ -536,7 +538,7 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
@@ -544,9 +546,9 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
|
||||
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
|
||||
"ensure apps +git-credential-init is available and you are logged in")
|
||||
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
WithHint("ensure apps +git-credential-init is available and you are logged in").
|
||||
WithCause(err)
|
||||
}
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
@@ -560,7 +562,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
|
||||
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
|
||||
if runErr != nil {
|
||||
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
|
||||
return false, false, appsExternalToolError(runErr, "git status failed: %s", gitErr(stderr, runErr))
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
return false, false, nil
|
||||
@@ -595,7 +597,7 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
|
||||
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
|
||||
return true, false, withAppsHint(
|
||||
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
|
||||
appsExternalToolError(e, "git push failed: %s", gitErr(se, e)),
|
||||
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
|
||||
}
|
||||
return true, true, nil
|
||||
@@ -609,10 +611,10 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
|
||||
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
|
||||
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
|
||||
return appsExternalToolError(e, "git add failed: %s", gitErr(se, e))
|
||||
}
|
||||
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
|
||||
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
|
||||
return appsExternalToolError(e, "git commit failed: %s", gitErr(se, e))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1466,3 +1467,28 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
t.Errorf("Description should mention app code: %q", AppsInit.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||||
cause := errors.New("exit status 128")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from failing git subprocess")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeExternalTool {
|
||||
t.Fatalf("classification = %s/%s, want internal/external_tool", p.Category, p.Subtype)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,7 +32,7 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,10 +31,10 @@ var AppsReleaseGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return output.ErrValidation("--release-id is required")
|
||||
return appsValidationParamError("--release-id", "--release-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsReleaseList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -31,7 +30,7 @@ var AppsSessionCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,10 +32,10 @@ var AppsSessionGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ var AppsSessionList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -35,13 +34,13 @@ var AppsSessionStop = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--session-id is required")
|
||||
return appsValidationParamError("--session-id", "--session-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
|
||||
return output.ErrValidation("--turn-id is required")
|
||||
return appsValidationParamError("--turn-id", "--turn-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -34,11 +33,15 @@ var AppsUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
body := buildAppsUpdateBody(rctx)
|
||||
if len(body) == 0 {
|
||||
return output.ErrValidation("provide at least one of --name or --description")
|
||||
return appsValidationError("provide at least one of --name or --description").
|
||||
WithParams(
|
||||
appsInvalidParam("--name", "provide at least one of --name or --description"),
|
||||
appsInvalidParam("--description", "provide at least one of --name or --description"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||||
@@ -23,11 +21,11 @@ const apiBasePath = "/open-apis/spark/v1"
|
||||
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
|
||||
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
|
||||
|
||||
// withAppsHint attaches an actionable next-step hint to a failure returned by
|
||||
// CallAPI, preserving its original classification (typed subtype/code/log_id or
|
||||
// legacy detail). A hint already present on the error is kept (the upstream
|
||||
// wording wins); only an empty hint is filled in. Mirrors
|
||||
// drive.appendDriveExportRecoveryHint. err==nil passes through.
|
||||
// withAppsHint attaches an actionable next-step hint to a typed failure,
|
||||
// preserving its original classification (subtype/code/log_id). A hint already
|
||||
// present on the error is kept (the upstream wording wins); only an empty hint
|
||||
// is filled in. Mirrors drive.appendDriveExportRecoveryHint. err==nil and
|
||||
// untyped errors pass through unchanged.
|
||||
func withAppsHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -39,14 +37,5 @@ func withAppsHint(err error, hint string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Legacy *output.ExitError fallback: fill the hint in place, preserving the
|
||||
// original class / exit code rather than downgrading the error.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) == "" {
|
||||
exitErr.Detail.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestWithAppsHint(t *testing.T) {
|
||||
@@ -17,46 +17,40 @@ func TestWithAppsHint(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
|
||||
t.Run("empty hint gets filled, classification preserved", func(t *testing.T) {
|
||||
in := errs.NewAPIError(errs.SubtypeNotFound, "boom").WithCode(404)
|
||||
out := withAppsHint(in, "run +release-list")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
p, ok := errs.ProblemOf(out)
|
||||
if !ok {
|
||||
t.Fatalf("returned error is not typed: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "run +release-list" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
|
||||
if p.Hint != "run +release-list" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "run +release-list")
|
||||
}
|
||||
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
|
||||
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
|
||||
if p.Subtype != errs.SubtypeNotFound || p.Code != 404 || p.Message != "boom" {
|
||||
t.Errorf("subtype/code/message mutated: subtype=%q code=%d msg=%q", p.Subtype, p.Code, p.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", "original hint")
|
||||
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("original hint")
|
||||
out := withAppsHint(in, "new hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "original hint" {
|
||||
t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint")
|
||||
p, _ := errs.ProblemOf(out)
|
||||
if p.Hint != "original hint" {
|
||||
t.Errorf("Hint = %q, want preserved %q", p.Hint, "original hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
|
||||
in := output.ErrWithHint(1, "api_error", "boom", " ")
|
||||
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint(" ")
|
||||
out := withAppsHint(in, "filled hint")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(out, &exitErr) {
|
||||
t.Fatalf("returned error is not *output.ExitError: %T", out)
|
||||
}
|
||||
if exitErr.Detail.Hint != "filled hint" {
|
||||
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
|
||||
p, _ := errs.ProblemOf(out)
|
||||
if p.Hint != "filled hint" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "filled hint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
|
||||
t.Run("untyped error returned unchanged, no panic", func(t *testing.T) {
|
||||
in := errors.New("plain")
|
||||
out := withAppsHint(in, "ignored")
|
||||
if out == nil || out.Error() != "plain" {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -37,7 +36,7 @@ func appDbEnvCreatePath(appID string) string {
|
||||
func requireAppID(raw string) (string, error) {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" {
|
||||
return "", output.ErrValidation("--app-id is required")
|
||||
return "", appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package apps
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,10 +21,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/apps/gitcred"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -53,9 +53,12 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
|
||||
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -129,9 +132,12 @@ var AppsGitCredentialRemove = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
|
||||
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
|
||||
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -268,7 +274,7 @@ func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
})
|
||||
data, err := parseIssueCredentialData(resp, err)
|
||||
data, err := parseIssueCredentialData(resp, err, i.rctx.APIClassifyContext())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -285,7 +291,8 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return nil, err
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
|
||||
WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
ac, err := i.f.NewAPIClientWithConfig(cfg)
|
||||
if err != nil {
|
||||
@@ -296,7 +303,11 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
data, err := parseIssueCredentialData(resp, err)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
Identity: string(core.AsUser),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -414,13 +425,11 @@ func gitCredentialLocalError(action string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Typed errors pass through unchanged; everything the apps domain and the
|
||||
// shared runtime produce is typed, so there is no legacy envelope to forward.
|
||||
if _, ok := errs.UnwrapTypedError(err); ok {
|
||||
return err
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
@@ -448,64 +457,43 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
issued.AppID = appID
|
||||
}
|
||||
if issued.GitHTTPURL == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if issued.PAT == "" {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) {
|
||||
// parseIssueCredentialData turns the git-credential issue response into the
|
||||
// credential data map. A standard Lark envelope (top-level "code") and any
|
||||
// HTTP error status route through the shared response classifier, so generic
|
||||
// codes (missing scope, app not authorized) and 5xx statuses keep their
|
||||
// canonical category/subtype/retryable classification. The endpoint's
|
||||
// non-standard success shapes — direct git info or a BaseResp wrapper — are
|
||||
// handled locally.
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
detail := logIDDetail(resp)
|
||||
if resp == nil || len(resp.RawBody) == 0 {
|
||||
return nil, &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "Issue Miaoda Git credential: empty response body",
|
||||
}}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"Issue Miaoda Git credential: empty response body")
|
||||
}
|
||||
var result map[string]any
|
||||
if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil {
|
||||
return nil, &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr),
|
||||
}, Cause: jsonErr}
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
msg := firstString(result, "msg", "message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
jsonErr := json.Unmarshal(resp.RawBody, &result)
|
||||
_, hasCode := result["code"]
|
||||
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
||||
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
||||
if cerr != nil {
|
||||
return nil, withAppsHint(cerr, gitCredentialIssueHint)
|
||||
}
|
||||
return nil, &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: resp.StatusCode,
|
||||
Message: msg,
|
||||
LogID: logIDString(resp),
|
||||
Hint: gitCredentialIssueHint,
|
||||
Retryable: resp.StatusCode >= http.StatusInternalServerError,
|
||||
}}
|
||||
}
|
||||
if _, hasCode := result["code"]; hasCode {
|
||||
code := firstInt64(result, "code")
|
||||
if code != 0 {
|
||||
return nil, &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: int(code),
|
||||
Message: firstString(result, "msg", "message"),
|
||||
LogID: logIDString(resp),
|
||||
Hint: gitCredentialIssueHint,
|
||||
}}
|
||||
}
|
||||
if data, ok := result["data"].(map[string]any); ok {
|
||||
if data != nil {
|
||||
result = data
|
||||
}
|
||||
// data == nil: a code==0 envelope whose fields sit beside "code" instead
|
||||
// of under "data" — keep the locally-unmarshalled top-level object.
|
||||
} else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -534,13 +522,11 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
if message == "" {
|
||||
message = "Git credential API returned non-zero BaseResp status"
|
||||
}
|
||||
return &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: int(code),
|
||||
Message: "Issue Miaoda Git credential: " + message,
|
||||
LogID: logID,
|
||||
}}
|
||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code))
|
||||
if logID != "" {
|
||||
baseErr = baseErr.WithLogID(logID)
|
||||
}
|
||||
return baseErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -578,6 +564,9 @@ func firstInt64(data map[string]interface{}, keys ...string) int64 {
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
return n
|
||||
|
||||
@@ -5,7 +5,6 @@ package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -35,7 +34,7 @@ func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read root: %w", err)
|
||||
return nil, appsStorageError(err, "apps storage: read root: %v", err)
|
||||
}
|
||||
appIDs := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/apps/gitcred"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -213,7 +213,7 @@ func TestParseIssueCredentialDataAcceptsDirectBaseRespShape(t *testing.T) {
|
||||
"expiredTime":1780050600,
|
||||
"BaseResp":{"StatusCode":0,"StatusMessage":"ok"}
|
||||
}`),
|
||||
}, nil)
|
||||
}, nil, errclass.ClassifyContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("parseIssueCredentialData returned error: %v", err)
|
||||
}
|
||||
@@ -717,9 +717,9 @@ func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
|
||||
t.Fatalf("typed error was rewrapped: %#v", got)
|
||||
}
|
||||
|
||||
exitErr := output.ErrValidation("bad app")
|
||||
if got := gitCredentialLocalError("action", exitErr); got != exitErr {
|
||||
t.Fatalf("legacy output error was rewrapped: %#v", got)
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app")
|
||||
if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) {
|
||||
t.Fatalf("typed validation error was rewrapped: %#v", got)
|
||||
}
|
||||
|
||||
if got := gitCredentialLocalError("action", nil); got != nil {
|
||||
@@ -925,43 +925,43 @@ func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseIssueCredentialDataErrors(t *testing.T) {
|
||||
if _, err := parseIssueCredentialData(nil, errors.New("transport failed")); err == nil {
|
||||
if _, err := parseIssueCredentialData(nil, errors.New("transport failed"), errclass.ClassifyContext{}); err == nil {
|
||||
t.Fatal("parseIssueCredentialData transport error returned nil")
|
||||
}
|
||||
if _, err := parseIssueCredentialData(nil, nil); err == nil {
|
||||
if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil {
|
||||
t.Fatal("parseIssueCredentialData nil response returned nil")
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil); err == nil {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); err == nil {
|
||||
t.Fatal("parseIssueCredentialData bad json returned nil")
|
||||
}
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "bad request") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "bad request") {
|
||||
t.Fatalf("HTTP error = %v", err)
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
|
||||
t.Fatalf("HTTP fallback error = %v", err)
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "failed") {
|
||||
t.Fatalf("code error = %v", err)
|
||||
}
|
||||
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil)
|
||||
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("code zero without data returned error: %v", err)
|
||||
}
|
||||
if data["log_id"] != "log_x" {
|
||||
t.Fatalf("log_id = %v", data["log_id"])
|
||||
}
|
||||
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil)
|
||||
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("null response with log id returned error: %v", err)
|
||||
}
|
||||
if data["log_id"] != "log_x" {
|
||||
t.Fatalf("null response log_id = %v", data["log_id"])
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
t.Fatalf("BaseResp error = %v", err)
|
||||
}
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
|
||||
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
|
||||
t.Fatalf("BaseResp fallback error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -970,7 +970,7 @@ func TestParseIssueCredentialDataErrors(t *testing.T) {
|
||||
// credential issuance failure is flagged retryable and carries the developer-access hint.
|
||||
func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil)
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected 503 error, got nil")
|
||||
}
|
||||
@@ -990,7 +990,7 @@ func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
|
||||
// non-zero business code (no HTTP status) carries the hint but is not retryable.
|
||||
func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil)
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected business-code error, got nil")
|
||||
}
|
||||
@@ -1014,11 +1014,10 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
// server msg and assert (a) Message equals that msg exactly, and (b) neither
|
||||
// Message nor Hint contains any token/secret-shaped string.
|
||||
//
|
||||
// Note: server msg passthrough is the framework's responsibility; apps adds
|
||||
// only a static hint. There is no msg redaction in this path (verbatim
|
||||
// passthrough is the existing behavior), so this test does not assert a
|
||||
// redaction that does not exist — it asserts that apps injects nothing
|
||||
// sensitive of its own.
|
||||
// Note: server msg passthrough is the shared classifier's responsibility;
|
||||
// apps adds only a static hint. There is no msg redaction in this path, so
|
||||
// this test does not assert a redaction that does not exist — it asserts
|
||||
// that apps injects nothing sensitive of its own.
|
||||
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
const serverMsg = "permission denied"
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
@@ -1045,7 +1044,7 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseIssueCredentialData(tc.resp, nil)
|
||||
_, err := parseIssueCredentialData(tc.resp, nil, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
@@ -1053,9 +1052,12 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
// (a) The server msg is passed through verbatim.
|
||||
if p.Message != serverMsg {
|
||||
t.Fatalf("Message = %q, want server msg %q (verbatim passthrough)", p.Message, serverMsg)
|
||||
// (a) The server msg survives into the message. The business-code
|
||||
// path passes it through verbatim; the HTTP-status path reports
|
||||
// "HTTP <status>: <body>" via the shared classifier, so assert
|
||||
// containment rather than equality.
|
||||
if !strings.Contains(p.Message, serverMsg) {
|
||||
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
|
||||
}
|
||||
// apps adds only the static hint — assert that exact static text,
|
||||
// proving apps injects no per-request secret into the hint either.
|
||||
@@ -1138,3 +1140,45 @@ exit 0
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
// TestParseIssueCredentialData_SharedClassifierCoverage pins the canonical
|
||||
// classifications the shared classifier provides on the credential-issue
|
||||
// path: a generic missing-scope code becomes a typed permission error with
|
||||
// the missing scopes extracted, and an HTTP 503 becomes a retryable
|
||||
// network/server_error — neither collapses to api/unknown.
|
||||
func TestParseIssueCredentialData_SharedClassifierCoverage(t *testing.T) {
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
|
||||
t.Run("missing scope classifies as authorization with scopes", func(t *testing.T) {
|
||||
body := `{"code":99991676,"msg":"token scope insufficient","error":{"permission_violations":[{"subject":"spark:app:read"}]}}`
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{
|
||||
StatusCode: http.StatusOK, RawBody: []byte(body), Header: header,
|
||||
}, nil, errclass.ClassifyContext{})
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Subtype != errs.SubtypeTokenScopeInsufficient {
|
||||
t.Fatalf("subtype = %q, want %q", permErr.Subtype, errs.SubtypeTokenScopeInsufficient)
|
||||
}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "spark:app:read" {
|
||||
t.Fatalf("MissingScopes = %v, want [spark:app:read]", permErr.MissingScopes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("http 503 classifies as retryable network server_error", func(t *testing.T) {
|
||||
_, err := parseIssueCredentialData(&larkcore.ApiResp{
|
||||
StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header,
|
||||
}, nil, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer {
|
||||
t.Fatalf("classification = %s/%s, want network/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !p.Retryable {
|
||||
t.Fatalf("retryable = false, want true for 5xx")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ package gitcred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string
|
||||
return err
|
||||
}
|
||||
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
|
||||
return fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
|
||||
return err
|
||||
@@ -106,7 +106,7 @@ func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
|
||||
if isGitConfigGetMissing(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("get %s: %w", key, err)
|
||||
return "", false, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(err)
|
||||
}
|
||||
|
||||
func gitConfigUnset(ctx context.Context, key string) error {
|
||||
@@ -114,7 +114,7 @@ func gitConfigUnset(ctx context.Context, key string) error {
|
||||
if isGitConfigUnsetMissing(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unset %s: %w", key, err)
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -40,21 +40,21 @@ func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer
|
||||
func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
||||
}
|
||||
if profile.UserOpenID == "" {
|
||||
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
if m.Issuer == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "git credential issuer is not configured")
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, appID, profile)
|
||||
if err != nil {
|
||||
@@ -125,14 +125,14 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
|
||||
func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, output.ErrValidation("--app-id is required")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
@@ -335,7 +335,7 @@ func (m *Manager) Erase(r io.Reader) error {
|
||||
}
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
record, err = m.Store.FindByURL(url)
|
||||
@@ -360,7 +360,8 @@ func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialR
|
||||
return CredentialRecord{}, "", false, err
|
||||
}
|
||||
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
|
||||
return CredentialRecord{}, "", false, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID)
|
||||
return CredentialRecord{}, "", false, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential").
|
||||
WithHint(fmt.Sprintf("run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID))
|
||||
}
|
||||
pat, err := m.Secrets.Get(record.PATRef)
|
||||
if err != nil {
|
||||
@@ -423,7 +424,7 @@ func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
|
||||
func parseNormalizedForInput(raw string) (CredentialInput, error) {
|
||||
parts := strings.SplitN(raw, "://", 2)
|
||||
if len(parts) != 2 {
|
||||
return CredentialInput{}, output.ErrValidation("invalid credential URL")
|
||||
return CredentialInput{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid credential URL")
|
||||
}
|
||||
hostPath := parts[1]
|
||||
idx := strings.Index(hostPath, "/")
|
||||
@@ -457,19 +458,19 @@ func defaultUsername(username string) string {
|
||||
|
||||
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
|
||||
if issued == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
|
||||
}
|
||||
if issued.AppID != "" && issued.AppID != appID {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if strings.TrimSpace(issued.PAT) == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
if issued.ExpiresAt <= now {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ package gitcred
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O.
|
||||
@@ -30,7 +30,7 @@ func lockURL(url string) func() {
|
||||
func lockApp(appID string) (func(), error) {
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create Git credential lock dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err)
|
||||
}
|
||||
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock"
|
||||
lock := lockfile.New(filepath.Join(dir, filepath.Base(name)))
|
||||
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func NormalizeGitHTTPURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", output.ErrValidation("git_http_url is empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err)
|
||||
}
|
||||
return normalizeParsedURL(u)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
|
||||
protocol := strings.TrimSpace(input.Protocol)
|
||||
host := strings.TrimSpace(input.Host)
|
||||
if protocol == "" || host == "" {
|
||||
return "", output.ErrValidation("git credential input must include protocol and host")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential input must include protocol and host")
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: protocol,
|
||||
@@ -44,11 +44,11 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
|
||||
func normalizeParsedURL(u *url.URL) (string, error) {
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", output.ErrValidation("git credential only supports http/https URLs")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs")
|
||||
}
|
||||
host := normalizeHost(scheme, u.Host)
|
||||
if host == "" {
|
||||
return "", output.ErrValidation("git_http_url host is empty")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url host is empty")
|
||||
}
|
||||
cleanPath := cleanURLPath(u.EscapedPath())
|
||||
normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String()
|
||||
|
||||
@@ -6,13 +6,13 @@ package apps
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -39,28 +39,18 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
return parseHTMLPublishResponse(apiResp.RawBody)
|
||||
}
|
||||
|
||||
func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
data, err := api.runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return nil, enrichHTMLPublishAPIError(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode html-publish response: %w", err)
|
||||
url, _ := data["url"].(string)
|
||||
if url == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"html-publish response is missing the published app url")
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg),
|
||||
buildHTMLPublishFailureHint(envelope.Code))
|
||||
}
|
||||
return &htmlPublishResponse{URL: envelope.Data.URL}, nil
|
||||
return &htmlPublishResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
// OAPI business error codes returned by the Miaoda
|
||||
@@ -74,9 +64,9 @@ const (
|
||||
func buildHTMLPublishFailureHint(code int) string {
|
||||
switch code {
|
||||
case errCodeBuildFailed:
|
||||
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
|
||||
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
|
||||
case errCodeAppNotFound:
|
||||
return "应用不存在或无权访问;请用户确认 app_id(从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)"
|
||||
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -6,21 +6,21 @@ package apps
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
@@ -94,15 +94,57 @@ func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint on code 90001")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", exitErr.Detail.Message)
|
||||
if !strings.Contains(problem.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeAppNotFound,
|
||||
"msg": "app not found",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected app-not-found recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +180,18 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) {
|
||||
if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil {
|
||||
t.Error("malformed html-publish response must surface a decode error")
|
||||
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
RawBody: []byte("{not json"),
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@ import (
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
@@ -26,7 +25,7 @@ type htmlPublishTarball struct {
|
||||
|
||||
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, errors.New("no files to pack")
|
||||
return nil, appsValidationParamError("--path", "no files to pack")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
@@ -45,10 +44,10 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, fmt.Errorf("tar close: %w", err)
|
||||
return nil, appsFileIOError(err, "tar close: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gzip close: %w", err)
|
||||
return nil, appsFileIOError(err, "gzip close: %v", err)
|
||||
}
|
||||
|
||||
return &htmlPublishTarball{
|
||||
@@ -60,12 +59,12 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
|
||||
|
||||
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
|
||||
if isUnsafeRelPath(c.RelPath) {
|
||||
return fmt.Errorf("invalid tar entry name %q", c.RelPath)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "invalid tar entry name %q", c.RelPath)
|
||||
}
|
||||
|
||||
src, err := fio.Open(c.AbsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", c.AbsPath, err)
|
||||
return appsInputPathEntryError(c.AbsPath, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
@@ -76,10 +75,10 @@ func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCa
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("write header %s: %w", c.RelPath, err)
|
||||
return appsFileIOError(err, "write header %s: %v", c.RelPath, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, src); err != nil {
|
||||
return fmt.Errorf("copy %s: %w", c.RelPath, err)
|
||||
return appsFileIOError(err, "copy %s: %v", c.RelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O.
|
||||
@@ -25,10 +25,10 @@ const storageRoot = "spark"
|
||||
// can traverse out of the storage directory.
|
||||
func checkSeg(name, what string) error {
|
||||
if err := validate.ResourceName(name, what); err != nil {
|
||||
return fmt.Errorf("apps storage: %w", err)
|
||||
return appsStorageError(err, "apps storage: %v", err)
|
||||
}
|
||||
if name == "." {
|
||||
return fmt.Errorf("apps storage: %s must not be \".\"", what)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func Read(appID, key string) ([]byte, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read: %w", err)
|
||||
return nil, appsStorageError(err, "apps storage: read: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -79,10 +79,10 @@ func Write(appID, key string, data []byte) error {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
|
||||
return fmt.Errorf("apps storage: create dir: %w", err)
|
||||
return appsStorageError(err, "apps storage: create dir: %v", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
|
||||
return fmt.Errorf("apps storage: write: %w", err)
|
||||
return appsStorageError(err, "apps storage: write: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func Delete(appID, key string) error {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("apps storage: delete: %w", err)
|
||||
return appsStorageError(err, "apps storage: delete: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func List(appID string) ([]string, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("apps storage: read dir: %w", err)
|
||||
return nil, appsStorageError(err, "apps storage: read dir: %v", err)
|
||||
}
|
||||
keys := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func isUnsafeRelPath(rel string) bool {
|
||||
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
|
||||
stat, err := fio.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", rootPath, err)
|
||||
return nil, appsInputPathError(err)
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
return []htmlPublishCandidate{{
|
||||
@@ -54,7 +54,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
//nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath.
|
||||
err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
return appsInputPathEntryError(path, walkErr)
|
||||
}
|
||||
// Skip a stray git repo: a directory named .git skips the whole subtree,
|
||||
// and a .git file (the gitdir pointer used by submodules/worktrees) is
|
||||
@@ -70,7 +70,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
return appsInputPathEntryError(path, err)
|
||||
}
|
||||
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。
|
||||
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。
|
||||
@@ -79,7 +79,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
}
|
||||
rel, err := filepath.Rel(rootPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
return appsFileIOError(err, "resolve relative path for %s: %v", path, err)
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
// Defense in depth: WalkDir + Rel inside rootPath should never yield a
|
||||
@@ -87,7 +87,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
|
||||
// filesystem layout shouldn't be able to inject one into RelPath.
|
||||
// Mirrors the same guard at tar entry write time.
|
||||
if isUnsafeRelPath(relSlash) {
|
||||
return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "walker produced unsafe relative path %q for %s", relSlash, path)
|
||||
}
|
||||
out = append(out, htmlPublishCandidate{
|
||||
RelPath: relSlash,
|
||||
|
||||
@@ -298,6 +298,14 @@ func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]in
|
||||
// carry fields a caller needs on failure (e.g. the file_token an overwrite
|
||||
// returned, for token-stability handling).
|
||||
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
|
||||
return ClassifyAPIResponseWith(resp, ctx.APIClassifyContext())
|
||||
}
|
||||
|
||||
// ClassifyAPIResponseWith is the RuntimeContext-free form of
|
||||
// ClassifyAPIResponse for callers that drive the request outside a running
|
||||
// shortcut (e.g. a cobra command holding only a factory) and supply their own
|
||||
// classification context.
|
||||
func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
||||
logID, _ := logIDFromHeader(resp)["log_id"].(string)
|
||||
|
||||
result, parseErr := client.ParseJSONResponse(resp)
|
||||
@@ -321,7 +329,7 @@ func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[stri
|
||||
}
|
||||
}
|
||||
out, _ := resultMap["data"].(map[string]interface{})
|
||||
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
|
||||
if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil {
|
||||
return out, apiErr
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
|
||||
@@ -28,7 +28,7 @@ lark-cli apps +db-execute --app-id app_xxx --env dev --sql - --yes < /Users/.../
|
||||
|
||||
- 成功默认 JSON 读取 `data.results[]`;每个元素对应一条 SQL,常见字段有 `sql_type`、`data`、`record_count`、`affected_rows`。
|
||||
- pretty 会按 SELECT/DML/DDL 自适应渲染;多语句会逐条显示 Statement 摘要。
|
||||
- 失败可能仍有前序语句已执行;看 `error.detail.statement_index`、`completed`、`rolled_back` 和 `hint` 决定从哪条继续。
|
||||
- 失败可能仍有前序语句已执行;此时 stdout 输出 `ok:false` 的 envelope(exit 非 0),从 `data` 读 `results[]`(全部逐条结果,失败语句 `sql_type` 为 `ERROR`)、`statement_index`、`error_code`、`error_message`、`rolled_back` 和 `note`,决定从哪条继续。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。当用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是资源类型、URL 路径模式和 token,而不是域名。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -12,7 +12,7 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
> **术语说明:** 飞书云空间也常被称为"云盘"或"云存储",三者指的是同一个产品,是飞书官方的云端文件存储与管理中心。
|
||||
> **术语说明:** 飞书云空间也常被称为"云盘"、"云存储"、"网盘"或"我的空间",这些说法通常指的是同一个产品,是飞书官方的云端文件存储与管理中心。
|
||||
|
||||
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。
|
||||
|
||||
@@ -21,6 +21,7 @@ metadata:
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||
@@ -51,88 +52,17 @@ metadata:
|
||||
|----------|---------------------------------------------------------|-----------|----------|
|
||||
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
|
||||
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | 不能直接当底层 `file_token`;优先用 `drive +inspect` 解包获取 `obj_token` |
|
||||
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
|
||||
|
||||
#### 处理流程
|
||||
|
||||
**推荐方式:使用 `drive +inspect` 自动解包**
|
||||
### Wiki 链接特殊处理
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
```
|
||||
|
||||
返回结果包含 `type`(底层文档类型)、`token`(真实 file_token)、`title`、`url` 等字段,直接用于后续操作。
|
||||
|
||||
**手动方式:使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote)
|
||||
- `node.obj_token`:**真实的文档 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
|
||||
3. **根据 `obj_type` 使用对应的 API**
|
||||
|
||||
| obj_type | 说明 | 使用的 API |
|
||||
|----------|------|-----------|
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 | `bitable.*` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
|
||||
#### 查询示例
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "docx",
|
||||
"obj_token": "xxxx",
|
||||
"title": "标题",
|
||||
"node_type": "origin",
|
||||
"space_id": "12345678910"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 资源关系
|
||||
|
||||
```
|
||||
Wiki Space (知识空间)
|
||||
└── Wiki Node (知识库节点)
|
||||
├── obj_type: docx (新版文档)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: doc (旧版文档)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: sheet (电子表格)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: bitable (多维表格)
|
||||
│ └── obj_token (真实文档 token)
|
||||
└── obj_type: file/slides/mindnote
|
||||
└── obj_token (真实文档 token)
|
||||
|
||||
Drive Folder (云空间/云盘/云存储文件夹)
|
||||
└── File (文件/文档)
|
||||
└── file_token (直接使用)
|
||||
```
|
||||
知识库链接背后可能是 docx、sheet、bitable、slides、file 等不同对象。后续要做评论、下载、导出或内容读取时,优先用 `drive +inspect` 拿到 `type`、`token`、`title`、`url`;完整手动解析和跨 skill 路由见共享文档 [`lark-wiki-token-routing.md`](../lark-shared/references/lark-wiki-token-routing.md)。不要只根据 `/wiki/<token>` 猜底层类型。
|
||||
|
||||
### 常见操作 Token 需求
|
||||
|
||||
@@ -145,84 +75,12 @@ Drive Folder (云空间/云盘/云存储文件夹)
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | `file_token` | 同添加评论 |
|
||||
|
||||
### 评论能力边界(关键!)
|
||||
### 评论能力入口
|
||||
|
||||
- `drive +add-comment` 支持两种模式。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。
|
||||
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id,`sheet` 支持 `<sheetId>!<cell>`,`slides` 支持 `<slide-block-type>!<xml-id>`,wiki URL 解析到这些类型时也支持对应局部评论。Drive file 本次只支持全文评论,不支持局部评论。
|
||||
- Drive file 评论仅支持白名单扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持,CLI 会直接报错提示当前还不支持这种类型的评论。
|
||||
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
|
||||
- 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `<`,`>` -> `>`。
|
||||
- 使用 `drive +add-comment` 时,shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2`、`drive file.comment.replys create`、`drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
|
||||
|
||||
### 评论查询与统计口径(关键!)
|
||||
|
||||
**强制规则**:`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
|
||||
|
||||
**正确示例:**
|
||||
|
||||
```bash
|
||||
# 默认查询:仅未解决评论(推荐)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
|
||||
|
||||
# 查询所有评论(用户未明确要求包含已解决评论)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx", "is_solved": false}'
|
||||
|
||||
# 包含已解决评论(需用户明确要求)
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
|
||||
```
|
||||
|
||||
**错误示例:**
|
||||
|
||||
```bash
|
||||
# 不推荐:用户未明确要求但查询所有评论
|
||||
lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "docx"}'
|
||||
```
|
||||
|
||||
- 查询文档评论时,使用 `drive file.comments list`。
|
||||
- `drive file.comments list` 返回的 `items` 应理解为"评论卡片"列表,每个 `item` 对应用户界面里看到的一张评论卡片,而不是平铺的互动消息列表。
|
||||
- 服务端语义上,创建第一条评论时会同时创建该卡片里的第一条 reply;因此真正承载正文的是每个 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的"评论本身"。
|
||||
- 当用户要统计"评论数"或"评论卡片数"时,统计 `items` 的长度即可;如果是全量统计,则对所有评论分页返回的 `items` 长度累加。
|
||||
- 当用户要统计"回复数"时,按用户视角应排除每张评论卡片里的首条评论,统计口径是所有 `item.reply_list.replies` 的长度之和减去 `items` 的长度。
|
||||
- 当用户要统计"总互动数"时,统计所有 `item.reply_list.replies` 的长度之和即可;这个口径包含每张评论卡片里的首条评论。
|
||||
- 如果某个 `item.has_more=true`,说明该评论卡片下还有更多回复未包含在当前返回中;此时需要继续调用 `drive file.comment.replys list` 拉全后,再做全量回复数 / 总互动数统计。
|
||||
|
||||
### 评论业务特性与引导(关键!)
|
||||
|
||||
#### Review 场景评论落点
|
||||
- 默认策略是“能局部就局部”:用户说 review、审阅、检查文档、标注问题、给修改建议、逐条评论时,优先创建局部评论。
|
||||
- 多个独立问题应分别创建多条局部评论;不要为了省调用次数把 review 发现的问题合并到全文评论。
|
||||
- 只有在目标类型支持全文评论,且出现以下任一情况时,才退回全文评论:用户明确要求全文/总体评论、评论内容确实是文档级总结、目标类型不支持局部评论,或无法稳定定位到具体位置;否则应说明限制并请求用户提供可定位位置。
|
||||
- 具体参数、定位方式和不同文档类型的约束见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
|
||||
|
||||
#### 评论排序引导
|
||||
- 一个文档通常有多个评论,评论按 `create_time`(创建时间)排序。
|
||||
- **重要**:只有当用户明确提到"最新评论"、"最后评论"、"最早评论"时,才需要根据 `create_time` 进行排序:
|
||||
- **必须先获取所有评论(处理分页拉完所有数据)**,不能只获取一页就排序
|
||||
- "最新评论" / "最后评论":按 `create_time` 降序排列,取第一条
|
||||
- "最早评论":按 `create_time` 升序排列,取第一条
|
||||
- 如果用户只说"第一条评论",直接使用 `drive file.comments list` 返回的第一条即可,不需要额外排序。
|
||||
|
||||
#### 评论回复限制
|
||||
- **添加评论回复前先检查是否存在以下限制**
|
||||
- **全文评论不支持回复**:`is_whole=true` 的评论(全文评论)无法添加回复,遇到此类评论应提示用户"全文评论不支持回复"。
|
||||
- **已解决评论不支持回复**:`is_solved=true` 的评论无法添加回复,遇到此类评论应提示用户"该评论已被解决,无法回复"。
|
||||
- **注意**:当用户要回复某条评论但该评论因上述限制不能回复时,只提示不能回复即可,**不要自动帮用户找其他可以回复的评论**,避免不符合用户预期。
|
||||
|
||||
#### 批量查询与列表查询的选择
|
||||
- 使用 `drive file.comments batch_query` 是**已知评论 ID 后**的批量查询,需要传入具体的评论 ID 列表。
|
||||
- 使用 `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论,或获取"最新/最后 N 条评论"等场景。
|
||||
|
||||
#### 评论定位字段
|
||||
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block),先确认目标是 `file_type=docx`,再阅读 [评论定位字段说明](references/lark-drive-comment-location.md),其他文档类型暂不支持返回定位字段。
|
||||
|
||||
#### Reaction / 表情场景
|
||||
- 遇到评论 / 回复上的 reaction(表情、各表情数量、谁点了什么、添加/删除表情)相关问题时,**先阅读 [lark-drive-reactions.md](../../skills/lark-drive/references/lark-drive-reactions.md) 了解如何使用**。
|
||||
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md):review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
|
||||
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
|
||||
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段。
|
||||
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`。
|
||||
|
||||
### 典型错误与解决方案
|
||||
|
||||
@@ -232,70 +90,52 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides) |
|
||||
|
||||
#### `permission.public.patch` 错误码引导
|
||||
### 权限能力入口
|
||||
|
||||
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope;它们通常表示租户、对外分享或文档密级策略拦截。
|
||||
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
|
||||
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
|
||||
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
|
||||
|
||||
| 错误码 | 含义 | 给用户的引导 |
|
||||
|--------|------------------------|--------------|
|
||||
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过 API 或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
|
||||
| `91010` | 文档对外分享未打开 | 提示用户:当前文档尚未打开对外分享,请先在文档权限设置中打开对外分享,再重试 `permission.public.patch`。 |
|
||||
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
## 不在本 skill 范围
|
||||
|
||||
当用户最初提供的是文档 URL,遇到 `91011` 或 `91012` 时直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token,需要先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL,再给出可点击的文档 URL。
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用权限接口授权:
|
||||
|
||||
```bash
|
||||
# 1. 获取当前应用的 open_id
|
||||
lark-cli api GET /open-apis/bot/v3/info --as bot
|
||||
# 从返回值中取 bot.open_id
|
||||
|
||||
# 2. 授权当前应用访问文档
|
||||
lark-cli drive permission.members create \
|
||||
--params '{"token":"<doc_token>","type":"<resource_type>"}' \
|
||||
--data '{"member_type":"openid","member_id":"<bot_open_id>","perm":"view","type":"user"}'
|
||||
```
|
||||
|
||||
> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。
|
||||
|
||||
`<resource_type>` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`、`slides`。
|
||||
- 文档正文读取、总结、创建、编辑、图片/附件插入或下载:使用 [`lark-doc`](../lark-doc/SKILL.md)。
|
||||
- 电子表格单元格、筛选、公式、样式等表内操作:使用 [`lark-sheets`](../lark-sheets/SKILL.md)。
|
||||
- Base / 多维表格内部的表、字段、记录、视图、仪表盘等操作:使用 [`lark-base`](../lark-base/SKILL.md)。
|
||||
- 知识空间、Wiki 节点层级、空间成员管理:使用 [`lark-wiki`](../lark-wiki/SKILL.md);上传本地文件到 wiki 节点仍用 `drive +upload --wiki-token`。
|
||||
- 原生 Markdown 文件读取、写入、patch、diff:使用 [`lark-markdown`](../lark-markdown/SKILL.md);把 Markdown 导入成在线 docx 才用 `drive +import --type docx`。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+preview`](references/lark-drive-preview.md) | List or download available preview artifacts for a Drive file; explicit `--type` required for downloads |
|
||||
| [`+cover`](references/lark-drive-cover.md) | List or download stable built-in cover presets for a Drive file; download-time HTTP 404 means the file has no artifact for that cover spec |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
|
||||
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/file/sheet/slides, also supports wiki URL resolving to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable/slides to a local file with limited polling; supports `--file-name` for local naming |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable, slides) |
|
||||
| [`+version-history`](references/lark-drive-version-history.md) | List historical versions of a file with only_tag=true and cursor-based pagination |
|
||||
| [`+version-get`](references/lark-drive-version-get.md) | Download a specific historical version of a file |
|
||||
| [`+version-revert`](references/lark-drive-version-revert.md) | Revert a file to a specific historical version |
|
||||
| [`+version-delete`](references/lark-drive-version-delete.md) | Delete a specific historical version of a file |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | List secure labels available to the current user |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | Update a Drive file/document secure label; downgrade approval errors require opening the document UI |
|
||||
| Shortcut | 说明 |
|
||||
|----------|----------|
|
||||
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象;支持 `--edited-since`、`--mine`、`--doc-types` 等扁平 flag。 |
|
||||
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点。 |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
|
||||
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
|
||||
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件的 PDF / HTML / 文本 / 图片等预览产物。 |
|
||||
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
|
||||
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |
|
||||
| `+sync` | 双向同步本地目录与 Drive 文件夹:拉取 `new_remote`、推送 `new_local`,`modified` 按 `--on-conflict=remote-wins\|local-wins\|keep-both\|ask` 处理;`--quick` 用修改时间近似比较;`--on-duplicate-remote` 支持 `fail` / `newest` / `oldest`;只同步 `type=file`,跳过在线文档和 shortcut,且不会删除两端多余文件。 |
|
||||
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides 添加评论;评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
|
||||
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
|
||||
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
|
||||
| [`+version-history`](references/lark-drive-version-history.md) | 查看文件历史版本。 |
|
||||
| [`+version-get`](references/lark-drive-version-get.md) | 下载指定历史版本。 |
|
||||
| [`+version-revert`](references/lark-drive-version-revert.md) | 回滚到指定历史版本。 |
|
||||
| [`+version-delete`](references/lark-drive-version-delete.md) | 删除指定历史版本。 |
|
||||
| [`+move`](references/lark-drive-move.md) | 移动 Drive 文件或文件夹;Wiki 层级移动走 `lark-wiki`。 |
|
||||
| [`+delete`](references/lark-drive-delete.md) | 删除 Drive 文件或文件夹,文件夹删除会轮询异步任务。 |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | 查询 import/export/move/delete 等异步任务结果。 |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -362,35 +202,3 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
- `get` — 获取当前用户的容量信息,包含各业务使用量、租户配额是否超限、用户配额、所在部门配额
|
||||
- 仅支持 `--as user`,不要使用默认的 bot 身份
|
||||
- `quota_detail_id` 传当前用户的 `user_id`
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------------------------------------------------|--------------------------------------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `permission.public.get` | `docs:permission.setting:read` |
|
||||
| `permission.public.patch` | `docs:permission.setting:write_only` |
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
| `quota_details.get` | `drive:quota_detail:read_one` |
|
||||
|
||||
> `quota_details.get` 是 user-only OpenAPI:调用时必须显式传 `--as user`,且 `quota_detail_id` 应填写当前用户的 `user_id`。
|
||||
|
||||
72
skills/lark-drive/references/lark-drive-comments-guide.md
Normal file
72
skills/lark-drive/references/lark-drive-comments-guide.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Drive 评论查询、统计与回复指南
|
||||
|
||||
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md),reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
|
||||
|
||||
## 评论模式
|
||||
|
||||
- `drive +add-comment` 支持全文评论和局部评论。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc` / `docx` / `file` 的 wiki URL。
|
||||
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id,`sheet` 支持 `<sheetId>!<cell>`,`slides` 支持 `<slide-block-type>!<xml-id>`,wiki URL 解析到这些类型时也支持对应局部评论。
|
||||
- Drive file 只支持全文评论,不支持局部评论。支持扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持。
|
||||
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
- 评论写入内容里的文本不能直接出现 `<`、`>`;提交前应转义为 `<`、`>`。`drive +add-comment` 会对 `type=text` 文本元素自动兜底转义;直接调用原生评论 API 时需要自行转义。
|
||||
- 如果 wiki 解析后不是 `doc` / `docx` / `file` / `sheet` / `slides`,不要用 `+add-comment`。
|
||||
|
||||
## 查询默认口径
|
||||
|
||||
`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
|
||||
|
||||
```bash
|
||||
# 默认查询:仅未解决评论
|
||||
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}'
|
||||
|
||||
# 包含已解决评论:仅当用户明确要求时使用
|
||||
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}'
|
||||
```
|
||||
|
||||
## 评论卡片与统计
|
||||
|
||||
- `drive file.comments list` 返回的 `items` 是评论卡片列表,每个 `item` 对应用户界面中的一张评论卡片,不是平铺的互动消息列表。
|
||||
- 创建第一条评论时会同时创建该卡片里的第一条 reply;真正承载正文的是 `item.reply_list.replies`,其中第一条 reply 在用户视角下就是这张卡片里的“评论本身”。
|
||||
- 统计“评论数”或“评论卡片数”:统计 `items` 长度;全量统计时对所有分页返回的 `items` 长度累加。
|
||||
- 统计“回复数”:统计所有 `item.reply_list.replies` 长度之和,再减去 `items` 长度。
|
||||
- 统计“总互动数”:统计所有 `item.reply_list.replies` 长度之和,包含每张评论卡片里的首条评论。
|
||||
- 如果 `item.has_more=true`,说明该评论卡片下还有更多回复未包含在当前返回中;需要继续调用 `drive file.comment.replys list` 拉全后,再做全量回复数或总互动数统计。
|
||||
|
||||
## 排序
|
||||
|
||||
- 只有当用户明确提到“最新评论”“最后评论”“最早评论”时,才需要按 `create_time` 排序。
|
||||
- 排序前必须拉完所有评论分页,不能只取第一页。
|
||||
- “最新评论”/“最后评论”:按 `create_time` 降序取第一条。
|
||||
- “最早评论”:按 `create_time` 升序取第一条。
|
||||
- 用户只说“第一条评论”时,直接使用 `drive file.comments list` 返回的第一条,不需要额外排序。
|
||||
|
||||
## 回复限制
|
||||
|
||||
- 回复前先检查目标评论状态。
|
||||
- `is_whole=true` 的全文评论不支持回复;遇到时提示“全文评论不支持回复”。
|
||||
- `is_solved=true` 的已解决评论不支持回复;遇到时提示“该评论已被解决,无法回复”。
|
||||
- 当目标评论不能回复时,只提示限制,不要自动替用户寻找其他可回复评论。
|
||||
|
||||
## batch_query 与 list
|
||||
|
||||
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
|
||||
- `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论、获取最新或最后 N 条评论等场景。
|
||||
|
||||
## 评论定位字段
|
||||
|
||||
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block),先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。
|
||||
- 其他文档类型暂不支持返回定位字段。
|
||||
|
||||
## 原生 API
|
||||
|
||||
需要更底层地直接调用评论 V2 协议时,先查看 schema,再调用原生命令。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.file.comments.create_v2
|
||||
lark-cli drive file.comments create_v2 \
|
||||
--params '{"file_token":"<DOC_TOKEN>"}' \
|
||||
--data '{"file_type":"docx","reply_elements":[{"type":"text","text":"全文评论内容"}]}'
|
||||
```
|
||||
41
skills/lark-drive/references/lark-drive-permission-guide.md
Normal file
41
skills/lark-drive/references/lark-drive-permission-guide.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Drive 权限与授权指南
|
||||
|
||||
> 前置条件:通用认证、scope 与 `--as` 规则见 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
|
||||
|
||||
## 何时读取
|
||||
|
||||
- 用户要修改文档公开权限,尤其是 `drive permission.public patch` 返回 `91009` / `91010` / `91011` / `91012`。
|
||||
- 用户要给文档、文件、文件夹、Wiki 或 slides 增加协作者权限,或把访问权限授予当前应用(bot)自身。
|
||||
- 用户遇到 `permission denied`,但错误表现更像租户对外分享、安全策略或密级拦截,而不是普通 scope 缺失。
|
||||
|
||||
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
|
||||
|
||||
## 公开权限错误码
|
||||
|
||||
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope;它们通常表示租户、对外分享或文档密级策略拦截。
|
||||
|
||||
| 错误码 | 含义 | 给用户的引导 |
|
||||
|--------|------|--------------|
|
||||
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过 API 或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
|
||||
| `91010` | 文档对外分享未打开 | 提示用户:当前文档尚未打开对外分享,请先在文档权限设置中打开对外分享,再重试 `permission.public.patch`。 |
|
||||
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
|
||||
当用户最初提供的是文档 URL,遇到 `91011` 或 `91012` 时直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token,需要先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL,再给出可点击的文档 URL。
|
||||
|
||||
## 授权当前应用访问文档
|
||||
|
||||
需要将文档权限授予当前应用(bot)自身时:
|
||||
|
||||
1. 先执行 `lark-cli api GET /open-apis/bot/v3/info --as bot`,从返回值取 `bot.open_id`。
|
||||
2. 再调用 `lark-cli drive permission.members create`,用 `member_type=openid`、`member_id=<bot_open_id>` 授权。
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members create \
|
||||
--params '{"token":"<doc_token>","type":"<resource_type>"}' \
|
||||
--data '{"member_type":"openid","member_id":"<bot_open_id>","perm":"view","type":"user"}'
|
||||
```
|
||||
|
||||
此方式仅适用于授权给当前应用。授权给其他用户时,直接使用对方的 open_id,无需调用 bot info 接口。
|
||||
|
||||
`<resource_type>` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`、`slides`。
|
||||
@@ -1,6 +1,6 @@
|
||||
# drive reactions
|
||||
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 了解 Drive 评论卡片模型、评论数/回复数统计口径、`file_token` / `file_type` 规则;再阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 了解 Drive 评论入口,再阅读 [`lark-drive-comments-guide.md`](lark-drive-comments-guide.md) 了解评论卡片模型、评论数/回复数统计口径、`file_token` / `file_type` 规则;同时阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
处理文档评论 / 回复上的 reaction(点赞、表情、各表情数量、谁点了什么、添加/删除表情)。这个场景不常见,但规则比较集中:查询时只有在用户明确需要 reaction 信息时才带 `need_reaction=true`;写入时统一使用 `drive file.comment.reply.reactions update_reaction`,操作对象始终是 `reply_id`。
|
||||
|
||||
|
||||
@@ -1,168 +1,89 @@
|
||||
---
|
||||
name: lark-shared
|
||||
version: 1.0.0
|
||||
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
|
||||
description: "首次配置 lark-cli、运行 auth login、用 --as 切换 user/bot 身份、处理权限不足或 scope 错误、遇到高风险写操作的确认门禁(exit 10 / confirmation)、更新 lark-cli、或看到 JSON 输出里的 _notice 时使用。"
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
|
||||
本技能指导你如何通过lark-cli操作飞书资源, 以及有哪些注意事项。
|
||||
通过 lark-cli 操作飞书资源的通用规则。正文是常驻核心;以下细节按需读取(`lark-cli skills read lark-shared references/<file>`):
|
||||
|
||||
## 配置初始化
|
||||
- **遇到 exit 10 / confirmation 错误** → 读 [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)(错误形态、识别、按 hint 重试、如何识别高风险)
|
||||
- **要帮用户做 user 身份授权** → 读 [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)(split-flow 完整步骤)
|
||||
- **拿到 `/wiki/` 链接或 wiki token** → 读 [`references/lark-wiki-token-routing.md`](references/lark-wiki-token-routing.md)(token 解包与按底层对象路由)
|
||||
|
||||
首次使用需运行 `lark-cli config init` 完成应用配置。
|
||||
|
||||
当你帮用户初始化配置时,使用background方式使用下面的命令发起配置应用流程,启动后读取输出,从中提取授权链接并发给用户。
|
||||
|
||||
**URL 转发规则**:当命令输出 `verification_url`、`verification_uri_complete`、`console_url` 等 URL 字段时:**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**URL 输出规则**:将 URL 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点、重新拼接 query),二维码和链接请一起展示给用户。
|
||||
|
||||
```bash
|
||||
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
## 认证
|
||||
## 认证与身份
|
||||
|
||||
### 身份类型
|
||||
|
||||
两种身份类型,通过 `--as` 切换:
|
||||
两种身份,通过 `--as` 切换:
|
||||
|
||||
| 身份 | 标识 | 获取方式 | 适用场景 |
|
||||
|------|------|---------|---------|
|
||||
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘/云存储等) |
|
||||
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问bot自己的资源 |
|
||||
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘等) |
|
||||
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问 bot 自己的资源 |
|
||||
|
||||
### 身份选择原则
|
||||
|
||||
输出的 `[identity: bot/user]` 代表当前身份。bot 与 user 表现差异很大,需确认身份符合目标需求:
|
||||
|
||||
- **Bot 看不到用户资源**:无法访问用户的日历、云空间(云盘/云存储)文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历
|
||||
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot
|
||||
- **Bot 权限**:只需在飞书开发者后台开通 scope,无需 `auth login`
|
||||
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足
|
||||
|
||||
- **Bot 看不到用户资源**:无法访问用户的日历、云空间文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历。
|
||||
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot。
|
||||
- **Bot 权限**:只需在飞书开发者后台开通 scope,无需 `auth login`。
|
||||
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足。
|
||||
|
||||
### 权限不足处理
|
||||
|
||||
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
|
||||
遇到权限相关错误时,**根据当前身份采取不同方案**。错误响应中的关键字段(注意区分来源):
|
||||
|
||||
错误响应中包含关键信息:
|
||||
- `permission_violations`:列出缺失的 scope (N选1)
|
||||
- `console_url`:飞书开发者后台的权限配置链接
|
||||
- `hint`:建议的修复命令
|
||||
- 缺失的 scope:`permission_violations`(原始 API 错误块,元素形如 `{subject: "<scope>"}`);CLI 结构化错误里则是已抽取好的 `missing_scopes`(scope 字符串数组)。
|
||||
- `console_url`:飞书开发者后台的权限配置链接。
|
||||
- `hint`:建议的修复命令。
|
||||
|
||||
#### Bot 身份(`--as bot`)
|
||||
- **Bot 身份**:将 `console_url` 提供给用户(按下方「安全规则」的 URL 规则展示),引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
- **User 身份**:
|
||||
```bash
|
||||
lark-cli auth login --domain <domain> # 按业务域授权
|
||||
lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推荐,最小权限)
|
||||
```
|
||||
auth login 必须指定范围(`--domain` 或 `--scope`);多次 login 的 scope 会累积(增量授权)。
|
||||
|
||||
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
### Agent 代理发起认证
|
||||
|
||||
#### User 身份(`--as user`)
|
||||
优先用 split-flow(`--no-wait` 发起 → 展示给用户 → 后续轮 `--device-code` 完成),避免同轮阻塞。三条铁律:① 不在同一轮展示 URL 后立刻阻塞轮询 `--device-code`(交还控制权,等用户回来);② `--device-code` 由你亲自执行,不要让用户自己跑;③ 不缓存 `verification_url` / `device_code`,每次需要授权都重新发起。授权 URL 的二维码展示按「安全规则」。完整步骤详见 [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain <domain> # 按业务域授权
|
||||
lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推荐,符合最小权限原则)
|
||||
```
|
||||
## 配置初始化
|
||||
|
||||
**规则**:auth login 必须指定范围(`--domain` 或 `--scope`)。多次 login 的 scope 会累积(增量授权)。
|
||||
|
||||
#### Agent 代理发起认证(推荐)
|
||||
|
||||
当你作为 AI agent 需要帮用户完成认证时,优先使用 split-flow,避免在同一轮对话中阻塞等待用户授权:
|
||||
|
||||
```bash
|
||||
# 发起授权(立即返回 device_code 和 verification_url)
|
||||
lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
|
||||
```
|
||||
|
||||
拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。
|
||||
|
||||
用户回复已完成授权后,再在后续步骤执行:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --device-code <device_code>
|
||||
```
|
||||
|
||||
**Split-Flow 完整步骤**:
|
||||
|
||||
**第一步:发起授权(当前轮)**
|
||||
|
||||
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`)
|
||||
2. 从 JSON 输出中提取 `verification_url` 和 `device_code`
|
||||
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
|
||||
4. 将 URL 和二维码展示给用户(先 URL,后二维码)
|
||||
5. **结束本轮对话前,必须明确告知用户**:"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
|
||||
|
||||
**第二步:完成授权(后续轮)**
|
||||
|
||||
1. 等待用户回复"已完成授权"
|
||||
2. **由你(AI agent)亲自执行**:`lark-cli auth login --device-code <device_code>`
|
||||
3. 此命令会轮询授权状态并完成登录
|
||||
4. 如果返回授权成功,流程结束
|
||||
|
||||
**关键规则**:
|
||||
|
||||
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
|
||||
- **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
|
||||
首次使用运行 `lark-cli config init --new`:帮用户初始化时**非阻塞启动**该命令、**持续读取输出**、从中提取授权链接发给用户(URL 的二维码展示按「安全规则」)。
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
命令执行后若检测到新版本,JSON 输出会包含 `_notice.update`(字段:`current`、`latest`、`message`、`command`)。
|
||||
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
**看到 `_notice.update` 时,完成用户当前请求后,主动提议更新**:
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
1. 告知用户当前版本和最新版本号(也可用 `lark-cli update --check` 只检查不安装)。
|
||||
2. 提议执行 `lark-cli update`(同时更新 CLI 和 AI Skills)。
|
||||
3. 更新完成后提醒:**退出并重新打开 AI Agent** 以加载最新 Skills。
|
||||
|
||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
不要静默忽略更新提示,即使当前任务与更新无关,也应在完成请求后补充告知。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
- **写入/删除操作前必须确认用户意图**。
|
||||
- 用 `--dry-run` 预览危险请求。
|
||||
- **文件路径只接受相对路径**:`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON)优先用 stdin 传入,避免路径和转义问题。
|
||||
- **文件路径只接受相对路径**:`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON)优先用 stdin,避免路径和转义问题。
|
||||
- **输出任何授权 / 配置类 URL(`verification_url` / `verification_uri_complete` / `console_url` 等)时**:必须用 `lark-cli auth qrcode` 生成并展示二维码(URL 在前、二维码在后,不可跳过);URL 视为 opaque string,不改写(不编码/解码、不加空格标点、不重拼 query)。
|
||||
|
||||
## 高风险操作的审批协议(exit 10)
|
||||
## 高风险操作的确认门禁(exit 10)
|
||||
|
||||
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时,CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope:
|
||||
高风险写操作(`risk: "high-risk-write"`)未确认时,CLI **退出码 `10`**,并返回确认 envelope(`type` 为 `confirmation` / `confirmation_required`)。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "confirmation_required",
|
||||
"message": "drive +delete requires confirmation",
|
||||
"hint": "add --yes to confirm",
|
||||
"risk": {
|
||||
"level": "high-risk-write",
|
||||
"action": "drive +delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
**遇到 exit 10:绝不当普通错误放弃,绝不静默加 `--yes`。**
|
||||
|
||||
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
|
||||
1. **停下**,把这次高风险操作和关键参数讲给用户,等其**显式同意**。
|
||||
2. 同意后,从 envelope 的 `hint` 读出确认 flag(`--yes` / `--force`),以 argv 数组**追加到原始命令**重试——不写死 `--yes`,不用 `sh -c` 拼接。
|
||||
3. 用户拒绝则终止。
|
||||
|
||||
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
|
||||
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
|
||||
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
|
||||
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁
|
||||
|
||||
**绝对不允许**:
|
||||
- 看到 exit 10 就默认加 `--yes` 静默重试(这等于禁用门禁)
|
||||
- 把 `confirmation_required` 当网络错误/权限错误处理
|
||||
- 在用户没明确同意的前提下追加 `--yes` 重试
|
||||
- 用 `sh -c` 等 shell 方式拼接命令重试——用 `exec.Command(argv...)` 参数数组形式,避免 shell 解析把用户参数当作语法
|
||||
|
||||
提前预判:想先让用户 review 危险操作的具体请求,调用时加 `--dry-run`——它不触发门禁,会打印完整请求详情(URL / body / params),你可以把这个预览给用户看过再去真正执行。
|
||||
|
||||
### 如何识别一条命令是高风险
|
||||
|
||||
- shortcut:`lark-cli <service> +<cmd> --help` 顶部会显示 `Risk: high-risk-write`
|
||||
- service 命令:`lark-cli schema <service>.<resource>.<method> --format json` 的返回值里 `"risk": "high-risk-write"`
|
||||
**错误形态识别、`action` 字段位置、如何判断高风险、`--dry-run` 预览 → 详见 [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)。**
|
||||
36
skills/lark-shared/references/lark-shared-auth-split-flow.md
Normal file
36
skills/lark-shared/references/lark-shared-auth-split-flow.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Agent 代理发起认证(split-flow)
|
||||
|
||||
当你作为 AI agent 需要帮用户完成 user 身份认证时,优先使用 split-flow,避免在同一轮对话中阻塞等待用户授权。
|
||||
|
||||
```bash
|
||||
# 发起授权(立即返回 device_code 和 verification_url)
|
||||
lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
|
||||
```
|
||||
|
||||
拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮 / 交还控制权。**不要**在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。
|
||||
|
||||
## 第一步:发起授权(当前轮)
|
||||
|
||||
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`)。
|
||||
2. 从 JSON 输出提取 `verification_url` 和 `device_code`。
|
||||
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`。
|
||||
4. 将 URL 和二维码展示给用户(先 URL,后二维码)。
|
||||
5. **结束本轮前明确告知用户**:"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"。
|
||||
|
||||
## 第二步:完成授权(后续轮)
|
||||
|
||||
1. 等待用户回复"已完成授权"。
|
||||
2. **由你(AI agent)亲自执行**:`lark-cli auth login --device-code <device_code>`。
|
||||
3. 此命令会轮询授权状态并完成登录;返回成功即结束。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行。
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL。
|
||||
- **禁止缓存 `verification_url` / `device_code`**:每次需要授权时都重新执行 `lark-cli auth login --no-wait --json` 生成新链接,不要将授权链接和 device code 存入上下文供后续复用。
|
||||
|
||||
## 授权范围
|
||||
|
||||
- auth login 必须指定范围(`--domain <domain>` 或 `--scope "<scope>"`);推荐 `--scope`,符合最小权限原则。
|
||||
- 多次 login 的 scope 会累积(增量授权)。
|
||||
- 可用 `--exclude "<scope>"` 排除特定 scope、`--recommend` 只请求推荐(可自动批准)的 scope。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 高风险操作的确认门禁(exit 10)
|
||||
|
||||
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。不带确认标志调用这类命令时,CLI 退出码 `10`,并在 stderr 返回结构化 envelope。
|
||||
|
||||
> 正文已给出安全默认(停下、绝不静默 `--yes`、从 `hint` 取 flag 追加到原始 argv 重试)。本文件是机制细节,遇到 exit 10 时按需读取。
|
||||
|
||||
## 关键:可靠信号是退出码 10,不是 type 字符串
|
||||
|
||||
仓库正在从「扁平错误」迁移到「typed 错误」,同一个门禁可能以两种形态出现,但**都以 exit 10 为信号**:
|
||||
|
||||
- **扁平式**(service / shortcut 命令,旧形态):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "confirmation_required",
|
||||
"message": "drive +delete requires confirmation",
|
||||
"hint": "add --yes to confirm",
|
||||
"risk": { "level": "high-risk-write", "action": "drive +delete" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **typed 式**(如 `config bind`,新形态):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "confirmation",
|
||||
"subtype": "confirmation_required",
|
||||
"risk": "high-risk-write",
|
||||
"action": "config bind --force",
|
||||
"hint": "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
只用 `error.type == "confirmation_required"` 判断会**漏掉 typed 式**。正确识别:**子进程 exit code = 10**,且 `error` 命中任一:
|
||||
|
||||
- `type == "confirmation_required"`(扁平),或
|
||||
- `type == "confirmation" && subtype == "confirmation_required"`(typed)。
|
||||
|
||||
## 处理流程
|
||||
|
||||
1. **识别**:exit code = 10 且命中上述任一形态。
|
||||
2. **向用户确认**:把操作名和关键参数展示给用户,明确告知"这是高风险操作",等待显式同意。
|
||||
- 操作名位置随形态而异:typed 在 `error.action`;扁平在 `error.risk.action`。取 `error.action || error.risk.action`。
|
||||
- 注意 `error.risk` 形态也不同:扁平是对象 `{level, action}`,typed 是字符串(如 `"high-risk-write"`)。
|
||||
3. **用户同意 → 从 `hint` 读出确认 flag,追加到原始 argv 后重试**。`hint` 是给 Agent 看的自然语言提示,写明了该用哪个 flag——**提取那个 flag(如 `--yes` / `--force`)追加到你的原始命令**,不要写死 `--yes`,也**不要照抄 hint 里的示例命令**(示例不含用户原始参数,照抄会丢参数):
|
||||
- 扁平式:`hint = "add --yes to confirm"` → 原始 argv 末尾追加 `--yes`。
|
||||
- typed 式(bind):`hint` 提示用 `--force` → 原始 argv 末尾追加 `--force`。
|
||||
4. **用户拒绝 → 终止流程**,不擅自改写参数或跳过门禁。
|
||||
|
||||
## 绝对不允许
|
||||
|
||||
- 看到 exit 10 就默认加 `--yes` 静默重试(等于禁用门禁)。
|
||||
- 把 `confirmation` / `confirmation_required` 当网络错误 / 权限错误处理。
|
||||
- 用户没明确同意就追加确认 flag 重试。
|
||||
- 用 `sh -c` 等 shell 方式拼接命令重试——用 `exec.Command(argv...)` 参数数组形式,避免 shell 解析把用户参数当作语法。
|
||||
|
||||
## 提前预览(不触发门禁)
|
||||
|
||||
想先让用户 review 危险操作的具体请求,调用时加 `--dry-run`——它不触发门禁,会打印完整请求详情(URL / body / params),可把预览给用户看过再真正执行。
|
||||
|
||||
## 如何识别一条命令是高风险
|
||||
|
||||
- shortcut:`lark-cli <service> +<cmd> --help` 顶部显示 `Risk: high-risk-write`。
|
||||
- service 命令:`lark-cli schema <service>.<resource>.<method> --format json` 返回值里 `"risk": "high-risk-write"`(同时 schema 会注入 `yes` 字段标记需确认)。
|
||||
- 注意:被标注 `high-risk-write` ≠ 一定走 exit-10 门禁。例如 `lark-cli update` 标了 risk 但没有 `--yes` flag、不走该门禁——以**实际 exit 10 + envelope** 为准,不要臆造 `--yes`。
|
||||
42
skills/lark-shared/references/lark-wiki-token-routing.md
Normal file
42
skills/lark-shared/references/lark-wiki-token-routing.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Wiki token routing
|
||||
|
||||
Wiki URL 中的 `/wiki/<token>` 是节点 token,不一定是底层文档、表格、Base、文件或幻灯片的对象 token。需要对底层对象做读取、评论、导出、下载、表内操作等动作时,先解包,再按底层类型路由。
|
||||
|
||||
## 推荐方式
|
||||
|
||||
优先使用 `lark-drive` 的 `drive +inspect`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/<wiki_token>'
|
||||
```
|
||||
|
||||
输出中的 `type` 是底层对象类型,`token` 是后续命令应使用的 canonical token。`wiki_node` 字段保留节点侧信息,如 `space_id`、`node_token`、`obj_token`、`obj_type`。
|
||||
|
||||
## 手动方式
|
||||
|
||||
如果不能使用 shortcut,再调用 Wiki 节点接口:
|
||||
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'
|
||||
```
|
||||
|
||||
从返回值中读取:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `node.obj_type` | 底层对象类型,如 `docx`、`doc`、`sheet`、`bitable`、`slides`、`file`、`mindnote` |
|
||||
| `node.obj_token` | 底层对象 token,用于对应业务 skill 或原生 API |
|
||||
| `node.node_token` / `token` | Wiki 节点 token,用于 Wiki 节点层级操作 |
|
||||
| `node.space_id` | 所属知识空间 |
|
||||
|
||||
## 路由
|
||||
|
||||
| `obj_type` | 后续操作 |
|
||||
|------------|----------|
|
||||
| `docx` / `doc` | 文档内容走 `lark-doc`;评论、权限、导出等云空间能力走 `lark-drive` |
|
||||
| `sheet` | 表内数据走 `lark-sheets`;评论、权限、导出等云空间能力走 `lark-drive` |
|
||||
| `bitable` | 表内数据走 `lark-base`;评论、权限、导出等云空间能力走 `lark-drive` |
|
||||
| `slides` | 幻灯片内容编辑走 `lark-slides`;评论、权限、导出等云空间能力走 `lark-drive` |
|
||||
| `file` | 普通文件上传、下载、评论、权限等走 `lark-drive` |
|
||||
| `mindnote` | 思维笔记的移动、删除、快捷方式、权限、安全标签等云空间能力走 `lark-drive`;知识库节点层级操作走 `lark-wiki` |
|
||||
| wiki 节点层级 / 空间成员 | 走 `lark-wiki`,不要把底层对象 token 当节点 token |
|
||||
Reference in New Issue
Block a user