mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
66 Commits
codex/opti
...
feat/miaod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2acf7d0bd | ||
|
|
8f948f34a5 | ||
|
|
ebaf4e79d2 | ||
|
|
aa38eff197 | ||
|
|
e35e74d6cc | ||
|
|
2d75888d86 | ||
|
|
fcc1d8b0dd | ||
|
|
eb9454fdc6 | ||
|
|
124c59ced7 | ||
|
|
1f8d659ee1 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac | ||
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 |
@@ -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/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
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/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
# 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/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
@@ -116,16 +116,14 @@ linters:
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
# These helpers emit legacy output.Err* / bare error shapes or drop
|
||||
# typed metadata such as Param/Cause. Migrated domains must use typed
|
||||
# common replacements or local typed helpers instead.
|
||||
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy or
|
||||
metadata-poor error shapes. Use typed common replacements, typed
|
||||
errs.NewXxxError builders, or domain-local typed helpers.
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
83
CHANGELOG.md
83
CHANGELOG.md
@@ -2,6 +2,86 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
@@ -1026,6 +1106,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
|
||||
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
16
cmd/build.go
16
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -140,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
@@ -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/event"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -260,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -300,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -328,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
@@ -351,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -370,3 +381,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -129,3 +130,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Type: "api",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
Message: "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
183
cmd/skill/skill.go
Normal file
183
cmd/skill/skill.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skill implements the `lark-cli skills` command group, which serves
|
||||
// binary-embedded skill content to AI agents. The package is "skill"; the
|
||||
// user-facing verb is "skills".
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
|
||||
if f.SkillContent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"skill content not embedded in this build")
|
||||
}
|
||||
return skillcontent.New(f.SkillContent), nil
|
||||
}
|
||||
|
||||
type readEnvelope struct {
|
||||
Skill string `json:"skill"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Guidance string `json:"guidance,omitempty"`
|
||||
}
|
||||
|
||||
type listEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []skillcontent.SkillInfo `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type listPathEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []skillcontent.DirEntry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Read embedded skill content (list / read)",
|
||||
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
|
||||
"the CLI binary at build time, so it stays in sync with the CLI version. " +
|
||||
"Machine resources such as assets/ and scripts/ are not embedded.",
|
||||
}
|
||||
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.AddCommand(newListCmd(f), newReadCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [name[/path]]",
|
||||
Short: "List skills, or list one layer under a skill path (like ls)",
|
||||
Example: ` lark-cli skills list # all skills: name, description, version
|
||||
lark-cli skills list lark-doc # one layer under a skill (like ls)
|
||||
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"list takes at most 1 argument: [name[/path]]").
|
||||
WithHint("run 'lark-cli skills list --help'")
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
|
||||
return nil
|
||||
}
|
||||
entries, listed, err := r.ListPath(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
|
||||
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "read <name>[/<path>] [path]",
|
||||
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
|
||||
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
|
||||
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
|
||||
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
|
||||
lark-cli skills read lark-doc --json # JSON envelope`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name, relpath, err := parseReadTarget(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
var pathOut string
|
||||
if relpath == "" {
|
||||
content, err = r.ReadSkill(name)
|
||||
pathOut = "SKILL.md"
|
||||
} else {
|
||||
content, pathOut, err = r.ReadReference(name, relpath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isMain := pathOut == "SKILL.md"
|
||||
if asJSON {
|
||||
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
|
||||
if isMain {
|
||||
env.Guidance = readGuidance(name)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
|
||||
if _, err := f.IOStreams.Out.Write(content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
|
||||
}
|
||||
if isMain {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
|
||||
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
|
||||
func parseReadTarget(args []string) (name, relpath string, err error) {
|
||||
switch len(args) {
|
||||
case 1:
|
||||
name, relpath = skillcontent.SplitArg(args[0])
|
||||
return name, relpath, nil
|
||||
case 2:
|
||||
return args[0], args[1], nil
|
||||
default:
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
|
||||
WithHint("run 'lark-cli skills read --help'")
|
||||
}
|
||||
}
|
||||
|
||||
// readGuidance routes cross-skill "../lark-foo/..." references back through
|
||||
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
|
||||
// relative form must be rewritten.
|
||||
func readGuidance(name string) string {
|
||||
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
|
||||
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
|
||||
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
|
||||
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
|
||||
}
|
||||
306
cmd/skill/skill_test.go
Normal file
306
cmd/skill/skill_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// calFS is the default single-skill content tree for these tests. The embedded
|
||||
// FS is now injected through the Factory (no package global), so tests pass it
|
||||
// explicitly to run() — nothing is shared, so they are safe under -parallel.
|
||||
func calFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
}
|
||||
}
|
||||
|
||||
// run executes the skills command tree against the given content FS (may be nil
|
||||
// to exercise the not-embedded path) and returns stdout/stderr/err.
|
||||
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
// Isolate CLI config state so tests never read/write the real config dir
|
||||
// (repo convention).
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
|
||||
f.SkillContent = fsys
|
||||
cmd := NewCmdSkill(f)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err = cmd.Execute()
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func TestSkillList(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
// "ok" is an explicit success marker (the list envelope is a typed struct;
|
||||
// no automatic _notice attaches).
|
||||
if !got.OK {
|
||||
t.Error("expected ok=true in list envelope")
|
||||
}
|
||||
if got.Count != 1 || len(got.Skills) != 1 {
|
||||
t.Fatalf("count: got %d", got.Count)
|
||||
}
|
||||
if got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Errorf("name: got %v", got.Skills[0]["name"])
|
||||
}
|
||||
// Top-level list carries version + metadata, not a references list.
|
||||
if _, ok := got.Skills[0]["references"]; ok {
|
||||
t.Error("top-level list must not include references")
|
||||
}
|
||||
if got.Skills[0]["version"] != "1.0.0" {
|
||||
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
|
||||
}
|
||||
if _, ok := got.Skills[0]["metadata"]; !ok {
|
||||
t.Error("expected metadata in list entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListJSONFlagAccepted(t *testing.T) {
|
||||
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
|
||||
// so it stays symmetric with read --json.
|
||||
stdout, _, err := run(t, calFS(), "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("list --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Count != 1 {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPath(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("list <name> error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Path != "lark-calendar" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// One layer under the skill root: SKILL.md (file) + references (dir).
|
||||
if got.Count != 2 || len(got.Entries) != 2 {
|
||||
t.Fatalf("entries: got %+v", got.Entries)
|
||||
}
|
||||
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
|
||||
t.Errorf("entry[0]: got %+v", got.Entries[0])
|
||||
}
|
||||
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
|
||||
t.Errorf("entry[1]: got %+v", got.Entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "no-such-skill")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Fatalf("expected 'unknown skill' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Fatalf("expected 'invalid path' error, got %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListTooManyArgs(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "a", "b")
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
|
||||
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
|
||||
// omitted from the catalog (no blank entry).
|
||||
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
|
||||
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
|
||||
}
|
||||
stdout, _, err := run(t, fsys, "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadRaw(t *testing.T) {
|
||||
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
|
||||
t.Errorf("raw output: got %q", stdout)
|
||||
}
|
||||
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
|
||||
if strings.Contains(stdout, "Tip:") {
|
||||
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
|
||||
}
|
||||
// Guidance goes to stderr: own files via `skills read <name> ...`, and
|
||||
// cross-skill refs routed to `skills read <other-skill> ...` (version-
|
||||
// consistent), not "read directly".
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
|
||||
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
|
||||
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "instead of opening them directly") ||
|
||||
strings.Contains(stderr, "read those directly") {
|
||||
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v", e)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Guidance is a separate field, not merged into content.
|
||||
if got.Guidance == "" {
|
||||
t.Error("expected guidance field for main SKILL.md")
|
||||
}
|
||||
if strings.Contains(got.Content, "Tip:") {
|
||||
t.Error("guidance must not be merged into content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFile(t *testing.T) {
|
||||
// Both the 2-arg and slash forms read the same file, with no guidance tip.
|
||||
for _, args := range [][]string{
|
||||
{"read", "lark-calendar", "references/agenda.md"},
|
||||
{"read", "lark-calendar/references/agenda.md"},
|
||||
} {
|
||||
stdout, stderr, err := run(t, calFS(), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("read %v error: %v", args, err)
|
||||
}
|
||||
if stdout != "# Agenda" {
|
||||
t.Errorf("read %v output: got %q", args, stdout)
|
||||
}
|
||||
// Reference reads carry no guidance on either stream.
|
||||
if strings.Contains(stderr, "Tip:") {
|
||||
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFileJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read file --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Reference reads do not carry the guidance tip.
|
||||
if got.Guidance != "" {
|
||||
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read", "no-such")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadMissingArg(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
|
||||
t.Fatalf("expected arg error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillNilContentFS(t *testing.T) {
|
||||
_, _, err := run(t, nil, "list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SkillContent is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not embedded") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,21 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -478,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
@@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -862,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallManual,
|
||||
@@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||
@@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
|
||||
@@ -5,9 +5,9 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||
// Problem.Code rather than the legacy envelope shape.
|
||||
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||
WithCode(vcNoteDetailNotFoundCode)
|
||||
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||
}
|
||||
if isLarkCode(typedNotFound, 99999) {
|
||||
t.Error("a different expected code must not match")
|
||||
}
|
||||
|
||||
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||
t.Error("typed error with another code must not match")
|
||||
}
|
||||
|
||||
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||
t.Error("untyped error must not match")
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ package vc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
84
events/vc/recording_ended.go
Normal file
84
events/vc/recording_ended.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
|
||||
type VCRecordingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingEndedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingEndedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingEndedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingEndedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingEndedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingEndedOutput{
|
||||
Type: recordingEndedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
|
||||
var envelope recordingEndedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingEndedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
84
events/vc/recording_started.go
Normal file
84
events/vc/recording_started.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
|
||||
type VCRecordingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingStartedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingStartedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingStartedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingStartedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingStartedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingStartedOutput{
|
||||
Type: recordingStartedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
|
||||
var envelope recordingStartedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingStartedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
468
events/vc/recording_test.go
Normal file
468
events/vc/recording_test.go
Normal file
@@ -0,0 +1,468 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if !strings.Contains(def.Description, "recording_bean") {
|
||||
t.Errorf("Description should document recording_bean source, got %q", def.Description)
|
||||
}
|
||||
if !strings.Contains(def.Description, "connected to Feishu software") {
|
||||
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
|
||||
}
|
||||
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
|
||||
t.Errorf("Description should not mention future sources, got %q", def.Description)
|
||||
}
|
||||
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
|
||||
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
|
||||
}
|
||||
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
|
||||
switch tc.eventType {
|
||||
case eventTypeRecordingTranscriptGenerated:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
|
||||
case eventTypeRecordingEnded:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
|
||||
}
|
||||
if def.Schema.Custom.Type != wantSchemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingStarted(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingStarted {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_transcript_001",
|
||||
"event_type": "vc.recording.recording_transcript_generated_v1",
|
||||
"create_time": "1761782400100"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"transcript_items": [
|
||||
{
|
||||
"speaker": {
|
||||
"id": {
|
||||
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
|
||||
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
|
||||
"user_id": null
|
||||
},
|
||||
"user_type": 100,
|
||||
"user_role": 1,
|
||||
"user_name": "Alice"
|
||||
},
|
||||
"text": "hello world",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782399000",
|
||||
"end_time_ms": "1761782400000",
|
||||
"sentence_id": "987654321"
|
||||
},
|
||||
{
|
||||
"speaker": {
|
||||
"user_name": "Bob"
|
||||
},
|
||||
"text": "second sentence",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782401000",
|
||||
"end_time_ms": "1761782402000",
|
||||
"sentence_id": "987654322"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out VCRecordingTranscriptGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
|
||||
if out.Type != eventTypeRecordingTranscriptGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400100) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
if len(out.TranscriptItems) != 2 {
|
||||
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
|
||||
}
|
||||
item := out.TranscriptItems[0]
|
||||
if item.SpeakerName != "Alice" || item.Text != "hello world" {
|
||||
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
|
||||
}
|
||||
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
|
||||
}
|
||||
if item.SentenceID != "987654321" {
|
||||
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
|
||||
}
|
||||
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
|
||||
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
|
||||
}
|
||||
itemJSON, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal transcript item: %v", err)
|
||||
}
|
||||
var itemFields map[string]any
|
||||
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
|
||||
t.Fatalf("unmarshal transcript item JSON: %v", err)
|
||||
}
|
||||
wantItemFields := map[string]bool{
|
||||
"speaker_name": true,
|
||||
"text": true,
|
||||
"start_time": true,
|
||||
"end_time": true,
|
||||
"sentence_id": true,
|
||||
}
|
||||
for gotField := range itemFields {
|
||||
if !wantItemFields[gotField] {
|
||||
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for wantField := range wantItemFields {
|
||||
if _, ok := itemFields[wantField]; !ok {
|
||||
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for _, unexpected := range []string{
|
||||
`"seq_id"`,
|
||||
`"speaker"`,
|
||||
`"user_open_id"`,
|
||||
`"user_type"`,
|
||||
`"user_role"`,
|
||||
`"language"`,
|
||||
`"start_time_ms"`,
|
||||
`"end_time_ms"`,
|
||||
`"sequence_id"`,
|
||||
`"transcript_item"`,
|
||||
} {
|
||||
if strings.Contains(string(got), unexpected) {
|
||||
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
|
||||
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400200) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
|
||||
t.Fatalf("ended output should drop object metadata, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), `"timestamp"`) {
|
||||
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
|
||||
}
|
||||
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
|
||||
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
payload string
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeRecordingStarted,
|
||||
process: processVCRecordingStarted,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "transcript",
|
||||
eventType: eventTypeRecordingTranscriptGenerated,
|
||||
process: processVCRecordingTranscriptGenerated,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ended",
|
||||
eventType: eventTypeRecordingEnded,
|
||||
process: processVCRecordingEnded,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
|
||||
if got != nil {
|
||||
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
|
||||
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
|
||||
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
|
||||
t.Helper()
|
||||
got := runRecordingProcessRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func recordingTestEventTime(millis int64) string {
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
163
events/vc/recording_transcript_generated.go
Normal file
163
events/vc/recording_transcript_generated.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
|
||||
type VCRecordingTranscriptItemOutput struct {
|
||||
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
|
||||
Text string `json:"text,omitempty" desc:"Transcript text"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
|
||||
}
|
||||
|
||||
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
|
||||
type VCRecordingTranscriptGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingTranscriptGeneratedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedItemIn struct {
|
||||
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
|
||||
Text string `json:"text"`
|
||||
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
|
||||
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
|
||||
SentenceID string `json:"sentence_id"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedSpeakerIn struct {
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedString string
|
||||
|
||||
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingTranscriptGeneratedOutput{
|
||||
Type: recordingTranscriptGeneratedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
|
||||
var envelope recordingTranscriptGeneratedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventTime(raw string) string {
|
||||
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, recordingTranscriptItem(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
|
||||
return VCRecordingTranscriptItemOutput{
|
||||
SpeakerName: recordingSpeakerName(item.Speaker),
|
||||
Text: item.Text,
|
||||
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
|
||||
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
|
||||
SentenceID: item.SentenceID,
|
||||
}
|
||||
}
|
||||
|
||||
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
|
||||
if speaker == nil {
|
||||
return ""
|
||||
}
|
||||
return speaker.UserName
|
||||
}
|
||||
|
||||
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = recordingTranscriptGeneratedString(str)
|
||||
return nil
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(data, &num); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = recordingTranscriptGeneratedString(num.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s recordingTranscriptGeneratedString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
@@ -11,13 +11,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
|
||||
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
|
||||
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
|
||||
|
||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||
)
|
||||
@@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition {
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingStarted,
|
||||
DisplayName: "Recording started",
|
||||
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingTranscriptGenerated,
|
||||
DisplayName: "Recording transcript generated",
|
||||
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingTranscriptGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingTranscriptGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingEnded,
|
||||
DisplayName: "Recording ended",
|
||||
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
23
events/whiteboard/native.go
Normal file
23
events/whiteboard/native.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||
type BoardWhiteboardUpdatedV1Data struct {
|
||||
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||
WhiteboardID string `json:"whiteboard_id"`
|
||||
// OperatorIDs lists the operators that produced this update batch.
|
||||
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||
}
|
||||
|
||||
// OperatorID identifies an operator that produced the whiteboard update,
|
||||
// expressed in the three Lark identity formats.
|
||||
type OperatorID struct {
|
||||
// OpenID is the operator's open_id within the current app.
|
||||
OpenID string `json:"open_id"`
|
||||
// UnionID is the operator's union_id across apps under the same ISV.
|
||||
UnionID string `json:"union_id"`
|
||||
// UserID is the operator's user_id within the tenant.
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
53
events/whiteboard/preconsume.go
Normal file
53
events/whiteboard/preconsume.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// cleanupTimeout bounds how long the unsubscribe call has to finish during
|
||||
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
|
||||
// and returns a cleanup that invokes the matching unsubscribe.
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"param whiteboard_id is required for %s", eventType).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
212
events/whiteboard/preconsume_test.go
Normal file
212
events/whiteboard/preconsume_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// recordedCall captures a single APIClient invocation for assertion.
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
// fakeAPIClient is a minimal event.APIClient stub that records calls and
|
||||
// can be configured to fail when the request path matches errOnPath.
|
||||
type fakeAPIClient struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
errOnPath string
|
||||
}
|
||||
|
||||
// CallAPI records the invocation and optionally returns a simulated error
|
||||
// when the path contains the configured errOnPath substring.
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
|
||||
return nil, errors.New("simulated subscribe failure")
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
|
||||
// PreConsume hook fails fast with an actionable error when whiteboard_id
|
||||
// is absent from the params map.
|
||||
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when whiteboard_id missing")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup on error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("missing whiteboard_id should carry a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
// returns an error when the runtime APIClient dependency is missing.
|
||||
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
// failed subscribe call surfaces the error and skips registering a cleanup,
|
||||
// so no spurious unsubscribe is invoked.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{errOnPath: "/subscribe"}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from subscribe call")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup when subscribe fails")
|
||||
}
|
||||
// only the failed subscribe call should have been made; no unsubscribe.
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
|
||||
// happy-path: subscribe is called once with the correct method/path/body,
|
||||
// and the returned cleanup invokes the matching unsubscribe.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatalf("expected non-nil cleanup")
|
||||
}
|
||||
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
|
||||
}
|
||||
got := rt.calls[0]
|
||||
if got.method != "POST" {
|
||||
t.Errorf("subscribe method: got %q, want POST", got.method)
|
||||
}
|
||||
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
|
||||
if got.path != wantSubPath {
|
||||
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
|
||||
}
|
||||
body, _ := got.body.(map[string]string)
|
||||
if body["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if len(rt.calls) != 2 {
|
||||
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
|
||||
}
|
||||
got2 := rt.calls[1]
|
||||
if got2.method != "POST" {
|
||||
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
|
||||
}
|
||||
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
|
||||
if got2.path != wantUnsubPath {
|
||||
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
|
||||
}
|
||||
body2, _ := got2.body.(map[string]string)
|
||||
if body2["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
|
||||
// whiteboard_id values containing reserved URL characters are properly
|
||||
// path-segment encoded so they cannot escape into adjacent path segments.
|
||||
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
|
||||
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(rt.calls))
|
||||
}
|
||||
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
|
||||
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
|
||||
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
|
||||
// required whiteboard_id parameter.
|
||||
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
keys := Keys()
|
||||
for _, k := range keys {
|
||||
if k.Key == eventTypeWhiteboardUpdated {
|
||||
if k.PreConsume == nil {
|
||||
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
if len(k.Params) == 0 {
|
||||
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
var found bool
|
||||
for _, p := range k.Params {
|
||||
if p.Name == "whiteboard_id" && p.Required {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
// 确保 event.APIClient 接口与本测试 mock 一致。
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
48
events/whiteboard/register.go
Normal file
48
events/whiteboard/register.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package whiteboard registers Board-domain EventKeys.
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||
|
||||
// Keys returns all Board-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeWhiteboardUpdated,
|
||||
DisplayName: "Whiteboard updated",
|
||||
Description: "Pushed when the whiteboard content is updated.",
|
||||
EventType: eventTypeWhiteboardUpdated,
|
||||
Params: []event.ParamDef{
|
||||
{
|
||||
Name: "whiteboard_id",
|
||||
Type: event.ParamString,
|
||||
Required: true,
|
||||
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||
},
|
||||
},
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||
},
|
||||
},
|
||||
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +44,8 @@ type Factory struct {
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
|
||||
@@ -18,17 +18,26 @@ type IOStreams struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
// ErrIsTerminal reports whether ErrOut is an interactive terminal. Use it to
|
||||
// gate stderr-only animations (spinners) so pipes / CI / captured stderr stay
|
||||
// clean. Derived from ErrOut's underlying *os.File; non-file writers → false.
|
||||
ErrIsTerminal bool
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal is derived from in's underlying *os.File, if any; non-file
|
||||
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
|
||||
// ErrIsTerminal is derived the same way from errOut.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
errIsTerminal := false
|
||||
if f, ok := errOut.(*os.File); ok {
|
||||
errIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, ErrIsTerminal: errIsTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
@@ -57,6 +66,7 @@ func normalizeStreams(s *IOStreams) *IOStreams {
|
||||
}
|
||||
if out.ErrOut == nil {
|
||||
out.ErrOut = sys.ErrOut
|
||||
out.ErrIsTerminal = sys.ErrIsTerminal
|
||||
}
|
||||
}
|
||||
return &out
|
||||
|
||||
@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
base.Troubleshooter = ts
|
||||
}
|
||||
}
|
||||
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
||||
// returns these as free-text reason strings with no machine-readable field
|
||||
// name (verified for code 190014:
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
||||
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
||||
// prompt — rather than fabricated structured params. Lifted before the
|
||||
// category switch so any classified arm inherits it; the CategoryAPI arm
|
||||
// below prefers this server detail over the context-free APIHint default.
|
||||
detailHint := liftErrorDetailValues(resp)
|
||||
if detailHint != "" {
|
||||
base.Hint = detailHint
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
@@ -129,7 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
// A server-supplied detail (lifted into base.Hint above) wins over the
|
||||
// context-free APIHint default; only fall back to APIHint when absent.
|
||||
if base.Hint == "" {
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
}
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
@@ -214,6 +230,10 @@ func stringFromAny(v any) string {
|
||||
// per-subtype recovery hint before returning it, so the wire envelope
|
||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||
// Config categories have authoritative recovery guidance, so the curated
|
||||
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
||||
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
||||
// detail wins).
|
||||
p.Hint = ConfigHint(p.Subtype)
|
||||
return &errs.ConfigError{Problem: p}
|
||||
}
|
||||
@@ -258,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
||||
}
|
||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||
// Permission categories have authoritative recovery guidance (scopes to
|
||||
// grant, console URL), so the curated PermissionHint deliberately overrides
|
||||
// any server detail lifted into p.Hint (the opposite precedence from the
|
||||
// CategoryAPI arm, where the lifted detail wins).
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||
permErr := &errs.PermissionError{
|
||||
Problem: p,
|
||||
@@ -366,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
|
||||
return "check the calling identity has the required scope"
|
||||
}
|
||||
|
||||
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||
// strings and joins them with "; ". Returns "" when the structure is absent or
|
||||
// carries no non-empty value. The shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"<reason>"}]}}.
|
||||
func liftErrorDetailValues(resp map[string]any) string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
details, ok := errBlock["details"].([]any)
|
||||
if !ok || len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
var values []string
|
||||
for _, d := range details {
|
||||
m, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, _ := m["value"].(string); v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(values, "; ")
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
|
||||
@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
|
||||
// resp.error.details[].value into Problem.Hint when the response routes to the
|
||||
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
|
||||
// — only a human-readable reason string, no machine-readable field name. It is
|
||||
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
|
||||
// structured params.
|
||||
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
map[string]any{"value": "end_time should be later than start_time"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
|
||||
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
|
||||
// detail values are joined with "; " into a single Hint, and empty values are
|
||||
// skipped.
|
||||
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
map[string]any{"value": "first reason"},
|
||||
map[string]any{"value": ""},
|
||||
map[string]any{"value": "second reason"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Hint != "first reason; second reason" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
|
||||
// the details array (not a JSON object) are skipped rather than panicking, and
|
||||
// well-formed siblings still surface in the Hint.
|
||||
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
"i am a bare string, not an object",
|
||||
map[string]any{"value": "the real reason"},
|
||||
42,
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Hint != "the real reason" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
|
||||
// block, a non-array details field, and an empty details array all leave the
|
||||
// Hint untouched (no lifted detail) instead of erroring.
|
||||
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
}{
|
||||
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
|
||||
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
|
||||
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
|
||||
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
// With no liftable detail, the Hint must not echo a server detail.
|
||||
if strings.Contains(p.Hint, "nope") {
|
||||
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
||||
// when the upstream response omits it — wire envelope must omit the field.
|
||||
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
||||
|
||||
16
internal/errclass/codemeta_calendar.go
Normal file
16
internal/errclass/codemeta_calendar.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var calendarCodeMeta = map[int]CodeMeta{
|
||||
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }
|
||||
39
internal/errclass/codemeta_calendar_test.go
Normal file
39
internal/errclass/codemeta_calendar_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
|
||||
// via the codemeta_calendar.go init() merge to its expected
|
||||
// Category/Subtype/Retryable.
|
||||
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 190014: calendar "invalid params" with a field-level detail
|
||||
// (error.details[].value) lifted into Hint by BuildAPIError.
|
||||
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
18
internal/errclass/codemeta_minutes.go
Normal file
18
internal/errclass/codemeta_minutes.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is stable across minutes endpoints are registered;
|
||||
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
|
||||
// Command-specific messages, hints, and subtypes are layered on top via
|
||||
// per-command enrichment.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var minutesCodeMeta = map[int]CodeMeta{
|
||||
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
|
||||
@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
|
||||
if got, ok := LookupCodeMeta(2091001); ok {
|
||||
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
|
||||
19
internal/errclass/codemeta_vc.go
Normal file
19
internal/errclass/codemeta_vc.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes (e.g. 124002 "recording still generating", which has no
|
||||
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
|
||||
// per-command enrichment for a retry hint.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var vcCodeMeta = map[int]CodeMeta{
|
||||
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
|
||||
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(vcCodeMeta, "vc") }
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -80,7 +83,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +138,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +160,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -213,7 +227,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText() string {
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
99
internal/event/consume/startup_guard_test.go
Normal file
99
internal/event/consume/startup_guard_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/validate_params_test.go
Normal file
64
internal/event/consume/validate_params_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
80
internal/output/spinner.go
Normal file
80
internal/output/spinner.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
const (
|
||||
spinnerInterval = 80 * time.Millisecond
|
||||
spinnerHideCursor = "\x1b[?25l"
|
||||
spinnerShowCursor = "\x1b[?25h"
|
||||
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
||||
)
|
||||
|
||||
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
||||
// until the returned stop() is called, e.g.:
|
||||
//
|
||||
// ⠹ Publishing dev → main... 3s
|
||||
//
|
||||
// It is meant for slow operations (long polls, first-time provisioning) so the
|
||||
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
||||
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
||||
//
|
||||
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
||||
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
||||
// the stderr-TTY check (IOStreams.ErrIsTerminal), not the output format: the
|
||||
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
|
||||
//
|
||||
// stop() clears the spinner line, restores the cursor, and blocks until the
|
||||
// render goroutine has finished — so callers can safely write the result to
|
||||
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
|
||||
// it is safe to call more than once (e.g. an explicit call plus a defer).
|
||||
func StartSpinner(w io.Writer, enabled bool, label string) func() {
|
||||
if !enabled || w == nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
start := time.Now()
|
||||
|
||||
go func() {
|
||||
defer close(finished)
|
||||
frame := 0
|
||||
fmt.Fprint(w, spinnerHideCursor)
|
||||
render := func() {
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
|
||||
frame = (frame + 1) % len(spinnerFrames)
|
||||
}
|
||||
render()
|
||||
ticker := time.NewTicker(spinnerInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
|
||||
return
|
||||
case <-ticker.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(done)
|
||||
<-finished // wait for the line to be cleared before returning
|
||||
})
|
||||
}
|
||||
}
|
||||
54
internal/output/spinner_test.go
Normal file
54
internal/output/spinner_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
|
||||
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, false, "working")
|
||||
stop()
|
||||
stop() // idempotent
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
|
||||
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
|
||||
stop := StartSpinner(nil, true, "working")
|
||||
stop() // must not panic
|
||||
}
|
||||
|
||||
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
|
||||
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, true, "Publishing")
|
||||
// The goroutine renders the first frame synchronously before selecting on
|
||||
// the stop channel, so even an immediate stop() yields one full cycle.
|
||||
stop()
|
||||
stop() // idempotent, must not panic or double-write after finished
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, spinnerHideCursor) {
|
||||
t.Errorf("missing hide-cursor escape:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerFrames[0]) {
|
||||
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
|
||||
}
|
||||
if !strings.Contains(out, "Publishing...") {
|
||||
t.Errorf("missing label:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerClearLine) {
|
||||
t.Errorf("missing clear-line escape:\n%q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, spinnerShowCursor) {
|
||||
t.Errorf("must end by restoring the cursor:\n%q", out)
|
||||
}
|
||||
}
|
||||
@@ -231,14 +231,9 @@ func TestLoadAutoApproveSet(t *testing.T) {
|
||||
t.Fatal("expected non-empty auto-approve set")
|
||||
}
|
||||
|
||||
// From scope_overrides.json allow list
|
||||
if !aaSet["calendar:calendar.event:create"] {
|
||||
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
|
||||
}
|
||||
|
||||
// Verify allow list entries are present
|
||||
// From scope_priorities.json recommend=="true"
|
||||
if !aaSet["sheets:spreadsheet:read"] {
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
|
||||
}
|
||||
|
||||
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
||||
@@ -257,16 +252,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
if len(allowSet) == 0 {
|
||||
t.Fatal("expected non-empty override allow set")
|
||||
}
|
||||
|
||||
// Known entries from scope_overrides.json
|
||||
if !allowSet["calendar:calendar.event:create"] {
|
||||
t.Error("expected calendar:calendar.event:create in allow set")
|
||||
}
|
||||
if !allowSet["mail:event"] {
|
||||
t.Error("expected mail:event in allow set")
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,9 +266,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAutoApproveScope(t *testing.T) {
|
||||
// Known auto-approve scope (in allow list)
|
||||
if !IsAutoApproveScope("calendar:calendar.event:create") {
|
||||
t.Error("expected calendar:calendar.event:create to be auto-approve")
|
||||
// Known auto-approve scope (recommend=true in scope_priorities.json)
|
||||
if !IsAutoApproveScope("sheets:spreadsheet:read") {
|
||||
t.Error("expected sheets:spreadsheet:read to be auto-approve")
|
||||
}
|
||||
|
||||
// Completely unknown scope
|
||||
@@ -290,9 +279,8 @@ func TestIsAutoApproveScope(t *testing.T) {
|
||||
|
||||
func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
scopes := []string{
|
||||
"calendar:calendar.event:create", // auto-approve (in allow list)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
"sheets:spreadsheet:read", // auto-approve (in allow list)
|
||||
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
}
|
||||
|
||||
result := FilterAutoApproveScopes(scopes)
|
||||
@@ -300,10 +288,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
t.Fatal("expected at least 1 auto-approve scope in result")
|
||||
}
|
||||
|
||||
// Check that calendar:calendar.event:create is included
|
||||
// Check that sheets:spreadsheet:read is included
|
||||
found := false
|
||||
for _, s := range result {
|
||||
if s == "calendar:calendar.event:create" {
|
||||
if s == "sheets:spreadsheet:read" {
|
||||
found = true
|
||||
}
|
||||
// Ensure unknown scopes are not included
|
||||
@@ -312,7 +300,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected calendar:calendar.event:create in result")
|
||||
t.Error("expected sheets:spreadsheet:read in result")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,25 +12,7 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [
|
||||
"calendar:calendar.event:create",
|
||||
"calendar:calendar.event:delete",
|
||||
"calendar:calendar.event:read",
|
||||
"calendar:calendar.event:update",
|
||||
"calendar:calendar.free_busy:read",
|
||||
"calendar:calendar:create",
|
||||
"calendar:calendar:delete",
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar:update",
|
||||
"contact:user.basic_profile:readonly",
|
||||
"mail:event",
|
||||
"mail:user_mailbox.mail_contact:read",
|
||||
"mail:user_mailbox.mail_contact:write",
|
||||
"mail:user_mailbox.message.address:read",
|
||||
"mail:user_mailbox.message.body:read",
|
||||
"mail:user_mailbox.message.subject:read",
|
||||
"mail:user_mailbox.message:readonly"
|
||||
],
|
||||
"allow": [],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -37,9 +40,15 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
verifyTimeout = 10 * time.Second
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
skillsIndexMaxBodySize = 1 << 20
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
}
|
||||
|
||||
r := &NpmResult{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
|
||||
client := transport.NewHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
|
||||
return r
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
|
||||
if _, err := io.Copy(&r.Stdout, limited); err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
if r.Stdout.Len() > skillsIndexMaxBodySize {
|
||||
r.Stdout.Reset()
|
||||
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
|
||||
return r
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -232,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
|
||||
}
|
||||
if result.Stdout.Len() != 0 {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
oldTimeout := skillsIndexFetchTimeout
|
||||
officialSkillsIndexURL = server.URL
|
||||
skillsIndexFetchTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
officialSkillsIndexURL = oldURL
|
||||
skillsIndexFetchTimeout = oldTimeout
|
||||
})
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
var netErr net.Error
|
||||
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
|
||||
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
|
||||
return r
|
||||
}}).ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if !strings.Contains(result.Stdout.String(), "override-skill") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
|
||||
209
internal/skillcontent/reader.go
Normal file
209
internal/skillcontent/reader.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillcontent reads embedded skill content from an injected fs.FS
|
||||
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
|
||||
// fed straight back into `read`.
|
||||
type DirEntry struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
func (r *Reader) List() ([]SkillInfo, error) {
|
||||
entries, err := fs.ReadDir(r.fsys, ".")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
|
||||
}
|
||||
out := make([]SkillInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Skip dirs that aren't real skills (no SKILL.md).
|
||||
if info, ok := r.skillInfo(e.Name()); ok {
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
desc, version, metadata := parseFrontmatter(data)
|
||||
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
|
||||
}
|
||||
|
||||
// ListPath lists one directory layer (no recursion) under "<name>" or
|
||||
// "<name>/<sub>", returning the entries and the cleaned path listed.
|
||||
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
|
||||
name, sub := SplitArg(arg)
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir := name
|
||||
if sub != "" {
|
||||
cleaned, err := cleanSubPath(sub)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir = name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q not found in skill %q", sub, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
|
||||
}
|
||||
}
|
||||
entries, err := fs.ReadDir(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, dir, nil
|
||||
}
|
||||
|
||||
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
|
||||
// separator is a bare skill name (rest "").
|
||||
func SplitArg(arg string) (name, rest string) {
|
||||
name, rest, _ = strings.Cut(arg, "/")
|
||||
return name, rest
|
||||
}
|
||||
|
||||
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
|
||||
// unparseable frontmatter yields ("", "", nil), never an error.
|
||||
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
|
||||
lines := strings.Split(string(skillMD), "\n")
|
||||
if strings.TrimRight(lines[0], "\r") != "---" {
|
||||
return "", "", nil
|
||||
}
|
||||
block := make([]string, 0, len(lines))
|
||||
closed := false
|
||||
for _, ln := range lines[1:] {
|
||||
if strings.TrimRight(ln, "\r") == "---" {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
block = append(block, ln)
|
||||
}
|
||||
if !closed {
|
||||
return "", "", nil
|
||||
}
|
||||
var fm struct {
|
||||
Description string `yaml:"description"`
|
||||
Version string `yaml:"version"`
|
||||
Metadata map[string]any `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
return fm.Description, fm.Version, fm.Metadata
|
||||
}
|
||||
|
||||
func (r *Reader) ReadSkill(name string) ([]byte, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *Reader) ensureSkill(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
info, err := fs.Stat(r.fsys, name)
|
||||
if err != nil || !info.IsDir() {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknownSkill(name string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
|
||||
WithHint("run 'lark-cli skills list' to see available skills")
|
||||
}
|
||||
|
||||
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
|
||||
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
|
||||
func cleanSubPath(relpath string) (string, error) {
|
||||
cleaned := path.Clean(relpath)
|
||||
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
|
||||
// survives; reject it explicitly alongside "../".
|
||||
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
|
||||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid path %q: must be a relative path without '..'", relpath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
|
||||
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cleaned, err := cleanSubPath(relpath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
full := name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q not found in skill %q", relpath, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q is a directory, not a file", relpath)
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, cleaned, nil
|
||||
}
|
||||
290
internal/skillcontent/reader_test.go
Normal file
290
internal/skillcontent/reader_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
"lark-calendar/references/create.md": {Data: []byte("# Create")},
|
||||
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
|
||||
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
|
||||
"lark-im/references/send.md": {Data: []byte("# Send")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
r := New(testFS())
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("got %d skills, want 2", len(skills))
|
||||
}
|
||||
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
|
||||
t.Fatalf("skills not sorted by name: %v", skills)
|
||||
}
|
||||
if skills[0].Description != "Calendar skill" {
|
||||
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
|
||||
}
|
||||
// version is the frontmatter `version:` field, passed through for drift checks.
|
||||
if skills[0].Version != "1.0.0" {
|
||||
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
|
||||
}
|
||||
// metadata is the frontmatter `metadata:` block, passed through verbatim.
|
||||
if skills[0].Metadata == nil {
|
||||
t.Fatal("expected metadata for lark-calendar")
|
||||
}
|
||||
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
|
||||
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
|
||||
}
|
||||
// No frontmatter → empty description and nil metadata (omitted from JSON).
|
||||
if skills[1].Description != "" {
|
||||
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
|
||||
}
|
||||
if skills[1].Metadata != nil {
|
||||
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
|
||||
}
|
||||
if skills[1].Version != "" {
|
||||
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPath(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
// Skill root: direct children only (one layer), each path skill-prefixed.
|
||||
entries, listed, err := r.ListPath("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath root error: %v", err)
|
||||
}
|
||||
if listed != "lark-calendar" {
|
||||
t.Errorf("listed path: got %q", listed)
|
||||
}
|
||||
want := map[string]bool{ // path → isDir
|
||||
"lark-calendar/SKILL.md": false,
|
||||
"lark-calendar/references": true,
|
||||
"lark-calendar/assets": true,
|
||||
}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
|
||||
}
|
||||
for _, e := range entries {
|
||||
isDir, ok := want[e.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected entry %q", e.Path)
|
||||
continue
|
||||
}
|
||||
if e.IsDir != isDir {
|
||||
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
|
||||
}
|
||||
}
|
||||
// Entries are sorted by path.
|
||||
if entries[0].Path != "lark-calendar/SKILL.md" {
|
||||
t.Errorf("entries not sorted: %v", entries)
|
||||
}
|
||||
|
||||
// Subdirectory: one layer under <name>/<subpath>.
|
||||
subEntries, subListed, err := r.ListPath("lark-calendar/references")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath subdir error: %v", err)
|
||||
}
|
||||
if subListed != "lark-calendar/references" {
|
||||
t.Errorf("listed subpath: got %q", subListed)
|
||||
}
|
||||
if len(subEntries) != 2 ||
|
||||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
|
||||
subEntries[1].Path != "lark-calendar/references/create.md" {
|
||||
t.Errorf("subdir entries: got %v", subEntries)
|
||||
}
|
||||
|
||||
// Unknown skill → typed validation error.
|
||||
if _, _, err := r.ListPath("no-such-skill"); err == nil {
|
||||
t.Error("expected error for unknown skill")
|
||||
} else {
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Path that points at a file (not a dir) → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
|
||||
t.Error("expected error listing a file")
|
||||
} else if !strings.Contains(err.Error(), "is a file") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Nonexistent subpath → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Traversal in the subpath is rejected, no listing leaked.
|
||||
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
|
||||
entries, _, err := r.ListPath(bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if entries != nil {
|
||||
t.Errorf("entries leaked for %q: %v", bad, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkill(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, err := r.ReadSkill("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSkill error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
|
||||
t.Errorf("unexpected content: %q", string(data))
|
||||
}
|
||||
|
||||
_, err = r.ReadSkill("no-such-skill")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown skill")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
|
||||
t.Errorf("message: got %q", verr.Message)
|
||||
}
|
||||
|
||||
if _, err := r.ReadSkill("../etc"); err == nil {
|
||||
t.Error("expected error for name with separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadReference(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadReference error: %v", err)
|
||||
}
|
||||
if string(data) != "# Agenda" {
|
||||
t.Errorf("content: got %q", string(data))
|
||||
}
|
||||
if cleaned != "references/agenda.md" {
|
||||
t.Errorf("cleaned path: got %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
|
||||
t.Error("expected directory error")
|
||||
} else if !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
|
||||
data, _, err := r.ReadReference("lark-calendar", bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if data != nil {
|
||||
t.Errorf("content leaked for %q: %q", bad, string(data))
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected validation error for %q, got %T", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDesc string
|
||||
wantVer string
|
||||
wantHasMeta bool
|
||||
}{
|
||||
{
|
||||
name: "description, version and metadata",
|
||||
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
|
||||
wantDesc: "My skill",
|
||||
wantVer: "2.1.0",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "description only, no metadata",
|
||||
input: "---\ndescription: Plain\n---\nbody\n",
|
||||
wantDesc: "Plain",
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
input: "no frontmatter here\n",
|
||||
},
|
||||
{
|
||||
name: "unclosed frontmatter",
|
||||
input: "---\ndescription: Never closed\n",
|
||||
},
|
||||
{
|
||||
name: "malformed YAML inside frontmatter",
|
||||
input: "---\n: bad: yaml: [\n---\nbody\n",
|
||||
},
|
||||
{
|
||||
name: "CRLF line endings",
|
||||
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
|
||||
wantDesc: "CRLF skill",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, ver, meta := parseFrontmatter([]byte(tc.input))
|
||||
if desc != tc.wantDesc {
|
||||
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
if ver != tc.wantVer {
|
||||
t.Errorf("version = %q, want %q", ver, tc.wantVer)
|
||||
}
|
||||
if (meta != nil) != tc.wantHasMeta {
|
||||
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkillMissingFile(t *testing.T) {
|
||||
// Use a separate MapFS so testFS() (and TestList) are unaffected.
|
||||
emptyFS := fstest.MapFS{
|
||||
"lark-empty/references/x.md": {Data: []byte("# X")},
|
||||
}
|
||||
r := New(emptyFS)
|
||||
_, err := r.ReadSkill("lark-empty")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SKILL.md is absent")
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(err, &ierr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,30 @@ func ParseGlobalSkillsJSON(text string) []string {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
|
||||
type officialSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type officialIndex struct {
|
||||
Skills []officialSkill `json:"skills"`
|
||||
}
|
||||
|
||||
var index officialIndex
|
||||
if err := json.Unmarshal([]byte(text), &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range index.Skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
|
||||
return sortedKeys(seen), nil
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -160,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
|
||||
if len(parts) > 0 {
|
||||
candidate := parts[0]
|
||||
// Check if it's a valid official skill name
|
||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
@@ -223,6 +246,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
@@ -258,14 +282,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
@@ -327,6 +346,40 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
indexResult := runner.ListOfficialSkillsIndex()
|
||||
if indexResult == nil || indexResult.Err != nil {
|
||||
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
|
||||
} else {
|
||||
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
|
||||
if err != nil {
|
||||
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
|
||||
} else if len(official) > 0 {
|
||||
return official, "", true
|
||||
} else {
|
||||
reasons = append(reasons, "official skills index contains no skills")
|
||||
}
|
||||
}
|
||||
|
||||
officialResult := runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
|
||||
return nil, strings.Join(reasons, "; "), false
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
if len(official) > 0 {
|
||||
return official, "", true
|
||||
}
|
||||
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
|
||||
} else {
|
||||
reasons = append(reasons, "official skills list returned no skills")
|
||||
}
|
||||
return nil, strings.Join(reasons, "; "), false
|
||||
}
|
||||
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
|
||||
@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
input := `Available Skills
|
||||
│ lark-calendar
|
||||
│ official-shared
|
||||
│ bad/name
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "official-shared"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
@@ -110,6 +123,43 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
|
||||
input := `{
|
||||
"skills": [
|
||||
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
|
||||
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
|
||||
{"name":" lark-base ","description":"Base","files":[]},
|
||||
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
|
||||
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
|
||||
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
|
||||
{"name":"","description":"empty","files":["SKILL.md"]}
|
||||
]
|
||||
}`
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
|
||||
}
|
||||
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`[{"name":"lark-calendar"}]`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`{"skills":[]}`,
|
||||
`{"skills":[{"name":"bad skill"}]}`,
|
||||
} {
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err == nil && len(got) != 0 {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialIndexOut string
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialIndexErr error
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
@@ -166,6 +218,8 @@ type fakeSkillsRunner struct {
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
}
|
||||
@@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func officialSkillsIndexOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"skills":[`)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Global Skills\n\n")
|
||||
@@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
|
||||
f.listedIndex++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialIndexOut)
|
||||
r.Err = f.officialIndexErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
f.listedOfficial++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
@@ -255,9 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
@@ -289,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
if runner.listedIndex != 1 {
|
||||
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||
}
|
||||
if runner.listedOfficial != 0 {
|
||||
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -322,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -342,9 +527,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -367,9 +553,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -391,9 +578,10 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -420,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -445,11 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -477,11 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -510,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -527,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -551,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -576,11 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -601,11 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -625,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -643,9 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
|
||||
@@ -15,8 +15,25 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -16,8 +16,26 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
||||
// Migrated code must call the domain's typed API wrapper or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
//
|
||||
// Files that do not import shortcuts/common are skipped: the legacy helpers
|
||||
// are methods on common.RuntimeContext, so a same-named method on another
|
||||
// receiver (for example the event domain's APIClient interface, whose
|
||||
// implementation classifies into typed errs.* errors) is not a legacy call.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !importsPath(file, commonImportPath) {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
@@ -53,7 +61,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importsPath reports whether the file imports the given package path.
|
||||
func importsPath(file *ast.File, importPath string) bool {
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(imp.Path.Value, "`\"") == importPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -618,6 +618,36 @@ func boom() error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
@@ -662,7 +692,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -784,6 +814,8 @@ func boom() error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -801,9 +833,33 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -851,14 +907,14 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
src := `package contact
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -895,8 +951,16 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/sheets/helpers.go",
|
||||
"shortcuts/slides/slides_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
"shortcuts/wiki/wiki_node_get.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -924,8 +988,114 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||
src := `package calendar
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.RejectDangerousChars("--summary", "x")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
|
||||
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
src := `package contact
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -933,7 +1103,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1003,3 +1173,23 @@ func boom() error {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
||||
// The event domain's APIClient interface has a same-named CallAPI method
|
||||
// whose implementation classifies into typed errs.* errors; without the
|
||||
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
||||
// helper and must not fire.
|
||||
src := `package vc
|
||||
|
||||
import "github.com/larksuite/cli/internal/event"
|
||||
|
||||
func boom(rt event.APIClient) error {
|
||||
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.51",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -21,9 +21,12 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
@@ -42,9 +45,9 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("GET", path, nil, nil)
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
|
||||
@@ -27,9 +27,14 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
|
||||
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope public --require-login`,
|
||||
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope specific --targets '[{"type":"user","id":"<open_id>"}]'`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
|
||||
@@ -64,9 +69,9 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
}
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("PUT", path, nil, body)
|
||||
data, err := rctx.CallAPITyped("PUT", path, nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, "verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))
|
||||
|
||||
@@ -8,9 +8,62 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func testRuntimeAccessScope(t *testing.T, scope, targets, approver string, applyEnabled, requireLogin bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "access-scope-set"}
|
||||
cmd.Flags().String("scope", scope, "")
|
||||
cmd.Flags().String("targets", targets, "")
|
||||
cmd.Flags().String("approver", approver, "")
|
||||
cmd.Flags().Bool("apply-enabled", applyEnabled, "")
|
||||
cmd.Flags().Bool("require-login", requireLogin, "")
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestBuildAccessScopeBody_Branches(t *testing.T) {
|
||||
t.Run("invalid scope", func(t *testing.T) {
|
||||
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "bogus", "", "", false, false)); err == nil {
|
||||
t.Error("unknown scope must error")
|
||||
}
|
||||
})
|
||||
t.Run("specific with all target kinds and approver", func(t *testing.T) {
|
||||
body, err := buildAccessScopeBody(testRuntimeAccessScope(t,
|
||||
"specific",
|
||||
`[{"type":"user","id":"u1"},{"type":"department","id":"d1"},{"type":"chat","id":"c1"}]`,
|
||||
"ou_appr", true, false))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body["scope"] != "Range" {
|
||||
t.Errorf("scope=%v want Range", body["scope"])
|
||||
}
|
||||
for _, k := range []string{"users", "departments", "chats", "apply_config"} {
|
||||
if _, ok := body[k]; !ok {
|
||||
t.Errorf("missing %q in body=%v", k, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("specific with invalid targets JSON", func(t *testing.T) {
|
||||
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "specific", "{bad", "", false, false)); err == nil {
|
||||
t.Error("invalid targets JSON must error")
|
||||
}
|
||||
})
|
||||
t.Run("public sets require_login", func(t *testing.T) {
|
||||
body, err := buildAccessScopeBody(testRuntimeAccessScope(t, "public", "", "", false, true))
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body["scope"] != "All" || body["require_login"] != true {
|
||||
t.Errorf("public body=%v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_Specific(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
@@ -201,3 +254,44 @@ func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAccessScopeTargets_Partitions(t *testing.T) {
|
||||
users, departments, chats := splitAccessScopeTargets([]map[string]interface{}{
|
||||
{"type": "user", "id": "u1"},
|
||||
{"type": "department", "id": "d1"},
|
||||
{"type": "chat", "id": "c1"},
|
||||
{"type": "user", "id": " "}, // empty id skipped
|
||||
{"type": "unknown", "id": "x"}, // unknown type skipped
|
||||
})
|
||||
if len(users) != 1 || users[0] != "u1" {
|
||||
t.Errorf("users=%v want [u1]", users)
|
||||
}
|
||||
if len(departments) != 1 || departments[0] != "d1" {
|
||||
t.Errorf("departments=%v want [d1]", departments)
|
||||
}
|
||||
if len(chats) != 1 || chats[0] != "c1" {
|
||||
t.Errorf("chats=%v want [c1]", chats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetsJSON_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantErr bool
|
||||
}{
|
||||
{"invalid json", "{not json", true},
|
||||
{"empty array", "[]", true},
|
||||
{"bad type", `[{"type":"role","id":"r1"}]`, true},
|
||||
{"empty id", `[{"type":"user","id":" "}]`, true},
|
||||
{"valid", `[{"type":"user","id":"u1"}]`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := validateTargetsJSON(c.in)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("validateTargetsJSON(%q) err=%v wantErr=%v", c.in, err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
71
shortcuts/apps/apps_callapi_typed_test.go
Normal file
71
shortcuts/apps/apps_callapi_typed_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx
|
||||
// response from the apps list endpoint must surface as a typed errs.Problem with
|
||||
// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration
|
||||
// CallAPI path produced a legacy *output.ExitError with no Retryable field, so
|
||||
// this test fails until AppsList is migrated to CallAPITyped.
|
||||
func TestAppsList_503IsRetryableTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Status: 503,
|
||||
// A gateway-style non-JSON body (text/html) forces the status-based
|
||||
// classifier (httpStatusError) rather than the API-envelope path.
|
||||
Headers: http.Header{"Content-Type": []string{"text/html"}},
|
||||
RawBody: []byte("<html><body>503 Service Unavailable</body></html>"),
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error on 503, got nil; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.Problem on 503, got %T: %v", err, err)
|
||||
}
|
||||
if !p.Retryable {
|
||||
t.Fatalf("expected Retryable == true on 503, got Problem=%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsList_SuccessShapeUnchanged pins that the success path is
|
||||
// output-shape-neutral after migration: a 200 envelope still yields a success
|
||||
// stdout envelope carrying the app_id.
|
||||
func TestAppsList_SuccessShapeUnchanged(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"app_id": "a", "name": "n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"app_id": "a"`) {
|
||||
t.Fatalf("stdout missing app_id: %s", got)
|
||||
}
|
||||
}
|
||||
83
shortcuts/apps/apps_chat.go
Normal file
83
shortcuts/apps/apps_chat.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsChat sends a user message to a session, starting/continuing a conversation.
|
||||
// Async: the message is queued and the response carries no business payload (no
|
||||
// turn_id, no next_poll_after_ms — the turn is not generated yet). Poll
|
||||
// +session-get; it returns next_poll_after_ms, and once the turn runs its handle
|
||||
// is in latest_turn.turn_id.
|
||||
|
||||
// Turn cost varies sharply by init state: the first +chat on a not-initialized
|
||||
// app runs a one-time design + first-generation pass server-side (~20-50 min);
|
||||
// chat on an already-initialized app is incremental and finishes in minutes.
|
||||
// The init-state check and matching polling cadence live in the lark-apps
|
||||
// skill reference (references/lark-apps-cloud-dev.md) — the canonical source.
|
||||
var AppsChat = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+chat",
|
||||
Description: "Send a message to a session to start/continue a conversation",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "做一个待办清单页面"`,
|
||||
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "把首页标题改为 我的待办"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "session-id", Desc: "session ID", Required: true},
|
||||
{Name: "message", Desc: "user message text", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("session-id")) == "" {
|
||||
return output.ErrValidation("--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 nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(chatPath(rctx.Str("app-id"), rctx.Str("session-id"))).
|
||||
Desc("Send a message to a session").
|
||||
Body(buildChatBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPITyped("POST", chatPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildChatBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "message sent; poll +session-get for turn status\n")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func chatPath(appID, sessionID string) string {
|
||||
return sessionPath(appID, sessionID) + "/chat"
|
||||
}
|
||||
|
||||
func buildChatBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"message": strings.TrimSpace(rctx.Str("message")),
|
||||
}
|
||||
}
|
||||
104
shortcuts/apps/apps_chat_test.go
Normal file
104
shortcuts/apps/apps_chat_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsChat_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
// +chat is async and returns NO business payload (no turn_id, no
|
||||
// next_poll_after_ms — the turn is not generated yet). turn_id and the
|
||||
// poll interval are read later from +session-get.
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "把首页表头改成蓝色", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["message"] != "把首页表头改成蓝色" {
|
||||
t.Fatalf("body.message = %v", sent["message"])
|
||||
}
|
||||
if _, present := sent["attachment_ids"]; present {
|
||||
t.Fatalf("attachment_ids must not be sent this iteration: %v", sent)
|
||||
}
|
||||
// +chat carries no next_poll_after_ms; the CLI must not fabricate one.
|
||||
if got := stdout.String(); strings.Contains(got, "next_poll_after_ms") {
|
||||
t.Fatalf("stdout must not reference next_poll_after_ms (chat returns none): %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_Pretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "message sent") || !strings.Contains(got, "+session-get") {
|
||||
t.Fatalf("pretty wrong: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_RequiresMessage(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "message") {
|
||||
t.Fatalf("expected --message required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: a non-blank message that fails for another reason must never be echoed.
|
||||
// Here we assert the blank-message error names the field only (no content leak path).
|
||||
func TestAppsChat_ValidationDoesNotEchoMessage(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// blank message triggers validation; the error must mention the flag, not any content.
|
||||
err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "", "--session-id", "conv_x", "--message", "secret-content-xyz", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-content-xyz") {
|
||||
t.Fatalf("validation error must not echo --message content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsChat_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsChat,
|
||||
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"message": "hi"`) {
|
||||
t.Fatalf("dry-run missing message body: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,24 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
|
||||
|
||||
// AppsCreate creates a new Miaoda app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+create",
|
||||
Description: "Create a new Miaoda app",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
|
||||
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -32,13 +38,6 @@ var AppsCreate = common.Shortcut{
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
appType := strings.TrimSpace(rctx.Str("app-type"))
|
||||
if appType == "" {
|
||||
return output.ErrValidation("--app-type is required")
|
||||
}
|
||||
if !validAppTypes[appType] {
|
||||
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -48,9 +47,9 @@ var AppsCreate = common.Shortcut{
|
||||
Body(buildAppsCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, createHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id"))
|
||||
@@ -59,15 +58,13 @@ var AppsCreate = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。
|
||||
var validAppTypes = map[string]bool{
|
||||
"HTML": true,
|
||||
}
|
||||
|
||||
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": strings.TrimSpace(rctx.Str("app-type")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
}
|
||||
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
|
||||
body["description"] = desc
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
@@ -68,7 +69,7 @@ func TestAppsCreate_Success(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--description", "d", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -83,8 +84,8 @@ func TestAppsCreate_Success(t *testing.T) {
|
||||
if sent["name"] != "Demo" {
|
||||
t.Fatalf("body.name = %v", sent["name"])
|
||||
}
|
||||
if sent["app_type"] != "HTML" {
|
||||
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
|
||||
if sent["app_type"] != "html" {
|
||||
t.Fatalf("body.app_type = %v (want html)", sent["app_type"])
|
||||
}
|
||||
if sent["description"] != "d" {
|
||||
t.Fatalf("body.description = %v", sent["description"])
|
||||
@@ -108,7 +109,7 @@ func TestAppsCreate_WithIconURL(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -133,7 +134,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--format", "pretty", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -144,7 +145,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
|
||||
|
||||
func TestAppsCreate_RequiresName(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "html", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("expected name required error, got %v", err)
|
||||
}
|
||||
@@ -159,20 +160,31 @@ func TestAppsCreate_RequiresAppType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict
|
||||
// lowercase enum (html / full_stack). Unknown values and legacy uppercase are
|
||||
// both rejected by the flag's Enum — the CLI does not normalize case; legacy
|
||||
// uppercase compatibility is a server-side concern, not surfaced by the client.
|
||||
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("expected unsupported app-type error, got %v", err)
|
||||
for _, appType := range []string{"spa", "HTML", "Full_Stack"} {
|
||||
t.Run(appType, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid value") {
|
||||
t.Fatalf("expected invalid-enum error for %q, got %v", appType, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "full_stack") {
|
||||
t.Fatalf("expected enum error to list allowed values, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -183,7 +195,55 @@ func TestAppsCreate_DryRun(t *testing.T) {
|
||||
if !strings.Contains(got, `"name": "Demo"`) {
|
||||
t.Fatalf("dry-run missing body: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"app_type": "HTML"`) {
|
||||
if !strings.Contains(got, `"app_type": "html"`) {
|
||||
t.Fatalf("dry-run missing app_type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_FullstackSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["app_type"] != "full_stack" {
|
||||
t.Fatalf("body.app_type = %v (want full_stack)", sent["app_type"])
|
||||
}
|
||||
if _, present := sent["message"]; present {
|
||||
t.Fatalf("message should never be sent: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"app_type": "full_stack"`) {
|
||||
t.Fatalf("dry-run missing app_type full_stack: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"message"`) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
300
shortcuts/apps/apps_db_audit_list.go
Normal file
300
shortcuts/apps/apps_db_audit_list.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
|
||||
// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON
|
||||
// (INSERT 无 before、DELETE 无 after),json 还原成对象。
|
||||
//
|
||||
// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 /
|
||||
// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。
|
||||
var AppsDBAuditList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-list",
|
||||
Description: "List row-change audit events for one or more tables (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders",
|
||||
"Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(auditListTables(rctx)) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table")
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditListPath(appID)).
|
||||
Desc("List Miaoda app table audit events").
|
||||
Params(buildAuditListParams(rctx, auditListTables(rctx)))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requested := auditListTables(rctx)
|
||||
env := rctx.Str("env")
|
||||
|
||||
// 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤,
|
||||
// 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。
|
||||
// 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。
|
||||
queryTables := requested
|
||||
var skipped []auditSkippedEntry
|
||||
if len(requested) > 1 {
|
||||
queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
// 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。
|
||||
if len(queryTables) == 0 {
|
||||
out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, len(requested))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectAuditLogItems(data["items"])
|
||||
data["items"] = items
|
||||
// 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。
|
||||
if len(skipped) > 0 {
|
||||
data["skipped"] = skipped
|
||||
} else {
|
||||
delete(data, "skipped")
|
||||
}
|
||||
multi := len(requested) > 1
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderAuditListPretty(w, items, skipped, len(requested), multi)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。
|
||||
type auditSkippedEntry struct {
|
||||
Table string `json:"table"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。
|
||||
func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) {
|
||||
existing, err := fetchExistingTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
enabled, err := fetchAuditEnabledTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
valid := make([]string, 0, len(requested))
|
||||
var skipped []auditSkippedEntry
|
||||
for _, t := range requested {
|
||||
switch {
|
||||
case !existing[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"})
|
||||
case !enabled[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"})
|
||||
default:
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
return valid, skipped, nil
|
||||
}
|
||||
|
||||
// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。
|
||||
func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if name := common.GetString(it, "name"); name != "" {
|
||||
existing[name] = true
|
||||
}
|
||||
}
|
||||
token = common.GetString(data, "page_token")
|
||||
if data["has_more"] != true || token == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := map[string]bool{}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if it["enabled"] == true {
|
||||
if name := common.GetString(it, "table"); name != "" {
|
||||
enabled[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。
|
||||
func asMapSlice(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditListTables 取 --table 切片,trim 去空。
|
||||
func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
out := make([]string, 0)
|
||||
for _, t := range rctx.StrSlice("table") {
|
||||
if v := strings.TrimSpace(t); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type auditLogItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventTime string `json:"event_time"`
|
||||
TargetTable string `json:"target_table"`
|
||||
Type string `json:"type"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Before interface{} `json:"before,omitempty"`
|
||||
After interface{} `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItem(operator 解析、before/after 还原成对象)。
|
||||
func projectAuditLogItems(raw interface{}) []auditLogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]auditLogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := auditLogItem{
|
||||
EventID: common.GetString(m, "event_id"),
|
||||
EventTime: common.GetString(m, "event_time"),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
Type: common.GetString(m, "type"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
}
|
||||
// before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。
|
||||
if b := common.GetString(m, "before"); b != "" {
|
||||
row.Before = safeParseJSON(b)
|
||||
}
|
||||
if a := common.GetString(m, "after"); a != "" {
|
||||
row.After = safeParseJSON(a)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。
|
||||
func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
return
|
||||
}
|
||||
var headers []string
|
||||
if multi {
|
||||
headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"}
|
||||
} else {
|
||||
headers = []string{"event_time", "type", "event_id", "operator", "summary"}
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)}
|
||||
if multi {
|
||||
cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
}
|
||||
|
||||
// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。
|
||||
func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) {
|
||||
if len(skipped) == 0 {
|
||||
return
|
||||
}
|
||||
parts := make([]string, 0, len(skipped))
|
||||
for _, s := range skipped {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason))
|
||||
}
|
||||
fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", "))
|
||||
}
|
||||
142
shortcuts/apps/apps_db_audit_set.go
Normal file
142
shortcuts/apps/apps_db_audit_set.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 审计保留期合法取值。
|
||||
var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"}
|
||||
|
||||
const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id <app_id>`"
|
||||
|
||||
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。
|
||||
var AppsDBAuditEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-enable",
|
||||
Description: "Enable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env")}).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
retention := rctx.Str("retention")
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": rctx.Str("env")},
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
}
|
||||
st := auditSetStatus(data, table)
|
||||
ret := common.GetString(st, "retention")
|
||||
if ret == "" {
|
||||
ret = retention
|
||||
}
|
||||
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBAuditDisable 关闭某张表的行级审计。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。
|
||||
var AppsDBAuditDisable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-disable",
|
||||
Description: "Disable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env")}).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": rctx.Str("env")},
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
}
|
||||
st := auditSetStatus(data, table)
|
||||
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。
|
||||
func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} {
|
||||
if st, ok := data["status"].(map[string]interface{}); ok {
|
||||
if common.GetString(st, "table") == "" {
|
||||
st["table"] = table
|
||||
}
|
||||
return st
|
||||
}
|
||||
return map[string]interface{}{"table": table}
|
||||
}
|
||||
139
shortcuts/apps/apps_db_audit_status.go
Normal file
139
shortcuts/apps/apps_db_audit_status.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false);
|
||||
// 不指定返回所有已配置表。json 单表返对象、多表返数组;pretty 单表 key/value、多表表格。
|
||||
var AppsDBAuditStatus = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-status",
|
||||
Description: "Show table audit (row-change tracking) status",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-status --app-id <app_id>",
|
||||
"Check one table: --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditStatusPath(appID)).
|
||||
Desc("Get table audit status").
|
||||
Params(buildAuditStatusParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
items := projectAuditStatusItems(data["items"])
|
||||
// 单表查询但后端无记录 → 占位 enabled=false(与 miaoda 一致)。
|
||||
if table != "" && len(items) == 0 {
|
||||
items = []map[string]interface{}{{"table": table, "enabled": false}}
|
||||
}
|
||||
// json:单表返对象、多表返数组。
|
||||
var out interface{}
|
||||
if table != "" && len(items) == 1 {
|
||||
out = items[0]
|
||||
} else {
|
||||
out = map[string]interface{}{"items": items}
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderAuditStatusPretty(w, items, table)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": rctx.Str("env")}
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。
|
||||
func projectAuditStatusItems(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]interface{}{
|
||||
"table": common.GetString(m, "table"),
|
||||
"enabled": m["enabled"] == true,
|
||||
}
|
||||
if v := common.GetString(m, "enabled_at"); v != "" {
|
||||
row["enabled_at"] = v
|
||||
}
|
||||
if v := common.GetString(m, "retention"); v != "" {
|
||||
row["retention"] = v
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditStatusPretty 单表渲染 key/value、多表渲染对齐表格(table/enabled/enabled_at/retention)。
|
||||
func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit configuration found.\n")
|
||||
return
|
||||
}
|
||||
yesNo := func(m map[string]interface{}) string {
|
||||
if m["enabled"] == true {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) }
|
||||
// 单表 → key/value
|
||||
if table != "" && len(items) == 1 {
|
||||
it := items[0]
|
||||
renderKeyValuePairs(w, [][2]string{
|
||||
{"table", common.GetString(it, "table")},
|
||||
{"enabled", yesNo(it)},
|
||||
{"enabled_at", get(it, "enabled_at")},
|
||||
{"retention", get(it, "retention")},
|
||||
})
|
||||
return
|
||||
}
|
||||
// 多表 → 表格
|
||||
headers := []string{"table", "enabled", "enabled_at", "retention"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
316
shortcuts/apps/apps_db_audit_test.go
Normal file
316
shortcuts/apps/apps_db_audit_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
|
||||
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
|
||||
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
|
||||
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
|
||||
)
|
||||
|
||||
// ── audit-status ──
|
||||
|
||||
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
|
||||
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// 单表无记录 → 占位对象 enabled:false(不是数组)。
|
||||
var env struct {
|
||||
Data struct {
|
||||
Table string `json:"table"`
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Table != "orders" || env.Data.Enabled {
|
||||
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
|
||||
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
|
||||
map[string]interface{}{"table": "users", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
|
||||
t.Fatalf("pretty table malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-enable / disable ──
|
||||
|
||||
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
|
||||
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required, exit 1
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
// 非法 retention → enum 校验 (validation)
|
||||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--retention" {
|
||||
t.Fatalf("Param = %q, want --retention", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
// dry-run body {table, enabled:true, retention}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
|
||||
// success
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-list ──
|
||||
|
||||
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
|
||||
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。
|
||||
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
|
||||
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
|
||||
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
|
||||
"before": `{"amount":100}`, "after": `{"amount":999}`,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// operator → 对象;before/after → 还原成对象(非字符串)。
|
||||
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"skipped"`) {
|
||||
t.Errorf("single-table query must not emit skipped:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `"before": "{`) {
|
||||
t.Errorf("before should be an object, not a JSON string:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
|
||||
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
|
||||
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list,
|
||||
// 不存在 / 未开启审计的表进 skipped。
|
||||
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
|
||||
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// schema:orders/users/carts 存在,ghost 不存在。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
|
||||
}}},
|
||||
})
|
||||
// status:orders/users 开启审计,carts 未开启。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
|
||||
map[string]interface{}{"table": "carts", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
// audit_list 只应被传入有效表 orders,users。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("tables"); got != "orders,users" {
|
||||
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
|
||||
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。
|
||||
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
|
||||
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"},
|
||||
}}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
// 不注册 audit_list:若被调用会命中未注册请求而报错。
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
|
||||
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
|
||||
}
|
||||
}
|
||||
150
shortcuts/apps/apps_db_changelog_list.go
Normal file
150
shortcuts/apps/apps_db_changelog_list.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbChangelogHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。
|
||||
// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name},
|
||||
// json 还原成对象、pretty 只展示 name。
|
||||
var AppsDBChangelogList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-changelog-list",
|
||||
Description: "List a Miaoda app database's DDL change history (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-changelog-list --app-id <app_id>",
|
||||
"Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "table", Desc: "filter by target table"},
|
||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appChangelogListPath(appID)).
|
||||
Desc("List Miaoda app DDL changelog").
|
||||
Params(buildChangelogParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectChangelogItems(data["items"])
|
||||
data["items"] = items
|
||||
changeID := strings.TrimSpace(rctx.Str("change-id"))
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderChangelogPretty(w, items, changeID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("table", "table")
|
||||
addStr("change-id", "change_id")
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type changelogItem struct {
|
||||
ChangeID string `json:"change_id"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
TargetTable string `json:"target_table"`
|
||||
ChangeType string `json:"change_type"`
|
||||
Summary string `json:"summary"`
|
||||
Statement string `json:"statement,omitempty"`
|
||||
}
|
||||
|
||||
// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItem(operator 解析成对象)。
|
||||
func projectChangelogItems(raw interface{}) []changelogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]changelogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, changelogItem{
|
||||
ChangeID: common.GetString(m, "change_id"),
|
||||
ChangedAt: common.GetString(m, "changed_at"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
ChangeType: common.GetString(m, "change_type"),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。
|
||||
func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) {
|
||||
if len(items) == 0 {
|
||||
if changeID != "" {
|
||||
fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID)
|
||||
} else {
|
||||
io.WriteString(w, "No DDL changes found.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{
|
||||
it.ChangeID,
|
||||
dashIfEmpty(it.ChangedAt),
|
||||
operatorName(it.Operator),
|
||||
dashIfEmpty(it.TargetTable),
|
||||
it.ChangeType,
|
||||
dashIfEmpty(it.Summary),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
143
shortcuts/apps/apps_db_changelog_list_test.go
Normal file
143
shortcuts/apps/apps_db_changelog_list_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list"
|
||||
|
||||
// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。
|
||||
func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--env", "dev", "--table", "orders",
|
||||
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbChangelogURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" {
|
||||
t.Fatalf("params = %v", a.Params)
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_RejectsBadSince 验证不可解析的 --since 报 --since 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--since" {
|
||||
t.Fatalf("Param = %q, want --since", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_SuccessParsesOperator 验证成功响应中 operator JSON 串被解析为对象并输出变更字段。
|
||||
func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbChangelogURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"change_id": "01J", "changed_at": "2026-04-15T10:30:00Z",
|
||||
"operator": `{"id":"7311","name":"alice"}`, "target_table": "orders",
|
||||
"change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_ChangeIDNotFoundPretty 验证按 --change-id 查询无结果时 pretty 打印 not-found 提示。
|
||||
func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbChangelogURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") {
|
||||
t.Fatalf("expected not-found message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOperator_Cases 验证 parseOperator 处理合法 JSON、空 name 回退 id、非 JSON 原样、空串返回 nil,以及 operatorName(nil) 为占位符。
|
||||
func TestParseOperator_Cases(t *testing.T) {
|
||||
if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" {
|
||||
t.Fatalf("valid: %#v", op)
|
||||
}
|
||||
if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" {
|
||||
t.Fatalf("name fallback to id: %#v", op)
|
||||
}
|
||||
if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" {
|
||||
t.Fatalf("non-json raw: %#v", op)
|
||||
}
|
||||
if op := parseOperator(""); op != nil {
|
||||
t.Fatalf("empty → nil, got %#v", op)
|
||||
}
|
||||
if operatorName(nil) != "—" {
|
||||
t.Fatalf("nil operatorName should be —")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeParseJSON_Cases 验证 safeParseJSON 合法 JSON 解析为对象、非法 JSON 原样返回字符串。
|
||||
func TestSafeParseJSON_Cases(t *testing.T) {
|
||||
if v := safeParseJSON(`{"a":1}`); v == nil {
|
||||
t.Fatalf("valid json → object")
|
||||
}
|
||||
if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" {
|
||||
t.Fatalf("invalid json → raw string, got %v", v)
|
||||
}
|
||||
}
|
||||
189
shortcuts/apps/apps_db_data_export.go
Normal file
189
shortcuts/apps/apps_db_data_export.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataExportMaxRows = 5000
|
||||
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
|
||||
|
||||
// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。
|
||||
// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。
|
||||
// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 <table>.csv);上限 5000 行 / 1 MB。
|
||||
var AppsDBDataExport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-export",
|
||||
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
|
||||
"Format follows the --output extension: .csv / .json / .sql (default csv).",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "source db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table")
|
||||
}
|
||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||
}
|
||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
format, _, _ := exportFormatAndOutput(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": rctx.Str("env"), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
format, out, err := exportFormatAndOutput(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, rctx.Str("env"), table)
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{rctx.Str("env")},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
// 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。
|
||||
if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' {
|
||||
if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil {
|
||||
return withAppsHint(cerr, dbDataExportHint)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
body := resp.RawBody
|
||||
if len(body) > dbDataExportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body))
|
||||
}
|
||||
|
||||
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(body)),
|
||||
}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output")
|
||||
}
|
||||
// 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。
|
||||
rows := 0
|
||||
if totalErr == nil {
|
||||
rows = total
|
||||
if lim := rctx.Int("limit"); rows > lim {
|
||||
rows = lim
|
||||
}
|
||||
} else {
|
||||
rows = countDataRows(body, format)
|
||||
}
|
||||
resolved, perr := rctx.FileIO().ResolvePath(out)
|
||||
if perr != nil || resolved == "" {
|
||||
resolved = out
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"table": table, "output": resolved, "format": format,
|
||||
"rows": rows, "size_bytes": saved.Size(),
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalAsInt(raw["total"]), nil
|
||||
}
|
||||
|
||||
// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。
|
||||
func totalAsInt(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exportFormatAndOutput 由 --output 推断数据格式与落盘路径:
|
||||
// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出 <table>.csv。
|
||||
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
return "csv", table + ".csv", nil
|
||||
}
|
||||
f, ferr := resolveDataFormat(filepath.Ext(out), true)
|
||||
if ferr != nil {
|
||||
return "", "", ferr
|
||||
}
|
||||
return f, out, nil
|
||||
}
|
||||
193
shortcuts/apps/apps_db_data_export_test.go
Normal file
193
shortcuts/apps/apps_db_data_export_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
|
||||
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
|
||||
|
||||
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required-flag, exit 1
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected required-flag error for missing --table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。
|
||||
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
|
||||
for _, lim := range []string{"0", "-1", "5001"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
|
||||
}
|
||||
if ve.Param != "--limit" {
|
||||
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。
|
||||
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:format 跟随 --output 扩展名;缺省 csv。
|
||||
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。
|
||||
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
cases := []struct{ output, wantFmt string }{
|
||||
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
|
||||
if c.output != "" {
|
||||
args = append(args, "--output", c.output)
|
||||
}
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
|
||||
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
|
||||
}
|
||||
if _, ok := a.Params["limit"]; !ok {
|
||||
t.Errorf("dry-run missing limit param")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
|
||||
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
|
||||
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// 第 1 步:records 列表 total=2(行数来源)。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
|
||||
})
|
||||
// 第 2 步:导出原始字节。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n"),
|
||||
Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
b, err := os.ReadFile(dir + "/orders.csv")
|
||||
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
|
||||
t.Fatalf("output file wrong: %q err=%v", string(b), err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
|
||||
t.Fatalf("output json missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 行数取自 records total,且按 --limit 截顶(min(total, limit))。
|
||||
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。
|
||||
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 100`) {
|
||||
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
|
||||
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
|
||||
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("export should still succeed via fallback, got %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(dir + "/orders.csv")
|
||||
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
|
||||
t.Fatalf("file not written on fallback path: %q", string(b))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 3`) {
|
||||
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。
|
||||
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
|
||||
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
|
||||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
if _, statErr := os.Stat("nope.csv"); statErr == nil {
|
||||
t.Fatalf("error path must not write the output file")
|
||||
}
|
||||
}
|
||||
142
shortcuts/apps/apps_db_data_import.go
Normal file
142
shortcuts/apps/apps_db_data_import.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches"
|
||||
|
||||
// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与
|
||||
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
|
||||
// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
|
||||
var AppsDBDataImport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-import",
|
||||
Description: "Import rows from a local csv/json file into a Miaoda app table",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes",
|
||||
"Table defaults to the file name; override with --table.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("file")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
// 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。
|
||||
if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil {
|
||||
return err
|
||||
}
|
||||
// 体积守卫前移到 Validate:用 Stat 先查大小(不读内容),dry-run 也能拦超大文件、且
|
||||
// 在读整个文件进内存之前就失败(对齐 +file-upload)。Stat 失败不在此报错,留给 Execute
|
||||
// 的 ReadInputFile 产出更精确的「文件不存在/越界」错误。
|
||||
if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file")
|
||||
}
|
||||
if importTableName(rctx) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
fileName := filepath.Base(strings.TrimSpace(rctx.Str("file")))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env"), "table": importTableName(rctx)}).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file")
|
||||
}
|
||||
if len(content) > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file")
|
||||
}
|
||||
fileName := filepath.Base(file)
|
||||
table := importTableName(rctx)
|
||||
|
||||
// multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{rctx.Str("env")}, "table": []string{table}},
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint)
|
||||
}
|
||||
data, err := rctx.ClassifyAPIResponse(resp)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbDataImportHint)
|
||||
}
|
||||
|
||||
outTable := common.GetString(data, "table")
|
||||
if outTable == "" {
|
||||
outTable = table
|
||||
}
|
||||
rows := int64(0)
|
||||
if f, ok := numericAsFloat(data["rows"]); ok {
|
||||
rows = int64(f)
|
||||
}
|
||||
out := map[string]interface{}{"file": file, "table": outTable, "rows": rows}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// importTableName 取目标表名:--table 优先,否则文件名去扩展名。
|
||||
func importTableName(rctx *common.RuntimeContext) string {
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
return t
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Base(f)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
161
shortcuts/apps/apps_db_data_import_test.go
Normal file
161
shortcuts/apps/apps_db_data_import_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import"
|
||||
|
||||
// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。
|
||||
func chdirTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBDataImport_RequiresAppID(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt)报不支持格式的校验错误。
|
||||
func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("data.txt", []byte("x\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。
|
||||
func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation_required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。
|
||||
func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
// >1MB → size 校验
|
||||
big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...)
|
||||
_ = os.WriteFile("big.csv", big, 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected 1MB limit error, got %T %v", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。
|
||||
// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态:file_name+file 走 body、env+table 走 query 且不再发 format。
|
||||
func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--env", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbDataImportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil {
|
||||
t.Fatalf("dry-run body should carry file_name + file: %v", a.Body)
|
||||
}
|
||||
if _, ok := a.Body["format"]; ok {
|
||||
t.Fatalf("format must no longer be sent: %v", a.Body)
|
||||
}
|
||||
if a.Params["env"] != "dev" || a.Params["table"] != "orders" {
|
||||
t.Fatalf("dry-run params (env+table) = %v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbDataImportURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) {
|
||||
t.Fatalf("output missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名(customers.json→customers)。
|
||||
func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Params["table"] != "customers" {
|
||||
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
98
shortcuts/apps/apps_db_env_create.go
Normal file
98
shortcuts/apps/apps_db_env_create.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBEnvCreate creates a DB environment for a Miaoda app(拆分单库为 dev/online 多环境)。
|
||||
//
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
|
||||
var AppsDBEnvCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-create",
|
||||
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
|
||||
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDbEnvCreatePath(appID)).
|
||||
Desc("Create Miaoda app DB environment").
|
||||
Body(buildDBEnvCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvCreateHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderEnvCreatePretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。
|
||||
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
|
||||
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"sync_data": rctx.Bool("sync-data"),
|
||||
}
|
||||
}
|
||||
|
||||
// renderEnvCreatePretty 输出 4 行(pretty 模式):
|
||||
//
|
||||
// ✓ Multi-env initialized
|
||||
// Environments: dev, online
|
||||
// Data synced: yes
|
||||
// Note: structure changes in dev now need to be released to online.
|
||||
func renderEnvCreatePretty(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintln(w, "✓ Multi-env initialized")
|
||||
|
||||
if envs, ok := data["environments"].([]interface{}); ok && len(envs) > 0 {
|
||||
names := make([]string, 0, len(envs))
|
||||
for _, e := range envs {
|
||||
if s, ok := e.(string); ok {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Environments: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
synced := "no"
|
||||
if ds, ok := data["data_synced"].(bool); ok && ds {
|
||||
synced = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, "Data synced: %s\n", synced)
|
||||
|
||||
fmt.Fprintln(w, "Note: structure changes in dev now need to be released to online.")
|
||||
}
|
||||
124
shortcuts/apps/apps_db_env_create_test.go
Normal file
124
shortcuts/apps/apps_db_env_create_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", // URL 仍走 db_dev_init,CLI 命令名 +db-env-create
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"status": "initialized",
|
||||
"environments": []interface{}{"dev", "online"},
|
||||
"data_synced": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["sync_data"] != true {
|
||||
t.Fatalf("body.sync_data = %v (want true)", sent["sync_data"])
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "initialized") {
|
||||
t.Fatalf("stdout should include status, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 不传 --sync-data(默认)→ body.sync_data=false
|
||||
func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "initialized"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["sync_data"] != false {
|
||||
t.Fatalf("body.sync_data = %v (want false by default)", sent["sync_data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"status": "initialized",
|
||||
"environments": []interface{}{"dev", "online"},
|
||||
"data_synced": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
wantLines := []string{
|
||||
"✓ Multi-env initialized",
|
||||
"Environments: dev, online",
|
||||
"Data synced: yes",
|
||||
"Note: structure changes in dev now need to be released to online.",
|
||||
}
|
||||
for _, line := range wantLines {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("pretty output missing line %q\ngot:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/db_dev_init") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --env 只接受 dev:传 online 应被 enum 校验拒绝。
|
||||
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
191
shortcuts/apps/apps_db_env_migrate.go
Normal file
191
shortcuts/apps/apps_db_env_migrate.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`"
|
||||
|
||||
// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。
|
||||
// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
|
||||
var AppsDBEnvDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-diff",
|
||||
Description: "Preview pending dev→online schema changes (no apply)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-diff --app-id <app_id>",
|
||||
"Apply the previewed changes with +db-env-migrate --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(data, "from"), common.GetString(data, "to")
|
||||
changes := projectMigrationChanges(data["changes"])
|
||||
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderMigrationDiff(w, from, to, changes)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status
|
||||
// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
|
||||
var AppsDBEnvMigrate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-migrate",
|
||||
Description: "Publish pending dev→online schema changes (irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
|
||||
"Preview first with +db-env-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
applied = len(projectMigrationChanges(submit["changes"]))
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "applied", "migrated":
|
||||
return true, nil
|
||||
case "failed":
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if n := intFromAny(final["changes_applied"]); n > 0 {
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type migrationChange struct {
|
||||
Type string `json:"type"`
|
||||
Table string `json:"table"`
|
||||
Statement string `json:"statement"`
|
||||
}
|
||||
|
||||
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。
|
||||
func projectMigrationChanges(raw interface{}) []migrationChange {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]migrationChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, migrationChange{
|
||||
Type: common.GetString(m, "type"),
|
||||
Table: common.GetString(m, "table"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
|
||||
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
|
||||
if len(changes) == 0 {
|
||||
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s\n", c.Statement)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。
|
||||
func migrateFailMsg(d map[string]interface{}, taskID string) string {
|
||||
if m := common.GetString(d, "error_message"); m != "" {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
|
||||
}
|
||||
|
||||
// intFromAny 把 JSON number / json.Number 转 int(计数用)。
|
||||
func intFromAny(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
369
shortcuts/apps/apps_db_env_recovery_quota_test.go
Normal file
369
shortcuts/apps/apps_db_env_recovery_quota_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate"
|
||||
dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status"
|
||||
dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery"
|
||||
dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status"
|
||||
dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status"
|
||||
dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota"
|
||||
)
|
||||
|
||||
// ── env-diff ──
|
||||
|
||||
// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体:POST env_migrate 且 dry_run=true。
|
||||
func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。
|
||||
func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"from": "dev", "to": "online",
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") {
|
||||
t.Fatalf("pretty diff malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。
|
||||
func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No pending changes from dev to online.") {
|
||||
t.Fatalf("expected empty message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── env-migrate ──
|
||||
|
||||
// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false(真实迁移)。
|
||||
func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") {
|
||||
t.Fatalf("pretty: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "lock timeout") {
|
||||
t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+db-env-diff") {
|
||||
t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。
|
||||
func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-diff ──
|
||||
|
||||
// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。
|
||||
func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --target error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。
|
||||
func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。
|
||||
func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"preview_status": "success", "tables_affected": 2, "estimated_seconds": 12,
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2},
|
||||
map[string]interface{}{"table": "carts", "action": "restore_table"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error,携带 message 与 PITR window hint。
|
||||
func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "snapshot expired") {
|
||||
t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "PITR window") {
|
||||
t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-apply ──
|
||||
|
||||
// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。
|
||||
func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No changes — database is already at this state.") {
|
||||
t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。
|
||||
func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryApplyURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。
|
||||
func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── quota-get ──
|
||||
|
||||
// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。
|
||||
func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0,
|
||||
"tables": 4, "views": 1,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") {
|
||||
t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") {
|
||||
t.Fatalf("expected used + tables retained:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views(及配额已对接时的
|
||||
// quota/usage_percent),后端额外字段不透传。
|
||||
func TestProjectDbQuota_WhitelistsFields(t *testing.T) {
|
||||
out := projectDbQuota(map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
|
||||
"tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1",
|
||||
})
|
||||
if _, ok := out["storage_quota_bytes"]; ok {
|
||||
t.Errorf("zero quota should be omitted: %v", out)
|
||||
}
|
||||
if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 {
|
||||
t.Errorf("whitelisted fields should be kept: %v", out)
|
||||
}
|
||||
for _, leaked := range []string{"tenant_key", "internal_shard"} {
|
||||
if _, ok := out[leaked]; ok {
|
||||
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
|
||||
}
|
||||
}
|
||||
|
||||
out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2})
|
||||
if _, ok := out2["storage_quota_bytes"]; !ok {
|
||||
t.Errorf("non-zero quota should be kept: %v", out2)
|
||||
}
|
||||
if _, ok := out2["usage_percent"]; !ok {
|
||||
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
|
||||
}
|
||||
}
|
||||
616
shortcuts/apps/apps_db_execute.go
Normal file
616
shortcuts/apps/apps_db_execute.go
Normal file
@@ -0,0 +1,616 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBExecute executes SQL against a Miaoda app database.
|
||||
//
|
||||
// POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式
|
||||
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。
|
||||
//
|
||||
// pretty 渲染 6 种形态:
|
||||
// - 单 SELECT:表格(列间两空格、列对齐填充)
|
||||
// - 空 SELECT:`(0 rows)`
|
||||
// - 单 DML:`✓ N row(s) <verb>`(verb 跟 sql_type:INSERT→inserted/UPDATE→updated/DELETE→deleted)
|
||||
// - 单 DDL:`✓ DDL executed`
|
||||
// - 多语句全部成功:逐条 `Statement K: ✓ <summary>` + 末尾 `✓ N statements executed`
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后升级成 typed errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息
|
||||
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无
|
||||
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
|
||||
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。
|
||||
//
|
||||
// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`)
|
||||
// - 单 DML → data = `{command, rows_affected}`
|
||||
// - 单 DDL → data = `{command}`
|
||||
// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
|
||||
//
|
||||
// 字段裁剪用框架原生 --jq/-q。
|
||||
//
|
||||
// Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
|
||||
//
|
||||
// SQL 来源二选一:--sql(内联文本,或 - 读 stdin)/ --file(.sql 文件路径,受 CLI 相对路径约束)。
|
||||
// --file 在 Validate 阶段读出内容、归一化到 --sql,下游统一从 rctx.Str("sql") 取。
|
||||
var AppsDBExecute = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-execute",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
|
||||
"Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return output.ErrValidation("--sql and --file are mutually exclusive")
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--file: %v", 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 nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appSQLPath(appID)).
|
||||
Desc("Execute SQL on Miaoda app database").
|
||||
Params(buildDBSQLParams(rctx)).
|
||||
Body(buildDBSQLBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("POST", appSQLPath(appID),
|
||||
buildDBSQLParams(rctx),
|
||||
buildDBSQLBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
|
||||
}
|
||||
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态,
|
||||
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
|
||||
stmts := parseSQLResult(common.GetString(raw, "result"))
|
||||
// JSON data 形态(不再透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 [{...}](空 → [])
|
||||
// - 单 DML → data = {command, rows_affected}
|
||||
// - 单 DDL → data = {command}
|
||||
// - 多语句 → data = [{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]
|
||||
// 字段裁剪走框架原生 --jq/-q(不引入 miaoda 的 --json <fields>)。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错
|
||||
// (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := shapeSQLData(stmts)
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
}
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderSQLPretty(w, stmts)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态:
|
||||
// - 无语句 → [](空数组)
|
||||
// - 单条语句 → singleStatementJSON(SELECT 是行数组、DML/DDL 是对象)
|
||||
// - 多条语句 → []multiStatementElement(每条统一成 {command,...} 对象,SELECT 行放 rows)
|
||||
//
|
||||
// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。
|
||||
func shapeSQLData(stmts []map[string]interface{}) interface{} {
|
||||
if len(stmts) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
if len(stmts) == 1 {
|
||||
return singleStatementJSON(stmts[0])
|
||||
}
|
||||
out := make([]interface{}, 0, len(stmts))
|
||||
for _, s := range stmts {
|
||||
out = append(out, multiStatementElement(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// singleStatementJSON 单条语句的 PRD JSON 形态:
|
||||
// - SELECT → 行数组(空 → [])
|
||||
// - DML → {command, rows_affected}
|
||||
// - DDL / OK / 其它 → {command}
|
||||
func singleStatementJSON(s map[string]interface{}) interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return selectRows(s)
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成
|
||||
// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。
|
||||
func multiStatementElement(s map[string]interface{}) map[string]interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)}
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组;
|
||||
// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null)。
|
||||
func selectRows(s map[string]interface{}) []map[string]interface{} {
|
||||
dataJSON := strings.TrimSpace(common.GetString(s, "data"))
|
||||
if dataJSON == "" || dataJSON == "null" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。
|
||||
// 返回失败语句下标(0-based)、该 ERROR statement、是否命中。
|
||||
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
|
||||
for i, s := range stmts {
|
||||
if common.GetString(s, "sql_type") == "ERROR" {
|
||||
return i, s, true
|
||||
}
|
||||
}
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIError(CategoryAPI → exit 1)。
|
||||
//
|
||||
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
|
||||
// message + hint 的人类可读文案(errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
|
||||
// miaoda-cli(src/cli/handlers/db/sql.ts、src/api/db/api.ts):
|
||||
// - message 末尾 "(at statement N of M)" 给出失败位置;
|
||||
// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回):
|
||||
// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted.";
|
||||
// 否则前序语句已逐条 commit、未回滚(flat 信封无逐句 breakdown,故 hint 简述前序已落地 + 从失败处续跑)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
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))
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case inferRolledBack(stmts[:errIdx]):
|
||||
hint = "Transaction rolled back; no changes persisted."
|
||||
case errIdx > 0:
|
||||
hint = fmt.Sprintf("Earlier statements were committed and not rolled back; fix statement %d and re-run the remaining statements.", stmtNo)
|
||||
default:
|
||||
hint = "No statements were applied; fix the SQL and re-run."
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。
|
||||
// 遍历已完成语句的 sql_type:BEGIN/START TRANSACTION +1,COMMIT/ROLLBACK/END -1;
|
||||
// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。
|
||||
func inferRolledBack(completed []map[string]interface{}) bool {
|
||||
depth := 0
|
||||
for _, s := range completed {
|
||||
switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) {
|
||||
case "BEGIN", "START TRANSACTION", "START_TRANSACTION":
|
||||
depth++
|
||||
case "COMMIT", "ROLLBACK", "END":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
return depth > 0
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
// code 兼容 int / "k_dl_1300002" / 数字字符串多形态(复用 codeString),解析失败回退 0 / 原文。
|
||||
func parseErrorSentinel(data string) (int, string) {
|
||||
if data == "" {
|
||||
return 0, "(unknown error)"
|
||||
}
|
||||
var e struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &e); err != nil {
|
||||
return 0, data
|
||||
}
|
||||
code := 0
|
||||
if cs := codeString(e.Code); cs != "" {
|
||||
if n, convErr := strconv.Atoi(cs); convErr == nil {
|
||||
code = n
|
||||
}
|
||||
}
|
||||
if e.Message == "" {
|
||||
return code, "(unknown error)"
|
||||
}
|
||||
return code, e.Message
|
||||
}
|
||||
|
||||
// buildDBSQLParams 构造 sql 接口的 query:env + 强制 transactional=false(DBA 模式)。
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"transactional": false,
|
||||
}
|
||||
}
|
||||
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。
|
||||
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"sql": rctx.Str("sql"),
|
||||
}
|
||||
}
|
||||
|
||||
// parseSQLResult 从 server result 字符串反序列化出 statements 数组,兼容两种 wire 形态:
|
||||
//
|
||||
// 1. 结构化形态:`[{"sql_type":"SELECT","data":"[...]","record_count":N}, ...]`
|
||||
// —— 每条 statement 含 sql_type / data / record_count / affected_rows 元数据。
|
||||
//
|
||||
// 2. 字符串数组形态:`["[{...rows...}]", "", ...]`
|
||||
// —— 每条 statement 一个字符串:SELECT 是 rows JSON、DML/DDL 是空串;
|
||||
// 无 sql_type 元数据,CLI 端按内容形态推断(SELECT vs OK)。
|
||||
//
|
||||
// 解析失败时返回单元素 fallback `{sql_type:"RAW", data:resultStr}`,pretty 路径原样打。
|
||||
func parseSQLResult(resultStr string) []map[string]interface{} {
|
||||
if resultStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 形态 1:结构化数组(每元素是 object)
|
||||
var structured []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(resultStr), &structured); err == nil && isStructuredResult(structured) {
|
||||
return structured
|
||||
}
|
||||
|
||||
// 形态 2:字符串数组(每元素是 rows JSON 或 "")
|
||||
var legacy []string
|
||||
if err := json.Unmarshal([]byte(resultStr), &legacy); err == nil {
|
||||
out := make([]map[string]interface{}, 0, len(legacy))
|
||||
for _, rowsJSON := range legacy {
|
||||
out = append(out, normalizeLegacyStatement(rowsJSON))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return []map[string]interface{}{{"sql_type": "RAW", "data": resultStr}}
|
||||
}
|
||||
|
||||
// isStructuredResult 判断反序列化出来的 []map 是不是新形态:第一条元素含 sql_type 字段。
|
||||
// 兼容场景:[]map 反序列化 legacy `[""]` 可能也能成(空 map),用 sql_type 存在性区分。
|
||||
func isStructuredResult(stmts []map[string]interface{}) bool {
|
||||
if len(stmts) == 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := stmts[0]["sql_type"]
|
||||
return ok
|
||||
}
|
||||
|
||||
// normalizeLegacyStatement 把 legacy wire 一个字符串元素转成跟新形态一致的 map。
|
||||
// 推断规则:data 是非空 rows 数组 → sql_type=SELECT;空串 / 空数组 → sql_type=OK(DML/DDL 老 wire 不可分)。
|
||||
func normalizeLegacyStatement(rowsJSON string) map[string]interface{} {
|
||||
stmt := map[string]interface{}{
|
||||
"sql_type": "OK",
|
||||
"data": rowsJSON,
|
||||
}
|
||||
trimmed := strings.TrimSpace(rowsJSON)
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return stmt
|
||||
}
|
||||
var rows []interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &rows); err != nil {
|
||||
// 非 JSON 数组(理论上 server 不会返这种),按原样保留 sql_type=OK
|
||||
return stmt
|
||||
}
|
||||
// 是 JSON 数组 → 视作 SELECT,含 record_count
|
||||
stmt["sql_type"] = "SELECT"
|
||||
stmt["record_count"] = float64(len(rows))
|
||||
return stmt
|
||||
}
|
||||
|
||||
// renderSQLPretty 按 statements 数量分单条 / 多条两种渲染路径。
|
||||
func renderSQLPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
if len(stmts) == 0 {
|
||||
fmt.Fprintln(w, "(empty result)")
|
||||
return
|
||||
}
|
||||
if len(stmts) == 1 {
|
||||
renderSingleStatementPretty(w, stmts[0])
|
||||
return
|
||||
}
|
||||
renderMultiStatementPretty(w, stmts)
|
||||
}
|
||||
|
||||
// renderSingleStatementPretty 单条 statement pretty(无 Statement header)。
|
||||
func renderSingleStatementPretty(w io.Writer, s map[string]interface{}) {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
renderSelectRowsAsTable(w, common.GetString(s, "data"))
|
||||
case sqlType == "ERROR":
|
||||
// 单条就挂的极端场景:直接打 ERROR 行(跟多语句失败的最后一行格式一致)。
|
||||
fmt.Fprintln(w, "✗ "+errorSummary(common.GetString(s, "data")))
|
||||
case isDMLType(sqlType):
|
||||
// 结构化 wire 下 INSERT / UPDATE / DELETE / MERGE:✓ N row(s) <verb>
|
||||
fmt.Fprintln(w, "✓ "+dmlSummary(sqlType, s["affected_rows"]))
|
||||
case sqlType == "OK":
|
||||
// legacy wire 下 DML / DDL 都映射成 OK(老 wire 不带 sql_type 元数据,无法区分动词 / 行数)
|
||||
fmt.Fprintln(w, "✓ ok")
|
||||
default:
|
||||
// 其余皆 DDL:真机 boe 返细粒度动词 CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE 等。
|
||||
fmt.Fprintln(w, "✓ DDL executed")
|
||||
}
|
||||
}
|
||||
|
||||
// renderMultiStatementPretty 多条 statement pretty:
|
||||
// - 每条用 "Statement K: ✓ <summary>" / "Statement K: ✗ <error> [<code>]"
|
||||
// - SELECT 用 "Statement K: SELECT (N row(s))" 头 + 紧跟表格
|
||||
// - 末尾汇总:全部成功 "✓ N statements executed";遇 ERROR 哨兵打「前序语句已落地」提示
|
||||
// (DBA 模式不回滚),失败本身由 Execute 升级成 typed error(exit 非 0)
|
||||
func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
failedIdx := -1
|
||||
successCount := 0
|
||||
for i, s := range stmts {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
idx := i + 1
|
||||
switch {
|
||||
case sqlType == "ERROR":
|
||||
fmt.Fprintf(w, "Statement %d: ✗ %s\n", idx, errorSummary(common.GetString(s, "data")))
|
||||
failedIdx = i
|
||||
case sqlType == "SELECT":
|
||||
rc := intOrZero(s["record_count"])
|
||||
fmt.Fprintf(w, "Statement %d: SELECT (%d row%s)\n", idx, rc, plural(rc))
|
||||
renderSelectRowsAsTable(w, common.GetString(s, "data"))
|
||||
successCount++
|
||||
case isDMLType(sqlType):
|
||||
fmt.Fprintf(w, "Statement %d: ✓ %s\n", idx, dmlSummary(sqlType, s["affected_rows"]))
|
||||
successCount++
|
||||
case sqlType == "OK":
|
||||
fmt.Fprintf(w, "Statement %d: ✓ ok\n", idx)
|
||||
successCount++
|
||||
default:
|
||||
// DDL 族:CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...
|
||||
fmt.Fprintf(w, "Statement %d: ✓ DDL executed\n", idx)
|
||||
successCount++
|
||||
}
|
||||
if i < len(stmts)-1 {
|
||||
fmt.Fprintln(w) // statements 间留空行
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if failedIdx >= 0 {
|
||||
// CLI 永远传 transactional=false,失败语句之前的语句已逐条 commit 落地、不会整批回滚——
|
||||
// 如实告诉用户,避免整批重跑导致重复写入。
|
||||
if successCount > 0 {
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n",
|
||||
failedIdx+1, successCount, plural(int64(successCount)))
|
||||
} else {
|
||||
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ %d statements executed\n", successCount)
|
||||
}
|
||||
}
|
||||
|
||||
// renderSelectRowsAsTable 把 SELECT 的 data(rows JSON 数组字符串)解析并渲染成对齐表格。
|
||||
// 空结果输出 "(0 rows)"。
|
||||
func renderSelectRowsAsTable(w io.Writer, dataJSON string) {
|
||||
if dataJSON == "" || dataJSON == "[]" {
|
||||
fmt.Fprintln(w, "(0 rows)")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil {
|
||||
// 数据不符合预期 schema —— 原样打 fallback。
|
||||
fmt.Fprintln(w, dataJSON)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(w, "(0 rows)")
|
||||
return
|
||||
}
|
||||
headers := collectColumns(rows)
|
||||
cells := make([][]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
line := make([]string, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
line = append(line, cellString(row[h]))
|
||||
}
|
||||
cells = append(cells, line)
|
||||
}
|
||||
renderAlignedTable(w, headers, cells)
|
||||
}
|
||||
|
||||
// collectColumns 按首行字段顺序收集列名;首行 key 顺序由 encoding/json 反序列化决定(map 无序),
|
||||
// 排序后保证输出稳定。列顺序在示例里跟 SQL SELECT 顺序一致——但 Go encoding/json 反序列化丢列序,
|
||||
// 这里按字典序保证可重现,agent / 测试可稳定 assert。
|
||||
func collectColumns(rows []map[string]interface{}) []string {
|
||||
set := map[string]struct{}{}
|
||||
for _, r := range rows {
|
||||
for k := range r {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
cols := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
cols = append(cols, k)
|
||||
}
|
||||
sort.Strings(cols)
|
||||
return cols
|
||||
}
|
||||
|
||||
// cellString 把任意 JSON value 转字符串显示(null → 空串;非字符串/数字 → JSON 编码)。
|
||||
func cellString(v interface{}) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
// 整数值不输出小数(id=101 而不是 101.000000)。
|
||||
if x == float64(int64(x)) {
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return fmt.Sprintf("%g", x)
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// dmlSummary 把 sql_type + affected_rows 渲染成 "N row(s) <verb>" 字符串。
|
||||
//
|
||||
// 动词映射:INSERT → inserted / UPDATE → updated / DELETE → deleted / MERGE → merged。
|
||||
// 未知 sql_type 默认 "affected"。
|
||||
func dmlSummary(sqlType string, affectedRows interface{}) string {
|
||||
n := intOrZero(affectedRows)
|
||||
verb := dmlVerb(sqlType)
|
||||
return fmt.Sprintf("%d row%s %s", n, plural(n), verb)
|
||||
}
|
||||
|
||||
// isDMLType 判断 sql_type 是否是行级 DML(带 affected_rows 语义)。
|
||||
// 真机 boe wire:SELECT 走表格、INSERT/UPDATE/DELETE/MERGE 走行数摘要、其余(CREATE_TABLE /
|
||||
// DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...)一律按 DDL 处理。
|
||||
func isDMLType(sqlType string) bool {
|
||||
switch strings.ToUpper(sqlType) {
|
||||
case "INSERT", "UPDATE", "DELETE", "MERGE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dmlVerb 把 DML sql_type 映射成过去分词动词:INSERT→inserted / UPDATE→updated / DELETE→deleted / MERGE→merged,未知 → affected。
|
||||
func dmlVerb(sqlType string) string {
|
||||
switch strings.ToUpper(sqlType) {
|
||||
case "INSERT":
|
||||
return "inserted"
|
||||
case "UPDATE":
|
||||
return "updated"
|
||||
case "DELETE":
|
||||
return "deleted"
|
||||
case "MERGE":
|
||||
return "merged"
|
||||
}
|
||||
return "affected"
|
||||
}
|
||||
|
||||
// plural 返回英文复数后缀:n==1 时空串,否则 "s"。
|
||||
func plural(n int64) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
// errorSummary 从 ERROR 哨兵的 data 字段({code, message} JSON)解析出 "message [code]" 形态。
|
||||
// 解析失败时回退到原文。
|
||||
func errorSummary(data string) string {
|
||||
if data == "" {
|
||||
return "(unknown error)"
|
||||
}
|
||||
var e struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &e); err != nil {
|
||||
return data
|
||||
}
|
||||
codeStr := codeString(e.Code)
|
||||
if codeStr != "" {
|
||||
return fmt.Sprintf("%s [%s]", e.Message, codeStr)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// codeString 处理 code 字段在 wire 上可能是 int / "k_dl_1300015" / 数字字符串等多形态。
|
||||
func codeString(c interface{}) string {
|
||||
switch x := c.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
// "k_dl_1300015" → 抽 1300015;纯数字保持原样。
|
||||
if strings.HasPrefix(x, "k_dl_") {
|
||||
return strings.TrimPrefix(x, "k_dl_")
|
||||
}
|
||||
return x
|
||||
case float64:
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// intOrZero 把 JSON number 转 int64;nil / 类型不匹配返回 0。
|
||||
func intOrZero(raw interface{}) int64 {
|
||||
if n, ok := numericAsFloat(raw); ok {
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
980
shortcuts/apps/apps_db_execute_test.go
Normal file
980
shortcuts/apps/apps_db_execute_test.go
Normal file
@@ -0,0 +1,980 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。
|
||||
func TestAppsDBExecute_SingleSELECTJSONIsRowArray(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{}{
|
||||
// DBA 模式 result:结构化数组 JSON 字符串
|
||||
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500}]","record_count":1}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 SELECT:data 直接是行数组(不再是 data.results[].data 字符串)
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data[0]["id"] != float64(101) || env.Data[0]["total_cents"] != float64(2500) {
|
||||
t.Fatalf("data[0] = %v, want {id:101,total_cents:2500}", env.Data[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。
|
||||
func TestAppsDBExecute_SingleDMLJSONShape(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":"INSERT","data":"","affected_rows":3}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DML:data = {command, rows_affected}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
RowsAffected int `json:"rows_affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 {
|
||||
t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。
|
||||
func TestAppsDBExecute_SingleDDLJSONShape(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":"CREATE_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DDL:data = {command}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "CREATE_TABLE" {
|
||||
t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。
|
||||
func TestAppsDBExecute_MultiStatementJSONShape(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":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) {
|
||||
t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0])
|
||||
}
|
||||
if env.Data[1]["command"] != "SELECT" {
|
||||
t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"])
|
||||
}
|
||||
rows, ok := env.Data[1]["rows"].([]interface{})
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。
|
||||
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/sql_commands" {
|
||||
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
}
|
||||
if env.API[0].Body["sql"] != "select 1" {
|
||||
t.Fatalf("body.sql = %v", env.API[0].Body["sql"])
|
||||
}
|
||||
if env.API[0].Params["env"] != "dev" {
|
||||
t.Fatalf("params.env = %v", env.API[0].Params["env"])
|
||||
}
|
||||
if env.API[0].Params["transactional"] != false {
|
||||
t.Fatalf("params.transactional = %v (want false, CLI is DBA mode)", env.API[0].Params["transactional"])
|
||||
}
|
||||
if _, ok := env.API[0].Body["transactional"]; ok {
|
||||
t.Fatalf("transactional should NOT be in body, got body=%v", env.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_RejectsEmptySQL 断言 --sql 全空白时校验报错(提示需要 --sql 或 --file)。
|
||||
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", " ", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sql or --file") {
|
||||
t.Fatalf("expected empty-sql error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --sql 与 --file 互斥
|
||||
func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1", "--file", "x.sql", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual-exclusion error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --file 读取相对路径 .sql 文件 → 内容进 body.sql(dry-run 验证)
|
||||
func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sqlPath := filepath.Join(dir, "m.sql")
|
||||
if err := os.WriteFile(sqlPath, []byte("SELECT 42 AS answer;\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 切到临时目录,使相对路径校验通过(CLI 仅接受 cwd 内相对路径)。
|
||||
// 用 os.Chdir + 还原而非 t.Chdir:后者要 Go 1.24,本仓库 go.mod 为 1.23。
|
||||
oldWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Body["sql"] != "SELECT 42 AS answer;\n" {
|
||||
t.Fatalf("body.sql = %v, want file content", env.API[0].Body["sql"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// legacy wire 形态测试 —— BOE server 实测返这种 ["rows-json-string", ...]
|
||||
// 形态而非 spec 里的 [{sql_type, data, ...}],CLI 端必须兼容。
|
||||
// 输入用 BOE 真实抓包数据(test_scripts/boe_e2e/run.log)。
|
||||
// ============================================================================
|
||||
|
||||
// TestAppsDBExecute_LegacyWireSingleSelect 断言 legacy 字符串数组 wire 的单 SELECT 能正常渲染表格、不回退到 RAW。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]"
|
||||
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": `["[{\"x\":1}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "x") {
|
||||
t.Errorf("missing header 'x':\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "1") {
|
||||
t.Errorf("missing value row '1':\n%s", got)
|
||||
}
|
||||
// 不应回退到 RAW
|
||||
if strings.Contains(got, "RAW") || strings.Contains(got, "[\\\"") {
|
||||
t.Errorf("should not fall back to RAW or raw-string passthrough:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
|
||||
// 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态(data 直接是行)
|
||||
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": `["[{\"x\":1}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data)
|
||||
}
|
||||
if env.Data[0]["x"] != float64(1) {
|
||||
t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelect 断言 legacy wire 多 SELECT 输出带 Statement N header 与末尾 "✓ N statements executed" 汇总。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
|
||||
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": `["[{\"?column?\":1}]","[{\"?column?\":2}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1; SELECT 2;", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// 多语句应有 Statement N: header
|
||||
if !strings.Contains(got, "Statement 1: SELECT") || !strings.Contains(got, "Statement 2: SELECT") {
|
||||
t.Errorf("missing Statement headers:\n%s", got)
|
||||
}
|
||||
// 末尾应有 ✓ N statements executed
|
||||
if !strings.Contains(got, "✓ 2 statements executed") {
|
||||
t.Errorf("missing summary line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时(legacy DDL)pretty 输出 "(empty result)"。
|
||||
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
// BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows)
|
||||
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
|
||||
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": ``, // 空字符串
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "CREATE TABLE foo (id INT)", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// result="" 触发 parseSQLResult 返 nil → renderSQLPretty 输出 "(empty result)"
|
||||
if !strings.Contains(got, "(empty result)") {
|
||||
t.Errorf("expected '(empty result)' for empty result string, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelectWithRealTable 断言含 CJK / uuid / int 字段的真实表行能正确显示在 pretty 表格里。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
|
||||
// BOE 实测真实表抓包(course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
|
||||
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": `["[{\"id\":\"abc-123\",\"title\":\"高效沟通\",\"capacity\":30}]"]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT id,title,capacity FROM course LIMIT 1", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// 验证 CJK / uuid / int 都能正确显示在表格里
|
||||
for _, want := range []string{"id", "title", "capacity", "abc-123", "高效沟通", "30"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in pretty output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pretty 单 SELECT:表格输出,列间两空格,无 Statement header。
|
||||
func TestAppsDBExecute_PrettySingleSelectTable(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":"SELECT","data":"[{\"id\":101,\"total_cents\":2500},{\"id\":102,\"total_cents\":1800}]","record_count":2}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "Statement 1:") {
|
||||
t.Errorf("single statement pretty should NOT have Statement header\noutput:\n%s", got)
|
||||
}
|
||||
// 列按字典序排序:id / total_cents
|
||||
if !strings.Contains(got, "id total_cents") {
|
||||
t.Errorf("missing header row\noutput:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "101 2500") || !strings.Contains(got, "102 1800") {
|
||||
t.Errorf("missing data rows\noutput:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyEmptySelect 断言空 SELECT 的 pretty 输出为 "(0 rows)"。
|
||||
func TestAppsDBExecute_PrettyEmptySelect(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":"SELECT","data":"[]","record_count":0}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "(0 rows)") {
|
||||
t.Fatalf("empty SELECT should print (0 rows), got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) <verb>"、各类 DDL(含细粒度动词)渲染 "✓ DDL executed"。
|
||||
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result string
|
||||
wantStr string
|
||||
}{
|
||||
{"INSERT_1_row", `[{"sql_type":"INSERT","data":"","affected_rows":1}]`, "✓ 1 row inserted"},
|
||||
{"UPDATE_5_rows", `[{"sql_type":"UPDATE","data":"","affected_rows":5}]`, "✓ 5 rows updated"},
|
||||
{"DELETE_0_rows", `[{"sql_type":"DELETE","data":"","affected_rows":0}]`, "✓ 0 rows deleted"},
|
||||
{"DDL", `[{"sql_type":"DDL","data":"","affected_rows":0}]`, "✓ DDL executed"},
|
||||
// 真机 boe 实测:DDL 的 sql_type 是细粒度动词(CREATE_TABLE / DROP_TABLE / ALTER_TABLE...),
|
||||
// data 是 "[]"、无 affected_rows。必须识别为 DDL,而不是落到 dmlSummary 渲染成 "0 rows affected"。
|
||||
{"CREATE_TABLE", `[{"sql_type":"CREATE_TABLE","data":"[]"}]`, "✓ DDL executed"},
|
||||
{"DROP_TABLE", `[{"sql_type":"DROP_TABLE","data":"[]"}]`, "✓ DDL executed"},
|
||||
{"ALTER_TABLE", `[{"sql_type":"ALTER_TABLE","data":"[]"}]`, "✓ DDL executed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(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": c.result}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), c.wantStr) {
|
||||
t.Errorf("want %q\ngot:\n%s", c.wantStr, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsAllSuccess 断言多语句全成功时逐条 Statement 摘要 + 末尾 "✓ N statements executed"。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(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":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"UPDATE","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, line := range []string{
|
||||
"Statement 1: ✓ 1 row inserted",
|
||||
"Statement 2: ✓ 1 row updated",
|
||||
"Statement 3: SELECT (1 row)",
|
||||
"✓ 3 statements executed",
|
||||
} {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsDDL 钉住真机 boe 多语句 DDL 的 wire:
|
||||
// CREATE_TABLE / DROP_TABLE(data="[]"、无 affected_rows)须渲染成 "✓ DDL executed",
|
||||
// 不能落到 dmlSummary 变成 "0 rows affected"。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsDDL(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":"CREATE_TABLE","data":"[]"},{"sql_type":"DROP_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, line := range []string{
|
||||
"Statement 1: ✓ DDL executed",
|
||||
"Statement 2: ✓ DDL executed",
|
||||
"✓ 2 statements executed",
|
||||
} {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "rows affected") {
|
||||
t.Errorf("DDL must not render as 'rows affected'\nfull:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel 断言多语句部分失败时 pretty 仍打逐条 ✓/✗ 摘要、声明前序已 commit 未回滚,且返回 typed error、不打成功汇总。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(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":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":1300015,\"message\":\"syntax error at or near 'SELEC'\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
// pretty 失败路径:逐条 ✓/✗ 摘要照打到 stdout(人看),同时返回 typed error(exit 非 0)。
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("pretty multi-statement failure must still return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, line := range []string{
|
||||
"Statement 1: ✓ 1 row inserted",
|
||||
"Statement 2: ✗ syntax error at or near 'SELEC' [1300015]",
|
||||
} {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
// 非事务(transactional=false)前序语句已逐条 commit 落地,须如实说明「committed and not rolled back」,
|
||||
// 绝不能误报整批回滚。
|
||||
if !strings.Contains(got, "committed and not rolled back") {
|
||||
t.Errorf("non-tx failure must state prior statements committed & not rolled back; got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "statements executed") {
|
||||
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed errs.APIError」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 typed errs.* 错误(type=api / subtype=server_error、
|
||||
// exit=1)。失败位置在 message 的 "(at statement N of M)",前序是否落地/是否回滚写在 hint。
|
||||
// 本例无 BEGIN → 前序逐条 commit、未回滚(hint 含 "committed and not rolled back")。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(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":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]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())
|
||||
}
|
||||
// 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())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if p.Code != 1300002 {
|
||||
t.Errorf("code = %d, want 1300002", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
// 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "committed and not rolled back") {
|
||||
t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(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 at or near 'SELEC'\"}"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]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())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("message missing locator: %q", p.Message)
|
||||
}
|
||||
// 第一条就失败、无落地 的语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "No statements were applied") {
|
||||
t.Errorf("hint should state nothing applied: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
|
||||
// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。
|
||||
// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。
|
||||
func TestAppsDBExecute_TransactionFailureRolledBack(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{}{
|
||||
// BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR)
|
||||
"result": `[` +
|
||||
`{"sql_type":"BEGIN","data":"[]"},` +
|
||||
`{"sql_type":"CREATE_TABLE","data":"[]"},` +
|
||||
`{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 4 of 4)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
// 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。
|
||||
if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") {
|
||||
t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。
|
||||
func TestInferRolledBack_Cases(t *testing.T) {
|
||||
stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} }
|
||||
cases := []struct {
|
||||
name string
|
||||
completed []map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"autocommit single", []map[string]interface{}{stmt("INSERT")}, false},
|
||||
{"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true},
|
||||
{"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false},
|
||||
{"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true},
|
||||
{"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := inferRolledBack(c.completed); got != c.want {
|
||||
t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。
|
||||
func TestCellString_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"string", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
{"int float", float64(101), "101"},
|
||||
{"fractional", float64(1.25), "1.25"},
|
||||
{"object", map[string]interface{}{"a": float64(1)}, `{"a":1}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := cellString(c.in); got != c.want {
|
||||
t.Errorf("cellString(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。
|
||||
func TestCodeString_Forms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"k_dl prefix", "k_dl_1300015", "1300015"},
|
||||
{"plain string", "1300015", "1300015"},
|
||||
{"float64", float64(42), "42"},
|
||||
{"unsupported", []int{1}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := codeString(c.in); got != c.want {
|
||||
t.Errorf("codeString(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDmlVerb_AllVerbs 断言 dmlVerb 对 INSERT/UPDATE/DELETE/MERGE 的动词映射(大小写不敏感),非 DML 返回 affected。
|
||||
func TestDmlVerb_AllVerbs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"INSERT": "inserted",
|
||||
"update": "updated",
|
||||
"DELETE": "deleted",
|
||||
"Merge": "merged",
|
||||
"CREATE_TABLE": "affected",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := dmlVerb(in); got != want {
|
||||
t.Errorf("dmlVerb(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntOrZero_Cases 断言 intOrZero 对 JSON number 取整、对非数字 / nil 返回 0。
|
||||
func TestIntOrZero_Cases(t *testing.T) {
|
||||
if got := intOrZero(float64(5)); got != 5 {
|
||||
t.Errorf("intOrZero(5)=%d want 5", got)
|
||||
}
|
||||
if got := intOrZero("x"); got != 0 {
|
||||
t.Errorf("intOrZero(non-numeric)=%d want 0", got)
|
||||
}
|
||||
if got := intOrZero(nil); got != 0 {
|
||||
t.Errorf("intOrZero(nil)=%d want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorSummary_Cases 断言 errorSummary 对空 / 非法 JSON / 带 code / 无 code 各情形生成 "message [code]" 文案。
|
||||
func TestErrorSummary_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{"empty", "", "(unknown error)"},
|
||||
{"malformed json", "not json", "not json"},
|
||||
{"with code", `{"code":"k_dl_1300015","message":"boom"}`, "boom [1300015]"},
|
||||
{"no code", `{"message":"plain"}`, "plain"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := errorSummary(c.in); got != c.want {
|
||||
t.Errorf("errorSummary(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseErrorSentinel_Cases 断言 parseErrorSentinel 解析 ERROR 哨兵 data 得到数值 code 与 message(含空 / 非法 / 空 message 回退)。
|
||||
func TestParseErrorSentinel_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in string
|
||||
wantCode int
|
||||
wantMsg string
|
||||
}{
|
||||
{"empty", "", 0, "(unknown error)"},
|
||||
{"malformed", "xyz", 0, "xyz"},
|
||||
{"code+msg", `{"code":"1300015","message":"boom"}`, 1300015, "boom"},
|
||||
{"empty msg", `{"code":"1300015","message":""}`, 1300015, "(unknown error)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
code, msg := parseErrorSentinel(c.in)
|
||||
if code != c.wantCode || msg != c.wantMsg {
|
||||
t.Errorf("parseErrorSentinel(%q)=%d,%q want %d,%q", c.in, code, msg, c.wantCode, c.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsStructuredResult_Cases 断言 isStructuredResult 仅在首元素含 sql_type 时判为新结构化形态。
|
||||
func TestIsStructuredResult_Cases(t *testing.T) {
|
||||
if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) {
|
||||
t.Error("expected structured=true when sql_type present")
|
||||
}
|
||||
if isStructuredResult([]map[string]interface{}{{}}) {
|
||||
t.Error("expected structured=false when sql_type absent")
|
||||
}
|
||||
if isStructuredResult(nil) {
|
||||
t.Error("expected structured=false for empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeLegacyStatement_Cases 断言 normalizeLegacyStatement 把空 / null / 非 JSON 标为 OK、把 rows 数组标为 SELECT 并带 record_count。
|
||||
func TestNormalizeLegacyStatement_Cases(t *testing.T) {
|
||||
t.Run("empty -> OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement("")
|
||||
if got["sql_type"] != "OK" {
|
||||
t.Errorf("got sql_type=%v want OK", got["sql_type"])
|
||||
}
|
||||
})
|
||||
t.Run("null -> OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement("null")
|
||||
if got["sql_type"] != "OK" {
|
||||
t.Errorf("got sql_type=%v want OK", got["sql_type"])
|
||||
}
|
||||
})
|
||||
t.Run("rows -> SELECT", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement(`[{"id":1}]`)
|
||||
if got["sql_type"] != "SELECT" {
|
||||
t.Errorf("got sql_type=%v want SELECT", got["sql_type"])
|
||||
}
|
||||
if got["record_count"] != float64(1) {
|
||||
t.Errorf("got record_count=%v want 1", got["record_count"])
|
||||
}
|
||||
})
|
||||
t.Run("non-json kept as OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement(`notjson`)
|
||||
if got["sql_type"] != "OK" {
|
||||
t.Errorf("got sql_type=%v want OK", got["sql_type"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellString_MarshalFallback 断言 cellString 对 json.Marshal 拒绝的类型(如 complex)回退到 fmt %v。
|
||||
func TestCellString_MarshalFallback(t *testing.T) {
|
||||
// complex128 is not switch-handled and json.Marshal rejects it →
|
||||
// falls back to fmt.Sprintf("%v", v), which is deterministic for complex.
|
||||
if got := cellString(complex(1, 2)); got != "(1+2i)" {
|
||||
t.Errorf("cellString(complex)=%q want (1+2i)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSingleStatementPretty_Branches 断言 renderSingleStatementPretty 对 SELECT/ERROR/DML/legacy OK/DDL 各分支的输出。
|
||||
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stmt map[string]interface{}
|
||||
substr string
|
||||
}{
|
||||
{"select empty", map[string]interface{}{"sql_type": "SELECT", "data": "[]"}, "(0 rows)"},
|
||||
{"error", map[string]interface{}{"sql_type": "ERROR", "data": `{"message":"boom"}`}, "✗ boom"},
|
||||
{"dml insert", map[string]interface{}{"sql_type": "INSERT", "affected_rows": float64(3)}, "✓ 3 rows inserted"},
|
||||
{"legacy ok", map[string]interface{}{"sql_type": "OK"}, "✓ ok"},
|
||||
{"ddl default", map[string]interface{}{"sql_type": "CREATE_TABLE"}, "✓ DDL executed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
renderSingleStatementPretty(&b, c.stmt)
|
||||
if !strings.Contains(b.String(), c.substr) {
|
||||
t.Errorf("output %q does not contain %q", b.String(), c.substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。
|
||||
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data string
|
||||
substr string
|
||||
}{
|
||||
{"empty string", "", "(0 rows)"},
|
||||
{"empty array", "[]", "(0 rows)"},
|
||||
{"malformed fallback", "{bad", "{bad"},
|
||||
{"rows", `[{"id":1}]`, "id"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
renderSelectRowsAsTable(&b, c.data)
|
||||
if !strings.Contains(b.String(), c.substr) {
|
||||
t.Errorf("output %q does not contain %q", b.String(), c.substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
100
shortcuts/apps/apps_db_quota_get.go
Normal file
100
shortcuts/apps/apps_db_quota_get.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBQuotaGet reports an app's database storage usage and object counts.
|
||||
//
|
||||
// GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时
|
||||
// 不输出(与 +file-quota-get 一致);tables / views 始终输出。
|
||||
var AppsDBQuotaGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-quota-get",
|
||||
Description: "Get an app's database storage usage",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-quota-get --app-id <app_id>",
|
||||
"Example: lark-cli apps +db-quota-get --app-id <app_id> --env dev",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": rctx.Str("env")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": rctx.Str("env")}, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectDbQuota(data)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderDbQuotaPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectDbQuota 白名单投影 db quota 字段:只保留 storage_used_bytes / tables / views,
|
||||
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段,避免无用字段消耗上下文。
|
||||
func projectDbQuota(data map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
|
||||
for _, k := range []string{"tables", "views"} {
|
||||
if v, ok := data[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
// 配额未对接(storage_quota_bytes=0/缺失)时不输出 quota / usage_percent。
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
out["storage_quota_bytes"] = data["storage_quota_bytes"]
|
||||
if v, ok := data["usage_percent"]; ok {
|
||||
out["usage_percent"] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderDbQuotaPretty 打 Storage(已用 / 配额 (百分比))与 Tables / Views 行(标签对齐 miaoda-cli)。
|
||||
func renderDbQuotaPretty(w io.Writer, data map[string]interface{}) {
|
||||
used := humanBytes(data["storage_used_bytes"])
|
||||
usage := used
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
pct := ""
|
||||
if p, ok := numericAsFloat(data["usage_percent"]); ok {
|
||||
pct = fmt.Sprintf(" (%.1f%%)", p)
|
||||
}
|
||||
usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct)
|
||||
}
|
||||
pairs := [][2]string{{"Storage", usage}}
|
||||
if f, ok := numericAsFloat(data["tables"]); ok {
|
||||
pairs = append(pairs, [2]string{"Tables", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
if f, ok := numericAsFloat(data["views"]); ok {
|
||||
pairs = append(pairs, [2]string{"Views", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
}
|
||||
267
shortcuts/apps/apps_db_recovery.go
Normal file
267
shortcuts/apps/apps_db_recovery.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)"
|
||||
|
||||
// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id,
|
||||
// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。
|
||||
var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-diff",
|
||||
Description: "Preview restoring the database to a point in time (PITR diff)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-diff --app-id <app_id> --target 2h",
|
||||
"Apply with +db-recovery-apply --target <same> --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
preview, err := runRecoveryPreview(rctx, appID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := recoveryDiffOutput(target, preview)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRecoveryDiff(w, target, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes,
|
||||
// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
|
||||
var AppsDBRecoveryApply = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-apply",
|
||||
Description: "Restore the database to a point in time (overwrites current data, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes",
|
||||
"Preview first with +db-recovery-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
// 目标=当前态 → 后端短路 no_changes,不轮询。
|
||||
if strings.ToLower(common.GetString(submit, "status")) == "no_changes" {
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "no_changes", "target": target}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "restored", "ready":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("recovery to %s failed", target)
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "restored", "target": target}
|
||||
if n := intFromAny(final["restore_time_sec"]); n > 0 {
|
||||
out["restore_time_sec"] = n
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
if n, ok := out["restore_time_sec"].(int); ok {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
prid := common.GetString(submit, "preview_request_id")
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
case "success":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = "recovery preview failed"
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
type recoveryChange struct {
|
||||
Table string `json:"table"`
|
||||
Inserted interface{} `json:"inserted,omitempty"`
|
||||
Deleted interface{} `json:"deleted,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
DroppedAt string `json:"dropped_at,omitempty"`
|
||||
}
|
||||
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
Action: common.GetString(m, "action"),
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
|
||||
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
|
||||
changes, _ := out["changes"].([]recoveryChange)
|
||||
if len(changes) == 0 {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
|
||||
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
|
||||
}
|
||||
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
|
||||
}
|
||||
|
||||
// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。
|
||||
func describeRecoveryChange(c recoveryChange) string {
|
||||
switch c.Action {
|
||||
case "restore_table":
|
||||
return "table will be restored"
|
||||
case "drop_table":
|
||||
return "table will be dropped"
|
||||
case "alter_table":
|
||||
return "table will be altered"
|
||||
case "unavailable":
|
||||
if c.DroppedAt != "" {
|
||||
return "diff unavailable: " + c.DroppedAt
|
||||
}
|
||||
return "diff unavailable"
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if n := intFromAny(c.Inserted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("+%d rows", n))
|
||||
}
|
||||
if n := intFromAny(c.Deleted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("-%d rows", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
87
shortcuts/apps/apps_db_table_get.go
Normal file
87
shortcuts/apps/apps_db_table_get.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
|
||||
//
|
||||
// GET /apps/{app_id}/tables/{table_name}。
|
||||
//
|
||||
// `--format` 同时驱动 CLI 渲染和 server 请求形态:
|
||||
// - `--format json`(默认)/ table / ndjson / csv:CLI 不传 format query,response 含结构化
|
||||
// columns / indexes / constraints / stats,envelope 化输出。
|
||||
// - `--format pretty`:CLI 给 server 带 ?format=ddl,response 含 ddl 字符串,stdout 直接打
|
||||
// ddl 内容(无 envelope / 无表格包装)。
|
||||
var AppsDBTableGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-table-get",
|
||||
Description: "Get a table's structure: columns, indexes and constraints",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-table-get --app-id <app_id> --table <table>",
|
||||
"Tip: filter fields with --jq (json format), e.g. -q '.data.columns[].name'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return output.ErrValidation("--table is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
|
||||
Desc("Get Miaoda app db table schema").
|
||||
Params(buildDBTableGetParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := appTablePath(appID, strings.TrimSpace(rctx.Str("table")))
|
||||
data, err := rctx.CallAPITyped("GET", path, buildDBTableGetParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbTableGetHint)
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
// pretty 模式:stdout 直接打 ddl 文本(无 trailing newline,由 server 返回的字符串决定)。
|
||||
io.WriteString(w, common.GetString(data, "ddl"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildDBTableGetParams 构造 schema 接口的 query。
|
||||
//
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": rctx.Str("env")}
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
return params
|
||||
}
|
||||
131
shortcuts/apps/apps_db_table_get_test.go
Normal file
131
shortcuts/apps/apps_db_table_get_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsDBTableGet_DefaultJSONReturnsStructuredFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "订单表",
|
||||
"columns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "id", "data_type": "int8",
|
||||
"is_primary_key": true, "is_unique": true,
|
||||
"is_allow_null": false, "default_value": "",
|
||||
},
|
||||
},
|
||||
"indexes": []interface{}{
|
||||
map[string]interface{}{"name": "orders_pkey", "type": "btree", "columns": []interface{}{"id"}, "definition": "..."},
|
||||
},
|
||||
"constraints": []interface{}{
|
||||
map[string]interface{}{"type": "primary_key", "name": "orders_pkey", "columns": []interface{}{"id"}},
|
||||
},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "orders"`) {
|
||||
t.Fatalf("stdout missing schema name: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format pretty 是触发 DDL 模式的唯一开关。
|
||||
// 用 --format json + --dry-run 走 JSON envelope 路径方便 parse,但 query 形态由代码内部
|
||||
// 根据 rctx.Format 决定 —— 这里我们直接传 --format pretty + --dry-run,pretty 模式下 dry-run
|
||||
// 输出是 plain text 列表,用 substring 校验 format=ddl 出现在 URL query 中。
|
||||
func TestAppsDBTableGet_PrettyFormatSendsFormatDDLQuery(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/tables/orders") {
|
||||
t.Fatalf("missing URL in dry-run output:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "format=ddl") {
|
||||
t.Fatalf("--format=pretty should trigger ?format=ddl, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
|
||||
// 默认 json / table / ndjson / csv 都走 schema 路径 —— CLI 不传 format query。
|
||||
for _, format := range []string{"json", "table", "ndjson", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
args := []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", format, "--dry-run", "--as", "user"}
|
||||
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := env.API[0].Params["format"]; ok {
|
||||
t.Fatalf("--format=%s should omit format query, got %v", format, env.API[0].Params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_PrettyOutputIsDDLTextOnly(t *testing.T) {
|
||||
// pretty 模式 stdout 直接打 ddl 字段文本,无 envelope / 表格包装。
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
ddl := "CREATE TABLE orders (\n id bigint NOT NULL,\n PRIMARY KEY (id)\n);"
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ddl": ddl},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "CREATE TABLE orders") {
|
||||
t.Fatalf("pretty output should contain raw DDL, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `"data":`) || strings.Contains(got, `"ddl":`) {
|
||||
t.Fatalf("pretty output should not be JSON envelope, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableGet_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableGet,
|
||||
[]string{"+db-table-get", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "table") {
|
||||
t.Fatalf("expected table required error, got %v", err)
|
||||
}
|
||||
}
|
||||
301
shortcuts/apps/apps_db_table_list.go
Normal file
301
shortcuts/apps/apps_db_table_list.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableList lists tables in a Miaoda app's database.
|
||||
//
|
||||
// GET /apps/{app_id}/tables(cursor 分页),response items[] 含 estimated_row_count /
|
||||
// size_bytes optional 字段,默认返回,不必额外传 query。
|
||||
//
|
||||
// 输出裁剪:server 给每张表回完整 columns[](与 +db-table-get 同源、内容一致)。CLI 用白名单
|
||||
// 投影(dbTableListItem)只组装产品要求字段、把 columns[] 折算成 column_count,避免逐表重复列定义
|
||||
// 放大 token、并与 +db-table-get 职责区分。完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
|
||||
//
|
||||
// pretty 渲染 5 列:name / description / estimated_row_count / size / columns(即 column_count);
|
||||
// 列间两空格、列对齐填充、空 description 用 "—" 占位、size 按 KB/MB/GB 友好格式化。
|
||||
var AppsDBTableList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-table-list",
|
||||
Description: "List tables in a Miaoda app database (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-table-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.items[].name'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appTablesPath(appID)).
|
||||
Desc("List Miaoda app db tables").
|
||||
Params(buildDBTableListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), buildDBTableListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbTableListHint)
|
||||
}
|
||||
// 白名单投影:只把产品要求的字段组装进 dbTableListItem,替换 server 原始 items[]。
|
||||
// server 给每张表回完整 columns[](与 +db-table-get 同源、逐字节一致),在 list 里逐表
|
||||
// 重复既放大 token 又与 schema 职责重叠。这里用白名单而非 delete 黑名单 —— server 后续新增
|
||||
// 字段不会自动泄漏进 CLI 输出。需要完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
|
||||
items := projectTableListItems(data["items"])
|
||||
data["items"] = items
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderTableListPretty(w, items)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// dbTableListItem 是 +db-table-list 对外输出的「产品要求字段」白名单。
|
||||
// 改字段在此处增删即可,无需在 Execute 里逐个 delete server 返回的多余字段。
|
||||
type dbTableListItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
EstimatedRowCount interface{} `json:"estimated_row_count,omitempty"`
|
||||
SizeBytes interface{} `json:"size_bytes,omitempty"`
|
||||
ColumnCount int `json:"column_count"`
|
||||
}
|
||||
|
||||
// projectTableListItems 把 server 原始 items[](map)投影成白名单 dbTableListItem 切片。
|
||||
// column_count 由 server 返回的 columns[] 长度派生(随后 columns[] 不再透出)。
|
||||
func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]dbTableListItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, dbTableListItem{
|
||||
Name: common.GetString(m, "name"),
|
||||
Description: common.GetString(m, "description"),
|
||||
EstimatedRowCount: m["estimated_row_count"],
|
||||
SizeBytes: m["size_bytes"],
|
||||
ColumnCount: deriveColumnCount(m),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// renderTableListPretty 5 列输出,列间两空格、列对齐填充。
|
||||
//
|
||||
// 列名:name / description / estimated_row_count / size / columns。
|
||||
// 空 description 用 "—" 占位;size 由 size_bytes 经 humanBytes 友好格式化;
|
||||
// columns 列取白名单投影的 column_count。
|
||||
func renderTableListPretty(w io.Writer, items []dbTableListItem) {
|
||||
headers := []string{"name", "description", "estimated_row_count", "size", "columns"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
desc := item.Description
|
||||
if desc == "" {
|
||||
desc = "—"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
item.Name,
|
||||
desc,
|
||||
intString(item.EstimatedRowCount),
|
||||
humanBytes(item.SizeBytes),
|
||||
fmt.Sprintf("%d", item.ColumnCount),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
|
||||
// renderAlignedTable 输出列对齐表格:列间两空格、列宽按每列最长 cell 填充、
|
||||
// 不画 `|` 和 `-` 分隔线、不依赖 TTY 着色。
|
||||
func renderAlignedTable(w io.Writer, headers []string, rows [][]string) {
|
||||
if len(headers) == 0 {
|
||||
return
|
||||
}
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = displayWidth(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if dw := displayWidth(cell); dw > widths[i] {
|
||||
widths[i] = dw
|
||||
}
|
||||
}
|
||||
}
|
||||
writeRow := func(cells []string) {
|
||||
for i, cell := range cells {
|
||||
if i >= len(widths) {
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
io.WriteString(w, " ")
|
||||
}
|
||||
io.WriteString(w, cell)
|
||||
if i < len(widths)-1 {
|
||||
pad := widths[i] - displayWidth(cell)
|
||||
if pad > 0 {
|
||||
io.WriteString(w, strings.Repeat(" ", pad))
|
||||
}
|
||||
}
|
||||
}
|
||||
io.WriteString(w, "\n")
|
||||
}
|
||||
writeRow(headers)
|
||||
for _, r := range rows {
|
||||
writeRow(r)
|
||||
}
|
||||
}
|
||||
|
||||
// displayWidth 估算字符串在 monospace 终端下的显示宽度。
|
||||
// ASCII 占 1 列;CJK / 全角字符占 2 列;其他多字节字符按 rune 数算(保守)。
|
||||
func displayWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r < 0x80:
|
||||
w++
|
||||
case isWide(r):
|
||||
w += 2
|
||||
default:
|
||||
w++
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func isWide(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x1100 && r <= 0x115F: // Hangul Jamo
|
||||
case r >= 0x2E80 && r <= 0x303E: // CJK Radicals / Kangxi
|
||||
case r >= 0x3041 && r <= 0x33FF: // Hiragana / Katakana / Bopomofo / CJK Symbols
|
||||
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
|
||||
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
|
||||
case r >= 0xA000 && r <= 0xA4CF: // Yi
|
||||
case r >= 0xAC00 && r <= 0xD7A3: // Hangul Syllables
|
||||
case r >= 0xF900 && r <= 0xFAFF: // CJK Compatibility Ideographs
|
||||
case r >= 0xFE30 && r <= 0xFE4F: // CJK Compatibility Forms
|
||||
case r >= 0xFF00 && r <= 0xFF60: // Fullwidth Forms
|
||||
case r >= 0xFFE0 && r <= 0xFFE6: // Fullwidth Signs
|
||||
case r >= 0x20000 && r <= 0x2FFFD: // CJK Extension B-F
|
||||
case r >= 0x30000 && r <= 0x3FFFD: // CJK Extension G
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// humanBytes 把 size_bytes 数值转 KB / MB / GB 友好字符串。
|
||||
// 1 KiB = 1024 B;与 PG / 操作系统约定一致。
|
||||
func humanBytes(raw interface{}) string {
|
||||
n, ok := numericAsFloat(raw)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
const unit = 1024.0
|
||||
switch {
|
||||
case n < unit:
|
||||
return fmt.Sprintf("%d B", int64(n))
|
||||
case n < unit*unit:
|
||||
return fmt.Sprintf("%.0f KB", n/unit)
|
||||
case n < unit*unit*unit:
|
||||
return formatFloat(n/(unit*unit)) + " MB"
|
||||
default:
|
||||
return formatFloat(n/(unit*unit*unit)) + " GB"
|
||||
}
|
||||
}
|
||||
|
||||
// formatFloat 一位小数;整数值省略小数(24 KB 而不是 24.0 KB;1.5 MB 而不是 1 MB)。
|
||||
func formatFloat(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return fmt.Sprintf("%d", int64(f))
|
||||
}
|
||||
return fmt.Sprintf("%.1f", f)
|
||||
}
|
||||
|
||||
// intString 把 JSON 反序列化进来的 number 转为整数字符串显示(estimated_row_count)。
|
||||
func intString(raw interface{}) string {
|
||||
if n, ok := numericAsFloat(raw); ok {
|
||||
return fmt.Sprintf("%d", int64(n))
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint:
|
||||
return float64(v), true
|
||||
case uint32:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// deriveColumnCount 从 items[i].columns 数组长度派生 column_count。
|
||||
func deriveColumnCount(m map[string]interface{}) int {
|
||||
cols, ok := m["columns"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return len(cols)
|
||||
}
|
||||
309
shortcuts/apps/apps_db_table_list_test.go
Normal file
309
shortcuts/apps/apps_db_table_list_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误
|
||||
// (code != 0,如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成
|
||||
// typed error —— 用 BOE 实测的错误码 / 文案做输入。
|
||||
//
|
||||
// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError
|
||||
// 归类为 typed errs.* error(wire type 为 "api" 类别,不再是 legacy 的
|
||||
// *output.ExitError / "api_error"),但仍保留 code 与 message。与 drive/okr 等
|
||||
// 已迁移域一致:用 errs.ProblemOf 读 typed envelope,断言不弱化。
|
||||
func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Body: map[string]interface{}{
|
||||
"code": 500002511,
|
||||
"msg": "k_dl_1600000:Invalid DB Branch:dev",
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("error.type = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Code != 500002511 {
|
||||
t.Fatalf("error.code = %d, want 500002511", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Message, "Invalid DB Branch") {
|
||||
t.Fatalf("error.message missing 'Invalid DB Branch': %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_SuccessReturnsItemsWithStats(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "订单表",
|
||||
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"name": "orders"`) {
|
||||
t.Fatalf("stdout missing table name: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"estimated_row_count": 1200`) {
|
||||
t.Fatalf("stdout missing estimated_row_count: %s", got)
|
||||
}
|
||||
// CLI 裁剪:json 默认不透出每表 columns[],折算成 column_count(mock 给了 2 列)。
|
||||
if !strings.Contains(got, `"column_count": 2`) {
|
||||
t.Fatalf("stdout missing column_count (should replace columns[]): %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"columns"`) {
|
||||
t.Fatalf("stdout should NOT contain raw columns[] (stripped to column_count): %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// pretty 5 列 + 列名 (size / columns,不是 size_bytes / column_count) + size 友好格式(KB) +
|
||||
// 空 description 用 "—" 占位。
|
||||
func TestAppsDBTableList_PrettyRendersFiveColumnsHumanReadable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/tables",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "orders",
|
||||
"description": "Order entries",
|
||||
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
|
||||
"estimated_row_count": 1200,
|
||||
"size_bytes": 81920, // 80 KB
|
||||
},
|
||||
map[string]interface{}{
|
||||
"name": "customers",
|
||||
"description": "",
|
||||
"columns": []interface{}{map[string]interface{}{"name": "id"}},
|
||||
"estimated_row_count": 350,
|
||||
"size_bytes": 24576, // 24 KB
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// Header 行 5 列命名。
|
||||
wantHeader := "name description estimated_row_count size columns"
|
||||
// rows
|
||||
wantOrders := "orders Order entries 1200 80 KB 2"
|
||||
wantCustomers := "customers — 350 24 KB 1"
|
||||
for _, want := range []string{wantHeader, wantOrders, wantCustomers} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing line %q\nactual output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// 禁止出现旧列名 / 原始字节。
|
||||
for _, banned := range []string{"size_bytes", "column_count", "81920", "24576"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("pretty output contains %q (must be human-formatted)\noutput:\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected app-id required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev",
|
||||
"--page-size", "50", "--page-token", "cursor-abc",
|
||||
"--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "GET" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/tables" {
|
||||
t.Fatalf("dry-run method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
}
|
||||
if env.API[0].Params["env"] != "dev" {
|
||||
t.Fatalf("dry-run params.env = %v (want dev)", env.API[0].Params["env"])
|
||||
}
|
||||
if pz, _ := env.API[0].Params["page_size"].(float64); int(pz) != 50 {
|
||||
t.Fatalf("dry-run params.page_size = %v (want 50)", env.API[0].Params["page_size"])
|
||||
}
|
||||
if env.API[0].Params["page_token"] != "cursor-abc" {
|
||||
t.Fatalf("dry-run params.page_token = %v (want cursor-abc)", env.API[0].Params["page_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, ok := env.API[0].Params["include_stats"]; ok {
|
||||
t.Fatalf("CLI should not send include_stats query, but got params=%v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBTableList_RejectsBadEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"float64", float64(3.5), 3.5, true},
|
||||
{"float32", float32(2), 2, true},
|
||||
{"int", int(7), 7, true},
|
||||
{"int32", int32(8), 8, true},
|
||||
{"int64", int64(9), 9, true},
|
||||
{"uint", uint(10), 10, true},
|
||||
{"uint32", uint32(11), 11, true},
|
||||
{"uint64", uint64(12), 12, true},
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := numericAsFloat(c.in)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("numericAsFloat(%v) = %v,%v want %v,%v", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFloat_IntegerVsFractional(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{24, "24"},
|
||||
{1.5, "1.5"},
|
||||
{2.04, "2.0"},
|
||||
{0, "0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatFloat(c.in); got != c.want {
|
||||
t.Errorf("formatFloat(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanBytes_UnitBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in interface{}
|
||||
want string
|
||||
}{
|
||||
{"non-numeric", "x", "—"},
|
||||
{"bytes", float64(512), "512 B"},
|
||||
{"kb", float64(2048), "2 KB"},
|
||||
{"mb fractional", float64(1572864), "1.5 MB"},
|
||||
{"gb integer", float64(2 * 1024 * 1024 * 1024), "2 GB"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := humanBytes(c.in); got != c.want {
|
||||
t.Errorf("humanBytes(%v)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntString_Cases(t *testing.T) {
|
||||
if got := intString(float64(42)); got != "42" {
|
||||
t.Errorf("intString(42)=%q want 42", got)
|
||||
}
|
||||
if got := intString("x"); got != "—" {
|
||||
t.Errorf("intString(non-numeric)=%q want —", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveColumnCount_Cases(t *testing.T) {
|
||||
if got := deriveColumnCount(map[string]interface{}{"columns": []interface{}{1, 2, 3}}); got != 3 {
|
||||
t.Errorf("deriveColumnCount=%d want 3", got)
|
||||
}
|
||||
if got := deriveColumnCount(map[string]interface{}{}); got != 0 {
|
||||
t.Errorf("deriveColumnCount(missing)=%d want 0", got)
|
||||
}
|
||||
if got := deriveColumnCount(map[string]interface{}{"columns": "notarray"}); got != 0 {
|
||||
t.Errorf("deriveColumnCount(wrongtype)=%d want 0", got)
|
||||
}
|
||||
}
|
||||
380
shortcuts/apps/apps_env_pull.go
Normal file
380
shortcuts/apps/apps_env_pull.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]*
|
||||
var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type envPullDatabaseInfo struct {
|
||||
Detected bool
|
||||
ExpiresAtRaw string
|
||||
ExpiresAtText string
|
||||
}
|
||||
|
||||
// AppsEnvPull pulls startup env vars for an app into the local .env.local file.
|
||||
var AppsEnvPull = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+env-pull",
|
||||
Description: "Pull app startup env vars into the local project .env.local",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +env-pull --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID"},
|
||||
{Name: "project-path", Desc: "local project root path (defaults to current directory)"},
|
||||
},
|
||||
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"}
|
||||
}
|
||||
_, 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}
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Pull app startup env vars into the local .env.local file").
|
||||
Set("project_path", projectPath).
|
||||
Set("env_file", envFile)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
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}
|
||||
}
|
||||
if err := checkEnvPullTarget(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
|
||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if envVars == nil {
|
||||
envVars = map[string]string{}
|
||||
}
|
||||
envVars["FORCE_DB_BRANCH"] = "dev"
|
||||
original, err := readEnvPullFile(envFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged, updated, created := mergeEnvPullFileContent(original, envVars)
|
||||
if err := ensureEnvPullParentDir(envFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
|
||||
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
|
||||
}
|
||||
|
||||
result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
|
||||
})
|
||||
_ = updated
|
||||
_ = created
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
projectPath = cwd
|
||||
}
|
||||
if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
projectPath = filepath.Clean(projectPath)
|
||||
return projectPath, filepath.Join(projectPath, ".env.local"), nil
|
||||
}
|
||||
|
||||
func checkEnvPullTarget(envFile string) error {
|
||||
info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write.
|
||||
if err != nil {
|
||||
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}
|
||||
}
|
||||
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"}
|
||||
}
|
||||
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 nil
|
||||
}
|
||||
|
||||
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
|
||||
raw := data["env_vars"]
|
||||
if raw == nil {
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
raw = nested["env_vars"]
|
||||
}
|
||||
}
|
||||
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"}}
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
switch typed := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]string, len(typed))
|
||||
for key, value := range typed {
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
skippedKeys = append(skippedKeys, key)
|
||||
continue
|
||||
}
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = s
|
||||
}
|
||||
return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil
|
||||
case []interface{}:
|
||||
out := make(map[string]string, len(typed))
|
||||
info := envPullDatabaseInfo{}
|
||||
for _, item := range typed {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := entry["key"].(string)
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
skippedKeys = append(skippedKeys, key)
|
||||
continue
|
||||
}
|
||||
value, ok := entry["value"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
if key == "SUDA_DATABASE_URL" {
|
||||
info.Detected = true
|
||||
info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"])
|
||||
}
|
||||
}
|
||||
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"}}
|
||||
}
|
||||
}
|
||||
|
||||
func readEnvPullFile(envFile string) (string, error) {
|
||||
data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err}
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func ensureEnvPullParentDir(envFile string) error {
|
||||
dir := filepath.Dir(envFile)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir.
|
||||
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) {
|
||||
if len(envVars) == 0 {
|
||||
if original == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return ensureTrailingNewline(original), nil, nil
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(original, "\r\n", "\n")
|
||||
lines := []string{}
|
||||
if normalized != "" {
|
||||
lines = strings.Split(normalized, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
}
|
||||
|
||||
used := make(map[string]bool, len(envVars))
|
||||
updated := make([]string, 0, len(envVars))
|
||||
for i, line := range lines {
|
||||
key, ok := parseEnvPullAssignmentLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, exists := envVars[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
lines[i] = formatEnvPullAssignment(key, value)
|
||||
updated = append(updated, key)
|
||||
used[key] = true
|
||||
}
|
||||
|
||||
created := make([]string, 0, len(envVars))
|
||||
pending := make([]string, 0, len(envVars))
|
||||
for key := range envVars {
|
||||
if used[key] {
|
||||
continue
|
||||
}
|
||||
pending = append(pending, key)
|
||||
}
|
||||
sort.Strings(pending)
|
||||
for _, key := range pending {
|
||||
lines = append(lines, formatEnvPullAssignment(key, envVars[key]))
|
||||
created = append(created, key)
|
||||
}
|
||||
|
||||
sort.Strings(updated)
|
||||
content := strings.Join(lines, "\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
return content, updated, created
|
||||
}
|
||||
|
||||
func parseEnvPullAssignmentLine(line string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") {
|
||||
remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t"))
|
||||
if remainder == "" || strings.HasPrefix(remainder, "=") {
|
||||
return "", false
|
||||
}
|
||||
trimmed = remainder
|
||||
}
|
||||
idx := strings.Index(trimmed, "=")
|
||||
if idx <= 0 {
|
||||
return "", false
|
||||
}
|
||||
key := strings.TrimSpace(trimmed[:idx])
|
||||
if key == "" || strings.ContainsAny(key, " \t") {
|
||||
return "", false
|
||||
}
|
||||
return key, true
|
||||
}
|
||||
|
||||
func formatEnvPullAssignment(key, value string) string {
|
||||
return fmt.Sprintf("%s=%s", key, strconv.Quote(value))
|
||||
}
|
||||
|
||||
func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"env_file": envFile,
|
||||
}
|
||||
if databaseInfo.ExpiresAtRaw != "" {
|
||||
result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasEnvPullDatabase(envVars map[string]string) bool {
|
||||
_, ok := envVars["SUDA_DATABASE_URL"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) {
|
||||
extras, ok := rawExtras.([]interface{})
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
for _, raw := range extras {
|
||||
entry, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, _ := entry["key"].(string)
|
||||
if key != "expiresAt" {
|
||||
continue
|
||||
}
|
||||
switch value := entry["value"].(type) {
|
||||
case string:
|
||||
rawValue := strings.TrimSpace(value)
|
||||
ts, err := strconv.ParseInt(rawValue, 10, 64)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
|
||||
case float64:
|
||||
ts := int64(value)
|
||||
rawValue := strconv.FormatInt(ts, 10)
|
||||
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) {
|
||||
fmt.Fprintf(w, "✓ App detected: %s\n", appID)
|
||||
if databaseInfo.Detected {
|
||||
fmt.Fprintln(w, "✓ Development database detected")
|
||||
}
|
||||
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
|
||||
if databaseInfo.ExpiresAtText != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText)
|
||||
}
|
||||
if len(skippedKeys) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", "))
|
||||
}
|
||||
fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.\n")
|
||||
}
|
||||
|
||||
func ensureTrailingNewline(s string) string {
|
||||
if s == "" || strings.HasSuffix(s, "\n") {
|
||||
return s
|
||||
}
|
||||
return s + "\n"
|
||||
}
|
||||
1081
shortcuts/apps/apps_env_pull_test.go
Normal file
1081
shortcuts/apps/apps_env_pull_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user