Compare commits

..

6 Commits

Author SHA1 Message Date
zhanghuanxu
efbb4f13e7 docs: tighten lark slides template preservation 2026-06-29 11:34:01 +08:00
zhanghuanxu
c906fcac7e feat: support PDF imports as slides 2026-06-26 15:42:09 +08:00
zhanghuanxu
6ddbbafc4f feat: expose slides presentation url 2026-06-26 14:46:47 +08:00
zhanghuanxu
bf9264c901 fix: stop advertising slides screenshot scope 2026-06-26 14:46:46 +08:00
zhanghuanxu
e9f8d1d94b feat: add slides xml get shortcut 2026-06-26 14:46:46 +08:00
zhanghuanxu
a520b7ca93 feat: add slides replace-pages shortcut 2026-06-26 14:46:46 +08:00
314 changed files with 1854 additions and 29533 deletions

View File

@@ -2,103 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.62] - 2026-07-01
### Features
- **vc**: Add meeting message send shortcut (#1643)
- **doc**: Add document word statistics helper (#1697)
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
### Bug Fixes
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
- **base**: Support JSON array input for field create (#1661)
- **task**: Expose completion state in `my tasks` output (#1641)
- **cli**: Reduce public content credential false positives (#1700)
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
@@ -1333,11 +1236,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55

View File

@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -1,49 +0,0 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.

View File

@@ -1,19 +0,0 @@
# contact
> skill: lark-contact
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

View File

@@ -67,21 +67,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]

View File

@@ -19,7 +19,6 @@ import (
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
@@ -171,10 +170,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
@@ -195,7 +190,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
@@ -211,12 +205,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)

View File

@@ -129,10 +129,7 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,19 +4,14 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -145,84 +140,14 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
return check
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

View File

@@ -10,22 +10,10 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -39,8 +27,6 @@ func TestRunList_TextOutput(t *testing.T) {
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -71,15 +57,9 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if key, ok := row["key"].(string); ok {
gotKeys[key] = row
}
}
var foundTask bool
for key, row := range gotKeys {
if key == "task.task.update_user_access_v2" {
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
@@ -89,12 +69,4 @@ func TestRunList_JSONOutput(t *testing.T) {
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := gotKeys[want]; !ok {
t.Errorf("JSON list output missing %q", want)
}
}
}

View File

@@ -124,45 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["key"] != key {
t.Errorf("key = %v, want %s", payload["key"], key)
}
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
}
properties, ok := resolved["properties"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
}
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
if _, ok := properties[field]; !ok {
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
}
}
if _, ok := properties["end_time"]; ok {
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
}
})
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -11,11 +11,9 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
@@ -30,60 +28,43 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Search users
lark-cli contact +search-user --query "John"
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Install all skills:
npx skills add larksuite/cli -g -y
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Learn more: https://github.com/larksuite/cli#agent-skills
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -548,49 +529,6 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
return available, deprecated
}
// Root command help groups, so an agent sees content domains, agent tooling, and
// CLI management as distinct blocks instead of one flat alphabetical dump.
const (
groupDomains = "lark-domains"
groupTooling = "agent-tooling"
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
if c.GroupID != "" {
continue
}
switch {
case tooling[c.Name()]:
c.GroupID = groupTooling
case management[c.Name()]:
c.GroupID = groupManagement
case isLarkDomain(c):
c.GroupID = groupDomains
}
}
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
return true
}
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
@@ -672,17 +610,6 @@ func installTipsHelpFunc(root *cobra.Command) {
defer func() { f.Hidden = true }()
}
}
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {

View File

@@ -76,13 +76,11 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
// The human skills-install guidance now lives in the root usage-template
// footer (below the command list), not in the agent-facing Long.
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}

View File

@@ -1,90 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
// (install-method detection, output, error handling). Package-level var so
// tests can stub it and avoid real network / self-update.
var runRootUpgrade = func(cmd *cobra.Command) {
for _, c := range cmd.Root().Commands() {
if c.Name() == "update" && c.RunE != nil {
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
return
}
}
}
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
// AND no flag tokens in the raw invocation.
func isBareRootInvocation(args []string) bool {
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
}
// readYes reads one line and reports whether it is an affirmative y/yes.
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
func readYes(r io.Reader) bool {
line, _ := bufio.NewReader(r).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}
runRootUpgrade(cmd)
}
// installRootUpgradePrompt wraps the root command's RunE (set to
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}
}

View File

@@ -1,191 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadYes(t *testing.T) {
cases := map[string]bool{
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
}
for in, want := range cases {
if got := readYes(strings.NewReader(in)); got != want {
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsBareRootInvocation(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
if !isBareRootInvocation([]string{}) {
t.Error("empty args + no raw flag tokens should be bare")
}
rawInvocationArgs = []string{"--profile", "x"}
if isBareRootInvocation([]string{}) {
t.Error("flag token present → not bare")
}
rawInvocationArgs = nil
if isBareRootInvocation([]string{"im"}) {
t.Error("positional arg → not bare")
}
}
func TestOfferRootUpgrade(t *testing.T) {
origV := build.Version
build.Version = "1.0.0" // release version so shouldSkip()==false
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
// This test builds a Factory literal (no NewDefault), so it never runs
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
cases := []struct {
name string
in, out, err bool
input string
latest string // "" → no state file (CheckCached nil)
optOut bool
wantPrompt, wantRun bool
}{
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Clear env that update.shouldSkip treats as "suppress" so the
// test is deterministic regardless of host (GitHub Actions sets
// CI=true, which would otherwise suppress the prompt).
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(tc.input),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: tc.in,
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
})
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
innerCalls := 0
root := &cobra.Command{Use: "lark-cli"}
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
}
if err := root.RunE(root, []string{"im"}); err != nil {
t.Fatalf("non-bare RunE err = %v", err)
}
if innerCalls != 2 {
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
}
}
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
// path (not the stub used elsewhere): from any command it must locate the
// registered "update" subcommand via cmd.Root() and invoke its RunE.
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
ran := 0
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
child := &cobra.Command{Use: "im"}
root.AddCommand(child)
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
if ran != 1 {
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
}
}
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
// when root has no RunE, installRootUpgradePrompt must not wrap it.
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}
}

View File

@@ -4,211 +4,41 @@
package service
import (
"encoding/json"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
// skill pointer) to a top-level Lark domain's description, returning false for
// anything that is not such a domain. Built lazily at help time because
// shortcuts attach after service registration. skillFS (nil-safe) gates the
// skill pointer.
//
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
// consume <EventKey>'…"); service domains carry only a Short at this point, so
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
// Direct child of root only — so Domain() reads this command's own tag, and
// nested resource groups are excluded.
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
return false
}
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
return false
}
if !cmd.HasAvailableSubCommands() {
return false
}
hasShortcuts, hasResources := false, false
for _, c := range cmd.Commands() {
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
continue
}
if strings.HasPrefix(c.Name(), "+") {
hasShortcuts = true
} else {
hasResources = true
}
}
var b strings.Builder
b.WriteString(domainHelpBase(cmd))
if hasShortcuts && hasResources { // routing only matters when both styles exist
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
cmd.Long = b.String()
return true
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
if base == "" {
base = cmd.Short
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
return base
}
// methodLong is the build-time Long (description + schema pointer +
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
// so command construction never parses the overlay.
func methodLong(description, schemaPath, paramsOnly string) string {
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
}
}
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
}
schemaPath, ok := ann[schemaPathAnnotation]
if !ok {
return false
}
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
skills = a.Skills
}
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
// it carries none.
func RenderAffordanceForCmd(cmd *cobra.Command) string {
raw, ok := affordanceRaw(cmd)
if !ok {
return ""
}
return renderAffordance(meta.Method{Affordance: raw})
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
}
// renderAffordance renders a method's affordance as a help block, or "" when it
// has none. Sections are joined with blank lines so they scan as distinct groups.
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var sections []string
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
@@ -219,18 +49,15 @@ func renderAffordance(m meta.Method) string {
if len(nonEmpty) == 0 {
return
}
var s strings.Builder
fmt.Fprintf(&s, "%s:\n", title)
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&s, " • %s\n", it)
fmt.Fprintf(&b, " • %s\n", it)
}
sections = append(sections, strings.TrimRight(s.String(), "\n"))
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.AvoidWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
bullets("Tips", a.Tips)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
@@ -244,13 +71,10 @@ func renderAffordance(m meta.Method) string {
}
}
if len(lines) > 0 {
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
for _, ext := range a.Extensions {
bullets(ext.Label, ext.Items)
}
bullets("Related", a.Related)
return strings.Join(sections, "\n\n")
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -8,18 +8,15 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"avoid_when": ["群已解散"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"tips": ["富文本用 msg_type=post"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
@@ -32,7 +29,6 @@ func TestRenderAffordance(t *testing.T) {
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Tips:", "富文本用 msg_type=post",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
@@ -52,12 +48,9 @@ func TestRenderAffordance(t *testing.T) {
}
}
// Affordance is rendered lazily (at --help time) rather than baked into the
// command's Long, so building a command never carries the affordance block —
// even for a method whose metadata happens to declare one.
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
@@ -66,120 +59,14 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if strings.Contains(cmd.Long, "Examples:") {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
// lookup and renders it; commands without a ref render nothing.
func TestRenderAffordanceForCmd(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service != "im" || methodID != "messages.create" {
return nil, false
}
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
block := RenderAffordanceForCmd(cmd)
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
if !strings.Contains(block, want) {
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
}
}
// No overlay for this method id -> empty block.
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
if got := RenderAffordanceForCmd(cmd2); got != "" {
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
}
}
// PrepareMethodHelp composes the guidance into Long at the top: description,
// then the affordance block, then the full-schema pointer — so an agent reads
// when-to-use/examples before the flag list.
func TestPrepareMethodHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
// Description leads; affordance block sits above the schema pointer.
descAt := strings.Index(long, "发送消息")
useAt := strings.Index(long, "When to use:")
exAt := strings.Index(long, "Examples:")
schemaAt := strings.Index(long, "Full parameter schema:")
if descAt != 0 {
t.Errorf("description should lead Long, got:\n%s", long)
}
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "event", Short: short, Long: long}
cmdmeta.SetDomain(dom, "event")
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
return dom
}
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
dom := domainCmd("Consume and manage real-time events", long)
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, long) {
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
}
if !strings.Contains(dom.Long, "Risk levels") {
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
}
// Re-rendering must not append the guidance a second time.
PrepareDomainHelp(dom, nil)
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
}
}
// A service domain carries only a Short at help time; it seeds the base.
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
}
}

View File

@@ -60,11 +60,8 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
// The redundant "<name>, required|optional." prefix is gone: required-ness is
// carried by the Required:/Optional: subheadings, and the snake-case --params
// key by the schema envelope — so it isn't echoed on every flag line.
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)

View File

@@ -30,11 +30,6 @@ func fieldFacts(f meta.Field) []string {
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if f.CanonicalType() == "boolean" {
// cobra shows no type word for bools and swallows a separate value as a
// positional, so spell out the presence-only contract.
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
@@ -47,15 +42,20 @@ func fieldFacts(f meta.Field) []string {
return facts
}
// paramFlagUsage renders the typed param flag's help line: the field's facts
// joined inline. Required/optional is not repeated here — the grouped help's
// Required:/Optional: subheadings already partition the flags — and the
// snake-case --params key is carried by the schema envelope (each param's
// property + "flag") and the params-only addendum, so it isn't echoed on every
// line either. Returns "" when the field has no facts (cobra then shows the bare
// flag with its type).
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
func paramFlagUsage(f meta.Field) string {
return strings.Join(fieldFacts(f), ". ")
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
@@ -103,23 +103,8 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
// cross-reference is dropped first (see cutDocRef).
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";\n\r", 60) }
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
// On the compact flag line the markdown link's URL is stripped, so the
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
// so a subject that runs straight into the phrase isn't orphaned.
var docRefRe = regexp.MustCompile(`[。;;,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
// cutDocRef truncates s at the first doc-reference breadcrumb.
func cutDocRef(s string) string {
if loc := docRefRe.FindStringIndex(s); loc != nil {
return s[:loc[0]]
}
return s
}
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -65,38 +64,15 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
refs := apicatalog.ServiceMethods(svc, nil)
// Collect each resource's verbs up front so resourceShort can summarize a
// resource as its verb list from the first ensureChildCommand call.
verbs := map[string][]string{}
for _, ref := range refs {
key := strings.Join(ref.ResourcePath, ".")
verbs[key] = append(verbs[key], ref.Method.Name)
}
for _, ref := range refs {
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// resourceShort summarizes a resource as its sorted verb list, or the
// "<name> operations" placeholder for an intermediate group with no methods.
func resourceShort(seg string, verbs []string) string {
if len(verbs) == 0 {
return seg + " operations"
}
sorted := append([]string(nil), verbs...)
sort.Strings(sorted)
return strings.Join(sorted, ", ")
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
@@ -201,19 +177,7 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
paginates bool // method accepts a page_token param (so --page-all is meaningful)
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
}
// methodPaginates reports whether a method takes a page_token param, the signal
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
func methodPaginates(m meta.Method) bool {
for _, f := range m.Params() {
if f.Name == "page_token" {
return true
}
}
return false
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -222,7 +186,6 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
serviceName: ref.Service.Name,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
@@ -230,7 +193,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
paginates: methodPaginates(m),
affordance: renderAffordance(m),
}
}
@@ -291,14 +254,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
// Keep the pagination flags registered (a harmless no-op if passed) but hide
// them from help on non-paginating commands, so help doesn't imply a
// get/write can paginate.
if !spec.paginates {
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
@@ -316,11 +271,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
// (setMethodHelpData records the coordinates it needs).
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
@@ -338,11 +292,13 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// Keep the precedence rule on the flag's own one line (not a multi-line
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
// meaningful when typed flags exist to override.
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
if len(spec.params) > 0 {
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {

View File

@@ -1,163 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"context"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
// whoamiResult is the structured output of `lark-cli whoami`.
//
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
// the app acting as itself; a user identity is the app acting *on behalf of* a
// person (calls are attributed to that user, who is not necessarily present).
// onBehalfOf only *names* that person and so appears only once a user is
// resolved — a user identity that is not signed in still has identity "user"
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
DefaultAs string `json:"defaultAs"`
Identity string `json:"identity"`
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
}
// delegatedUser is the user a user-identity acts on behalf of.
type delegatedUser struct {
UserName string `json:"userName,omitempty"`
OpenID string `json:"openId,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. Output is always
// JSON — whoami is consumed by agents. With the built-in credential path it is
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
// Output is always JSON. Accept (and ignore) --json so existing
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
// mode exists.
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
_ = cmd.Flags().MarkHidden("json")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
return err
}
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
if err := f.CheckStrictMode(ctx, as); err != nil {
return err
}
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
source := resolveSource(
cmd.Flags().Changed("as"),
flagAs,
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
// resolveSource derives how the effective identity became effective.
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto_detect"
}
if strictForced != "" {
return "strict_mode"
}
return "default_as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = diag.Bot.Hint
}
default: // user
res.Available = diag.User.Available
// Use Status (not the raw TokenStatus) so the vocab matches the bot
// branch: "ready" means usable for both. available stays the canonical
// usable signal; tokenStatus is the readable state behind it.
res.TokenStatus = diag.User.Status
// Set onBehalfOf only when a user is actually resolved; an unresolved
// user identity (not signed in) has no one to act on behalf of yet.
if diag.User.UserName != "" || diag.User.OpenID != "" {
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
}
if !diag.User.Available {
res.Hint = diag.User.Hint
}
}
return res
}

View File

@@ -1,320 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestResolveSource(t *testing.T) {
tests := []struct {
name string
changedAs bool
flagAs core.Identity
autoDetected bool
strictForced core.Identity
want string
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
{"auto detected", false, "", true, "", "auto_detect"},
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
{"default_as", false, "", false, "", "default_as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
if got != tt.want {
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
t.Fatalf("app context = %#v", r)
}
}
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
// whoami renders the diagnosed hint verbatim (single source of truth) so it
// stays correct for the external-provider path without whoami knowing about it.
if r.Hint != diag.User.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
}
}
func TestBuildResult_BotReady(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OnBehalfOf != nil {
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
}
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint != diag.Bot.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
}
}
func TestWhoami_BotJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if !got.Available || got.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
}
if got.Profile != "test-profile" {
t.Fatalf("profile = %q, want test-profile", got.Profile)
}
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OnBehalfOf != nil {
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
}
}
func TestWhoami_RejectsInvalidAs(t *testing.T) {
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
t.Run("as="+bad, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", bad})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
}
// Lock in the typed validation contract: an unsupported identity must
// surface as a *errs.ValidationError on --as, not just any error.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--as" {
t.Errorf("Param = %q, want %q", ve.Param, "--as")
}
})
}
}
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
wantErr := fmt.Errorf("boom")
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want propagated config error")
}
// The f.Config() failure must propagate unchanged, not be masked by a later
// command-execution error.
if !errors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// Regression for the external-provider blind spot: with credentials managed by
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
t.Fatalf("got %#v, want user/available/ready", got)
}
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
}
if got.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.Hint)
}
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || got.Available {
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
}
if strings.Contains(got.Hint, "auth login") {
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
}
if !strings.Contains(got.Hint, "external") {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list. The embed must live
// in this root package because go:embed cannot reach up out of a package's dir.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
// init wires the embedded content into the CLI. It compiles into `go build .` but
// not the single-file preview build (`go build ./main.go`), so that build stays
// self-contained (shipping no embedded content). Assembly failures warn on stderr
// rather than panicking — embedded content is nice-to-have, not load-bearing.
func init() {
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
affordance.SetSource(sub)
}
}

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
type VCParticipantMeetingJoinedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingJoinedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -1,281 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
schemaType reflect.Type
}{
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
} {
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:meeting.meetingevent: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 def.Schema.Custom.Type != tc.schemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle(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: eventTypeMeetingStarted,
process: processVCParticipantMeetingStarted,
},
{
name: "joined",
eventType: eventTypeMeetingJoined,
process: processVCParticipantMeetingJoined,
},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_001",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
if out["type"] != tc.eventType {
t.Errorf("type = %q", out["type"])
}
if out["event_id"] != "ev_vc_lifecycle_001" {
t.Errorf("event_id = %q", out["event_id"])
}
if out["timestamp"] != "1608725989000" {
t.Errorf("timestamp = %q", out["timestamp"])
}
if out["meeting_id"] != "6911188411934433028" {
t.Errorf("meeting_id = %q", out["meeting_id"])
}
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
}
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
t.Errorf("start_time = %q, want %q", out["start_time"], want)
}
if _, hasEndTime := out["end_time"]; hasEndTime {
t.Error("end_time should not be present in started/joined output")
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_002",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
switch tc.eventType {
case eventTypeMeetingStarted:
var started VCParticipantMeetingStartedOutput
if err := json.Unmarshal(out, &started); err != nil {
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
}
if started.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", started.StartTime)
}
case eventTypeMeetingJoined:
var joined VCParticipantMeetingJoinedOutput
if err := json.Unmarshal(out, &joined); err != nil {
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
}
if joined.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
}
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
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 TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", 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 != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventType)
})
}
}
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
t.Helper()
got := runMeetingLifecycleRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out map[string]string
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
}
return out
}
func runMeetingLifecycleRaw(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
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
type VCParticipantMeetingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingStartedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -11,8 +11,6 @@ import (
)
const (
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
@@ -32,38 +30,6 @@ const (
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingStarted,
DisplayName: "Participant meeting started",
Description: "Triggered when a meeting the current user participates in has started",
EventType: eventTypeMeetingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
},
Process: processVCParticipantMeetingStarted,
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
},
{
Key: eventTypeMeetingJoined,
DisplayName: "Participant meeting joined",
Description: "Triggered when the current user joins a meeting",
EventType: eventTypeMeetingJoined,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
},
Process: processVCParticipantMeetingJoined,
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
},
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance is the lazily-loaded store of usage guidance for
// service-API methods. The source of truth is one markdown file per service in
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
// domain owners maintain it next to skills/ and shortcuts/. A service is read
// and parsed at most once, on first access, so normal command execution never
// touches it.
package affordance
import (
"encoding/json"
"io/fs"
"strings"
"sync"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
var (
mu sync.Mutex
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
// For returns the raw affordance overlay for one method, loading the owning
// service on first access. ok is false when there is no entry (absent source,
// parse failure, or unknown method all collapse to "no guidance").
func For(service, methodID string) (json.RawMessage, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return nil
}
m := map[string]json.RawMessage{}
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list") via the registry's
// authoritative resource↔id table. Resource names are irregularly pluralised
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"encoding/json"
"testing"
"testing/fstest"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
// paragraph (use_when) and a fenced example.
const fixtureMD = "# approval\n" +
"> skill: lark-approval\n\n" +
"## instances cc\n" +
"把一个审批实例抄送给指定用户。\n\n" +
"### Examples\n\n" +
"**抄送给用户**\n" +
"```bash\n" +
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
"```\n\n" +
"## instances get\n" +
"查询某审批实例详情。\n\n" +
"### Examples\n\n" +
"**按 code 查询**\n" +
"```bash\n" +
"lark-cli approval instances get --instance-code \"x\"\n" +
"```\n"
func TestFor(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
// A seeded method in a seeded service resolves to its overlay.
raw, ok := For("approval", "instances.cc")
if !ok {
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
}
var a struct {
UseWhen []string `json:"use_when"`
Examples []struct {
Command string `json:"command"`
} `json:"examples"`
}
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("overlay is not valid affordance JSON: %v", err)
}
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
t.Errorf("overlay missing use_when/examples: %s", raw)
}
// Misses: unknown method in a known service, and an unknown service, both
// resolve to ok=false (no panic, no error) so callers treat them as "no
// guidance".
if _, ok := For("approval", "instances.no_such_method"); ok {
t.Error("unknown method should be ok=false")
}
if _, ok := For("no_such_service", "x.y"); ok {
t.Error("unknown service should be ok=false")
}
// A second lookup of the same service is served from cache (parsed at most
// once) and stays consistent.
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
// dropped (regression: they previously only updated pending, lost without a fence).
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
t.Errorf("Tips paragraph dropped: %v", a.Tips)
}
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}

View File

@@ -1,180 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
//
// # domain optional `> skill: <name>` applied to every method
// ## command e.g. `instances get`
// <lead paragraph> -> use_when (when this command is right)
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
// Parsing is lazy and cached (see For), so the constrained grammar is read at
// most once per domain.
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
// standardSection maps a section heading to its typed Affordance field; any
// other heading becomes an extension.
var standardSection = map[string]string{
"Avoid when": "avoid_when",
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
label string
items []string
cases []meta.AffordanceCase
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
out := map[string]meta.Affordance{}
var skill, curKey string
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
var secs []*mdSection
var sec *mdSection
var pending string
var fence []string
inFence := false
assemble := func() {
if curKey == "" {
return
}
if len(para) > 0 {
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
para = nil
}
var a meta.Affordance
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
a.AvoidWhen = s.items
case "prerequisites":
a.Prerequisites = s.items
case "tips":
a.Tips = s.items
case "examples":
a.Examples = s.cases
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
// flushPending appends a non-bullet paragraph line that was not consumed as
// an example description (i.e. no fence followed) to the current section's
// items, so prose under any section is preserved rather than dropped.
flushPending := func() {
if sec != nil && pending != "" {
sec.items = append(sec.items, linkToBacktick(pending))
pending = ""
}
}
for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
flushPending()
assemble()
curKey = resolve(line[3:])
reset()
continue
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
sec = &mdSection{label: strings.TrimSpace(line[4:])}
secs = append(secs, sec)
pending, fence, inFence = "", nil, false
continue
}
if curKey == "" {
continue
}
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
if t == "" {
if len(para) > 0 {
useWhen = append(useWhen, strings.Join(para, " "))
para = nil
}
} else {
para = append(para, t)
}
continue
}
// inside a section: a fenced block is an example command; otherwise the
// shape follows the writing (bullet item vs **description** before a fence).
if strings.HasPrefix(t, "```") {
if !inFence {
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue
}
if inFence {
fence = append(fence, line)
continue
}
if strings.HasPrefix(t, "-") {
flushPending()
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
} else if t != "" {
flushPending()
pending = strings.Trim(t, "* ")
}
}
flushPending()
assemble()
return out
}

View File

@@ -18,9 +18,6 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
@@ -30,24 +27,19 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}

View File

@@ -10,20 +10,8 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -27,13 +27,6 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
// Secure label endpoint codes observed from drive +secure-label-update
// failure telemetry.
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -102,35 +102,6 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
}
}
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_Unknown(t *testing.T) {
_, ok := LookupCodeMeta(999999)
if ok {

View File

@@ -13,7 +13,6 @@ import (
"strings"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -62,131 +61,12 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
if ctx == nil {
ctx = context.Background()
}
// An external provider mints tokens on demand and blocks interactive auth,
// so the built-in keychain heuristics and "auth login" hints don't apply.
if provider := activeExternalProvider(ctx, f); provider != "" {
return diagnoseExternal(ctx, f, cfg, provider, verify)
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
// activeExternalProvider returns the active extension provider name, or "".
// An error degrades to the built-in path: an unreachable provider would already
// have failed the f.Config() that produced cfg.
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
if f == nil || f.Credential == nil {
return ""
}
name, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return ""
}
return name
}
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
if cfg == nil || cfg.AppID == "" {
notConfigured := Identity{
Status: StatusNotConfigured,
Message: "not configured (missing app config)",
Hint: externalCredentialHint(provider),
}
return Result{Bot: notConfigured, User: notConfigured}
}
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
return Result{
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
}
}
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("Bot", provider)
}
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("User", provider)
}
// enrichUserInfo populates UserOpenId only after the provider returns and
// verifies a UAT (and clears it on failure), so a resolved open id is the
// external analogue of a keychain token being present.
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: not signed in via credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
id := Identity{
Status: StatusReady,
Available: true,
TokenStatus: StatusReady,
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
Message: "User identity: ready (provided by " + provider + ")",
}
if !verify {
return id
}
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
return externalVerifyFailed(id, "User", provider, err)
}
id.Verified = boolPtr(true)
return id
}
func notProvidedExternally(label, provider string) Identity {
return Identity{
Status: StatusNotConfigured,
Message: label + " identity: not provided by credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
// (open id, user name) already resolved before the probe.
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
id.Available = false
id.Verified = boolPtr(false)
id.Status = StatusVerifyFailed
id.TokenStatus = ""
id.Message = label + " identity: verify failed: " + err.Error()
id.Hint = externalCredentialHint(provider)
return id
}
// externalCredentialHint reports the constraint, not a remediation: the
// identity is the provider's to manage, not lark-cli's to fix. What to do about
// it is the caller's call — there may be no user to ask.
func externalCredentialHint(provider string) string {
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{

View File

@@ -10,11 +10,9 @@ import (
"testing"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
@@ -350,136 +348,3 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
// external-credential diagnosis path. account makes the provider "active";
// token (when set) satisfies ResolveToken during verify.
type fakeExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return p.token, nil
}
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
cred := credential.NewCredentialProvider(
[]extcred.Provider{prov}, nil, nil,
func() (*http.Client, error) { return nil, nil },
)
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{},
}
}
// assertExternalHint locks the contract that an external-provider hint never
// points at interactive commands blocked under an external provider.
func assertExternalHint(t *testing.T, hint string) {
t.Helper()
if hint == "" {
t.Fatalf("hint empty, want external guidance")
}
for _, blocked := range []string{"auth login", "config --help"} {
if strings.Contains(hint, blocked) {
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
}
}
if !strings.Contains(hint, "external") {
t.Fatalf("hint %q should explain credentials are external", hint)
}
}
func TestDiagnose_External_UserReady(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
// The bug this guards: the built-in path read the keychain (empty under an
// external provider) and reported the user as missing. Now availability
// follows the resolved account, so a signed-in user reads as ready.
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
t.Fatalf("user identity = %#v", got.User)
}
if got.User.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
}
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
}
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Available || got.User.Status != StatusMissing {
t.Fatalf("user = %#v, want missing/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_BotOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
// Provider declares bot-only: user is unavailable even though an open id is
// present, and the hint is external (not "auth login").
if got.User.Available || got.User.Status != StatusNotConfigured {
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_UserOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.User.Available || got.User.Status != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
}
assertExternalHint(t, got.Bot.Hint)
}
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user = %#v, want available and verified", got.User)
}
}
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Available || got.User.Status != StatusVerifyFailed {
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("verified = %v, want false", got.User.Verified)
}
assertExternalHint(t, got.User.Hint)
}

View File

@@ -5,39 +5,30 @@ package meta
import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
// Affordance is the hand-authored usage guidance overlaid on a method: when to
// use it, when not to, prerequisites, few-shot examples, and related methods.
// It is the single typed model of the affordance shape; the envelope renderer
// and the command help both parse through ParsedAffordance so the vocabulary
// is defined once. The JSON tags double as the envelope's wire shape.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Tips []string `json:"tips,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Extensions []AffordanceSection `json:"extensions,omitempty"`
Related []string `json:"related,omitempty"`
Skills []string `json:"skills,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
type AffordanceCase struct {
Description string `json:"description,omitempty"`
Description string `json:"description"`
Command string `json:"command"`
}
// AffordanceSection is a custom guidance section: any heading beyond the
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
// here with its label preserved, so authors can add sections without code
// changes.
type AffordanceSection struct {
Label string `json:"label"`
Items []string `json:"items,omitempty"`
}
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
// malformed, or wholly empty — callers treat all three as "no guidance".
// ParsedAffordance decodes the method's raw affordance overlay into the typed
// Affordance. ok is false when the method carries no affordance, the JSON is
// malformed, or every section is empty — so callers can treat "no guidance"
// uniformly.
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
@@ -46,7 +37,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
return Affordance{}, false
}
return a, true

View File

@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
notOK := map[string]string{
"empty payload": ``,
"empty object": `{}`,
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
@@ -35,9 +35,8 @@ func TestMethod_ParsedAffordance(t *testing.T) {
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"avoid_when": ["已知具体 calendar_id"],
"do_not_use_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
@@ -48,22 +47,10 @@ func TestMethod_ParsedAffordance(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
t.Errorf("Tips = %v", a.Tips)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
// A method whose only guidance is Tips still parses as populated.
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
if !ok {
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
}
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
t.Errorf("Tips = %v", tipsOnly.Tips)
}
}

View File

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

View File

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

View File

@@ -52,9 +52,6 @@ func isPlaceholderValue(value string) bool {
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
printfPlaceholderValue(normalized) ||
htmlEntityAnglePlaceholder(normalized) ||
starMaskedPlaceholder(normalized) ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
@@ -64,42 +61,12 @@ func isPlaceholderValue(value string) bool {
return namedPlaceholderValue(normalized)
}
func htmlEntityAnglePlaceholder(value string) bool {
if !strings.HasPrefix(value, "&lt;") || !strings.HasSuffix(value, "&gt;") {
return false
}
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "&lt;"), "&gt;"))
}
func starMaskedPlaceholder(value string) bool {
var stars int
for _, r := range value {
if r == '*' {
stars++
continue
}
return false
}
return stars >= 3
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") ||
allXPlaceholder(value) ||
conventionalNamedPlaceholderValue(value)
}
func printfPlaceholderValue(value string) bool {
switch value {
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
return true
default:
return false
}
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
}
func allXPlaceholder(value string) bool {
@@ -114,41 +81,6 @@ func allXPlaceholder(value string) bool {
return true
}
func conventionalNamedPlaceholderValue(value string) bool {
if !delimitedPlaceholderIdentifier(value) {
return false
}
normalized := strings.ReplaceAll(value, "-", "_")
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
return conventionalCredentialPlaceholderName(rest)
}
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
return conventionalCredentialPlaceholderName(rest)
}
return false
}
func conventionalCredentialPlaceholderName(value string) bool {
switch value {
case "api_key",
"access_key",
"private_key",
"secret",
"password",
"passwd",
"token",
"webhook",
"access_token",
"refresh_token",
"bearer_token",
"session_token",
"client_secret":
return true
default:
return false
}
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||

View File

@@ -4,8 +4,6 @@
package publiccontent
import (
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"sort"
@@ -54,9 +52,8 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isBenignCodeCredentialExpression(file, value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
@@ -66,15 +63,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
if isSchemaDottedIdentifier(line, match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
if isPlaceholderBearerHeader(match) {
continue
}
for range bearerHeaderRE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
@@ -267,7 +261,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(key, value)
return tokenLikePlaceholderValue(value)
default:
return false
}
@@ -279,13 +273,12 @@ func tokenLikePlaceholderKey(key string) bool {
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(key, value string) bool {
func tokenLikePlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
@@ -295,51 +288,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
strings.HasPrefix(normalized, ".")
}
func maskedTokenFixturePlaceholderValue(key, value string) bool {
if authCredentialTokenKey(key) {
return false
}
var stars, alnum int
for _, r := range value {
switch {
case r == '*':
stars++
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
alnum++
default:
return false
}
}
return stars >= 6 && alnum > 0
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"refresh_token",
"session_token",
"bearer_token",
"auth_token",
"authorization_token",
"id_token":
return true
default:
return false
}
}
func isPermissionScopeIdentifierAssignment(key, value string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
switch strings.ToLower(strings.Trim(value, `"',;`)) {
case "read", "write", "modify", "readonly", "get_as_user":
return true
default:
return false
}
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
@@ -380,87 +328,20 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
func isBenignCodeCredentialExpression(file, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || credentialShapedValue(value) {
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
}
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
return true
}
if strings.Contains(match, "+") {
return true
}
if rawValueQuoted {
return false
}
if quotedLiteral(value) {
return sourceCodeLiteralLooksNonSecret(value, false)
}
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
assignmentIdx := strings.Index(typeAndRHS, "=")
if assignmentIdx < 0 {
return "", false
}
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ",;")
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
return true
}
if credentialShapedValue(value) {
return false
}
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
return true
}
if quotedLiteral(value) {
return false
}
return codeReferenceExpression(value)
}
func credentialAssignmentRawValueQuoted(match string) bool {
key, ok := credentialAssignmentKey(match)
if !ok {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
case ".go", ".py":
return true
default:
return false
@@ -474,147 +355,7 @@ func quotedLiteral(value string) bool {
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
literal := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(literal, "/") {
return true
}
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
sourceCodeEnvVarNameLiteral(literal) ||
sourceCodeAttributeNameLiteral(literal) ||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
}
func sourceCodeFormatArgumentContext(line, match string) bool {
idx := strings.Index(line, match)
if idx < 0 {
return false
}
prefix := line[:idx]
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
prefix = prefix[semicolon+1:]
}
return strings.Contains(prefix, "fmt.") ||
strings.Contains(prefix, "log.") ||
strings.Contains(prefix, "printf(") ||
strings.Contains(prefix, "Printf(") ||
strings.Contains(prefix, "Errorf(") ||
strings.Contains(prefix, "Fprintf(")
}
func sourceCodeFormatStringLiteral(value string) bool {
for i := 0; i < len(value)-1; i++ {
if value[i] != '%' {
continue
}
if value[i+1] == '%' {
i++
continue
}
j := i + 1
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
j++
}
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
return true
}
}
return false
}
func sourceCodeEnvVarNameLiteral(value string) bool {
if value == "" || !strings.Contains(value, "_") {
return false
}
var hasCredentialMarker bool
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_':
default:
return false
}
}
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
if strings.Contains(value, marker) {
hasCredentialMarker = true
break
}
}
return hasCredentialMarker
}
func sourceCodeAttributeNameLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
}
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "fake_") ||
strings.HasPrefix(normalized, "fake-") ||
strings.Contains(normalized, "placeholder") ||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
}
func sourceCodeCredentialTermLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
return conventionalCredentialPlaceholderName(normalized)
}
func sourceCodeCredentialPrefixLiteral(value string) bool {
switch strings.ToLower(value) {
case "appsecret:":
return true
default:
return false
}
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
return true
default:
return false
}
}
func sourceCodeSchemaTypeLiteral(value string) bool {
normalized := strings.ToLower(value)
return normalized == "string" || strings.HasPrefix(normalized, "string(")
}
func benignCredentialStatusLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
if !delimitedPlaceholderIdentifier(normalized) {
return false
}
for _, marker := range []string{
"bad_fmt",
"expired",
"format",
"invalid",
"missing",
"permission",
"status",
"type",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func codeReferenceExpression(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ";")
if value == "" {
return false
}
@@ -623,10 +364,7 @@ func codeReferenceExpression(value string) bool {
return true
}
}
if !codeIdentifier(value) {
return false
}
return codeIdentifier(value)
return codeIdentifier(value) && !credentialNameFragment(value)
}
func codeIdentifier(value string) bool {
@@ -643,6 +381,20 @@ func codeIdentifier(value string) bool {
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isSchemaDottedIdentifier(line, match string) bool {
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
@@ -652,40 +404,6 @@ func isNonSecretLiteralValue(value string) bool {
}
}
func isJWTToken(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 3 {
return false
}
header, err := decodeBase64URLSegment(parts[0])
if err != nil || !json.Valid(header) {
return false
}
var fields map[string]interface{}
if err := json.Unmarshal(header, &fields); err != nil {
return false
}
alg, ok := fields["alg"].(string)
return ok && alg != ""
}
func decodeBase64URLSegment(value string) ([]byte, error) {
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.URLEncoding.DecodeString(value)
}
func isPlaceholderBearerHeader(match string) bool {
normalized := strings.ToLower(match)
idx := strings.LastIndex(normalized, "bearer ")
if idx < 0 {
return false
}
value := strings.TrimSpace(match[idx+len("bearer "):])
return isPlaceholderValue(value)
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
@@ -1023,12 +741,7 @@ func sanitizeSemanticExcerpt(text string) string {
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
if isJWTToken(match) {
return "<jwt-like-token>"
}
return match
})
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}

View File

@@ -211,7 +211,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
}
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
if !findingRules(got)["public_content_detector_fingerprint"] {
t.Fatalf("expected detector fingerprint finding, got %#v", got)
}
@@ -549,7 +549,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
}
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
"inPrivateKey = true",
"inPrivateKey = false",
"hasPrivateKey: false",
@@ -725,7 +725,7 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
@@ -734,7 +734,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -743,7 +743,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
}
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
"AppSecret: stored,",
"AccessToken: result.Token.AccessToken,",
`token := runtime.Str("token")`,
@@ -756,7 +756,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
}
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
"def normalize_token(value: str) -> str:",
" token = rest[index]",
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
@@ -770,174 +770,8 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
}
}
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
"class Counter:",
" def __init__(self) -> None:",
" self._token_kind: TokenKind | None = None",
" self.access_token: AccessToken | None = None",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
`return fmt.Errorf("failed to remove token: %v", err)`,
`const LarkErrTokenMissing = "token_missing"`,
`const LarkErrTokenExpired = 99991677`,
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
`const LargeAttachmentTokenAttr = "data-mail-token"`,
`const fakeOfficeTokenPrefix = "fake_office_"`,
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
`tokenTypeHint := "access_token"`,
`const TokenTenant Token = "tenant"`,
`const secretKeyPrefix = "appsecret:"`,
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
`return &credential.TokenResult{Token: "test-token"}, nil`,
`fmt.Fprintf(w, "password=%s\n", pat)`,
`text += "(img_token:" + imgToken + ")"`,
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
`this.token = token;`,
`// AppSecret: "appsecret:<appId>"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
`{"token":"&lt;wiki_token&gt;"}`,
`{"token":"Pgrrwvr***********UnRb"}`,
`"scope_name": "auth:user_access_token:read"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
"LARKSUITE_CLI_APP_SECRET=dry-run",
"client_secret: dry_run",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
cases := []struct {
name string
file string
text string
}{
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ScanFile(tc.file, []byte(tc.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("typed credential assignment should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
"client_secret=%s",
"access_token=%v",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
`<img token="..." url="https://..." width="..." height="..."/>`,
`<sheet token="..." sheet-id="...">`,
}, "\n")+"\n"))
@@ -949,7 +783,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
}
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
@@ -957,38 +791,8 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
}
}
func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) {
got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{
"| Method | Permission |",
"| --- | --- |",
"| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |",
"| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got)
}
}
}
func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) {
got := ScanFile("docs/api.md", []byte(strings.Join([]string{
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"corehr:employment.international_assignment.custom_field.apaas_id__c:read",
"user_mailbox.sent_messages.get_recall_detail queries recall detail.",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got)
}
}
}
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"1704067200"}`,
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
}, "\n")+"\n"))
@@ -1001,7 +805,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
@@ -1017,7 +821,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
}
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
`{ "block_token": "boardXXXX" }`,
`{ "resource_token": "doc_token_or_url" }`,
`{ "token": "canonical_token" }`,
@@ -1037,7 +841,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
@@ -1053,7 +857,7 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
}
@@ -1154,19 +958,6 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
}
}
func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) {
got := ScanFile("docs/auth.md", []byte(strings.Join([]string{
"Authorization: Bearer YOUR_ACCESS_TOKEN",
`{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`,
"Authorization: Bearer <access-token>",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
t.Fatalf("bearer placeholder should not be bearer finding: %#v", got)
}
}
}
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
token := "abcdefghijklmnopqrstuvwxyz"
text := "private launch plan for internal rollout on Friday\n" +
@@ -1184,22 +975,6 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
}
}
func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) {
text := "private launch plan for internal rollout on Friday\n" +
"Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n"
got := semanticCandidate("docs/public.md", "file", text, 1)
if len(got) != 1 {
t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
}
if strings.Contains(got[0].Excerpt, "<jwt-like-token>") {
t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0])
}
if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") {
t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0])
}
}
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
text := strings.Join([]string{
"Generated with automated code assistant",
@@ -1237,37 +1012,6 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
}
}
func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: YOUR_CLIENT_SECRET",
"api_key: YOUR_API_KEY",
"password: YOUR_PASSWORD",
"access_token: ACCESS_TOKEN_HERE",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"

View File

@@ -4,11 +4,8 @@
package schema
import (
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
@@ -25,7 +22,7 @@ func Convert(f meta.Field) Property {
if f.Type == "file" {
p.Format = "binary"
}
p.Description = normalizeDesc(f.Description)
p.Description = f.Description
p.Default = f.CoercedDefault()
p.Example = f.CoercedExample()
p.Minimum = f.MinBound()
@@ -55,24 +52,6 @@ func Convert(f meta.Field) Property {
return p
}
var (
sepRunRe = regexp.MustCompile(`[;]{2,}`)
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
)
// normalizeDesc de-crufts a meta_data description for the envelope — strips
// markdown emphasis and collapses doubled separators/spaces — but keeps content
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
func normalizeDesc(s string) string {
if s == "" {
return ""
}
s = strings.ReplaceAll(s, "**", "")
s = sepRunRe.ReplaceAllString(s, "; ")
s = spaceRunRe.ReplaceAllString(s, " ")
return strings.TrimRight(s, " ;;。.,、\n")
}
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
// arrays for the envelope. enumDescriptions is nil unless at least one value
// carries a description (so the bare-enum form stays values-only), keeping the
@@ -107,18 +86,6 @@ func propsOf(fields []meta.Field) *OrderedProps {
return op
}
// paramPropsOf is propsOf for the params section: each property also carries
// its CLI flag (--kebab-name).
func paramPropsOf(fields []meta.Field) *OrderedProps {
op := &OrderedProps{}
for _, f := range fields {
p := Convert(f)
p.Flag = "--" + f.FlagName()
op.Set(f.Name, p)
}
return op
}
// requiredOf returns the alphabetized names of the required fields.
func requiredOf(fields []meta.Field) []string {
var required []string
@@ -141,17 +108,16 @@ func buildInputSchema(m meta.Method) *InputSchema {
Properties: &OrderedProps{},
}
addInputObject(is, "params", "", m.Params(), true, "")
addInputObject(is, "data", "", m.Data(), false, "--data")
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
addInputObject(is, "params", "", m.Params())
addInputObject(is, "data", "", m.Data())
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
if m.Risk == core.RiskHighRiskWrite {
falseVal := false
is.Properties.Set("yes", Property{
Type: "boolean",
Flag: "--yes",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
})
}
@@ -159,24 +125,20 @@ func buildInputSchema(m meta.Method) *InputSchema {
return is
}
// addInputObject adds one section (params/data/file) when it has fields, marking
// the section required at top level when any field is. asFlags tags each property
// with its --flag (params only); carrier names the section's flag (--data/--file).
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
// addInputObject adds one named sub-object section (params/data/file) to the
// input schema when it has fields: its Properties come from the fields, its
// Required lists the mandatory keys, and the section itself is required at top
// level when any field is required. Empty sections are skipped.
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
if len(fields) == 0 {
return
}
props := propsOf(fields)
if asFlags {
props = paramPropsOf(fields)
}
req := requiredOf(fields)
is.Properties.Set(name, Property{
Type: "object",
Description: description,
Carrier: carrier,
Required: req,
Properties: props,
Properties: propsOf(fields),
})
if len(req) > 0 {
is.Required = append(is.Required, name)
@@ -217,13 +179,7 @@ func buildMeta(m meta.Method) *Meta {
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
m := ref.Method
// The affordance overlay lives in the CLI, not the metadata; look it up
// lazily here (it takes precedence over any affordance the metadata carries).
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
m.Affordance = raw
}
return assemble(ref.Service.Name, ref.ResourcePath, m)
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
}
// Envelopes renders the given method refs into envelopes, sorted by name. The
@@ -249,7 +205,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
return Envelope{
Name: name,
Description: normalizeDesc(m.Description),
Description: m.Description,
InputSchema: buildInputSchema(m),
OutputSchema: buildOutputSchema(m),
Meta: buildMeta(m),

View File

@@ -9,9 +9,7 @@ import (
"reflect"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
@@ -506,31 +504,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
// service + method id), so a method whose metadata carries none still gets
// guidance in its envelope when an overlay entry exists.
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
// The overlay source is the top-level affordance/ tree, injected at startup;
// inject a fixture so this unit test does not depend on the shipped content.
// Reset afterwards (this binary installs no source by default) for isolation.
t.Cleanup(func() { affordance.SetSource(nil) })
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
if env.Meta == nil || env.Meta.Affordance == nil {
t.Fatal("expected affordance from the approval overlay, got none")
}
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
}
// A method id with no overlay entry carries no affordance.
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
if bare.Meta != nil && bare.Meta.Affordance != nil {
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},

View File

@@ -13,10 +13,6 @@ import (
)
// Envelope is the MCP Tool spec contract for a single API method command.
//
// The REST route (httpMethod/path) is deliberately NOT exposed: every
// schema-resolvable method already has a typed command, so the raw path would
// only tempt an agent toward the `api` escape hatch.
type Envelope struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -48,15 +44,9 @@ type OutputSchema struct {
// "params" / "data" sub-objects inside inputSchema): it lists which keys
// inside that object's Properties are mandatory. Leaf fields ignore it.
type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
// absent on body/file fields, which travel via the section's Carrier.
Flag string `json:"flag,omitempty"`
// Carrier names the flag a whole inputSchema section travels on ("--data" /
// "--file"); empty on the params section, whose properties carry their Flag.
Carrier string `json:"carrier,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
// each allowed value, in the same order. Omitted when no value carries a
// description. This is the widely-recognized JSON-Schema extension (VS Code,

View File

@@ -16,14 +16,6 @@ import (
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
// EnvNoProxyWarn suppresses the proxy-detected warning when set to any
// non-empty value, while leaving proxy behavior unchanged. Unlike
// EnvNoProxy (which both silences the warning AND disables the proxy), this
// keeps proxy egress active. It exists so agents consuming --format json can
// keep using the proxy without the human-oriented warning line landing in
// the output stream and breaking JSON parsing.
EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
@@ -81,11 +73,6 @@ func redactProxyURL(raw string) string {
// are redacted. Safe to call multiple times; only the first call prints.
func WarnIfProxied(w io.Writer) {
proxyWarningOnce.Do(func() {
// Explicit opt-out: silence the warning without touching proxy behavior.
// Checked before the plugin and env-proxy branches so it suppresses both.
if os.Getenv(EnvNoProxyWarn) != "" {
return
}
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// Shared), so its warning and disable instructions take precedence.
// Emitting the env-proxy warning here would be misleading: it tells the
@@ -101,7 +88,7 @@ func WarnIfProxied(w io.Writer) {
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n",
key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn)
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
})
}

View File

@@ -93,47 +93,6 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
}
}
// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN
// suppresses the warning while the proxy stays configured (unlike
// LARK_CLI_NO_PROXY, which also disables the proxy).
func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxyWarn, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
}
}
// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that
// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning.
func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
t.Cleanup(func() { proxyPluginStatus = old })
t.Setenv(EnvNoProxyWarn, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.63",
"version": "1.0.57",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -5,12 +5,7 @@
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
// CommonJS script works on all supported Node versions (require() of an ESM
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
// point below before main() runs.
let p;
const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
@@ -379,12 +374,7 @@ async function main() {
}
}
(async () => {
p = await import("@clack/prompts");
await main();
})().catch((err) => {
const msg = "Unexpected error: " + (err.message || err);
if (p) p.cancel(msg);
else console.error(msg);
main().catch((err) => {
p.cancel("Unexpected error: " + (err.message || err));
process.exit(1);
});

View File

@@ -1,207 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultAppsAnalyticsEnv = "online"
defaultAppsAnalyticsGranular = "day"
analyticsListEndpoint = "query_analytics_data"
)
// AppsAnalyticsList lists online app product analytics.
var AppsAnalyticsList = common.Shortcut{
Service: appsService,
Command: "+analytics-list",
Description: "List online app user and page-view analytics",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +analytics-list --app-id <app_id> --analytics users --granularity week",
"Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"},
{Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}},
{Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"},
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
{Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"},
{Name: "page", Desc: "frontend page or route filter"},
{Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}},
{Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, _, _, err := buildAnalyticsListBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _, _, _ := buildAnalyticsListBody(rctx)
return common.NewDryRunAPI().
POST(analyticsListPath(rctx.Str("app-id"))).
Desc("List online app analytics").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
body, types, labels, err := buildAnalyticsListBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := observabilitySeriesOutput{
Items: normalizeAnalyticsSeries(data, types, labels),
HasMore: false,
}
rctx.OutFormat(out, nil, func(w io.Writer) {
rows := observabilitySeriesRows(out.Items)
sortObservabilityRowsDesc(rows, "timestamp_ns")
rows = filterObservabilityRowsWithTime(rows, "timestamp_ns")
appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels))
})
return nil
},
}
func analyticsListPath(appID string) string {
return appScopedPath(appID, analyticsListEndpoint)
}
func buildAnalyticsListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) {
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
if env == "" {
env = defaultAppsAnalyticsEnv
}
if err := validateObservabilityEnv(env); err != nil {
return nil, nil, nil, err
}
types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type"))
if err != nil {
return nil, nil, nil, err
}
since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until"))
if err != nil {
return nil, nil, nil, err
}
aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity"))
if err != nil {
return nil, nil, nil, err
}
if page := strings.TrimSpace(rctx.Str("page")); page != "" {
filter["page"] = page
}
body := map[string]interface{}{
"metric_types": types,
"start_timestamp_ns": nsNumber(since),
"end_timestamp_ns": nsNumber(until),
"time_aggregation_unit": aggregation,
"need_pack_lack_point": false,
}
if len(filter) > 0 {
body["filter"] = filter
}
return body, types, labels, nil
}
func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) {
name = strings.TrimSpace(strings.ToLower(name))
series = strings.TrimSpace(strings.ToLower(series))
deviceType = strings.TrimSpace(strings.ToLower(deviceType))
filter := make(map[string]interface{})
if deviceType != "" {
switch deviceType {
case "desktop", "mobile":
filter["device_types"] = []string{deviceType}
default:
return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile")
}
}
switch name {
case "users":
switch series {
case "":
return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil
case "active", "active-users":
return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil
case "new", "new-users":
return []string{"NEW_USER"}, []string{"new-users"}, filter, nil
case "total", "total-users":
return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil
default:
return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total")
}
case "page-view":
switch series {
case "", "all":
return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil
case "desktop", "desktop-view":
if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil {
return nil, nil, nil, err
}
return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil
case "mobile", "mobile-view":
if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil {
return nil, nil, nil, err
}
return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil
default:
return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile")
}
default:
return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view")
}
}
func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error {
if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType {
return appsValidationParamError("--device-type", "--device-type conflicts with --series")
}
filter["device_types"] = []string{deviceType}
return nil
}
func analyticsGranularityForCLI(granularity string) (string, error) {
switch strings.TrimSpace(strings.ToLower(granularity)) {
case "", "day":
return "DAY", nil
case "week":
return "WEEK", nil
case "month":
return "MONTH", nil
default:
return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month")
}
}
func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} {
items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns")
fillObservabilityZeroesWhenPartiallyPresent(items, labels)
return items
}
func analyticsSeriesSchema(labels []string) appsOutputSchema {
columns := []appsOutputColumn{
{Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")},
}
for _, label := range labels {
columns = append(columns, appsOutputColumn{Key: label})
}
return appsOutputSchema{Columns: columns, Strict: true}
}

View File

@@ -1,459 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users",
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
"--granularity", "week", "--dry-run", "--as", "user",
}, factory, stdout)
if 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"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
body := env.API[0].Body
if _, ok := body["start_timestamp_ns"]; !ok {
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
}
if _, ok := body["start_timestamp"]; ok {
t.Fatalf("analytics should not use start_timestamp: %#v", body)
}
if body["time_aggregation_unit"] != "WEEK" {
t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"])
}
if _, ok := body["app_env"]; ok {
t.Fatalf("analytics OpenAPI body should not include app_env: %#v", body)
}
if _, ok := body["analytics_types"]; ok {
t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body)
}
if body["need_pack_lack_point"] != false {
t.Fatalf("need_pack_lack_point = %#v, want false", body["need_pack_lack_point"])
}
if _, ok := body["group_by"]; ok {
t.Fatalf("group_by is intentionally unsupported for now: %#v", body)
}
if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 {
t.Fatalf("metric_types = %#v", body["metric_types"])
}
if body["start_timestamp_ns"] != "1782208800000000000" ||
body["end_timestamp_ns"] != "1782208860000000000" {
t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"])
}
}
func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{
name: "series",
args: []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
"--series", "desktop", "--page", "/home", "--dry-run", "--as", "user",
},
},
{
name: "device-type",
args: []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
"--device-type", "desktop", "--dry-run", "--as", "user",
},
},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsAnalyticsList, tc.args, 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(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
filter := env.API[0].Body["filter"].(map[string]interface{})
deviceTypes := filter["device_types"].([]interface{})
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)
}
if tc.name == "series" && filter["page"] != "/home" {
t.Fatalf("filter.page = %#v, want /home", filter["page"])
}
})
}
}
func TestAppsAnalyticsList_DesktopSeriesUsesDesktopValueLabel(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"metric_type": "PAGE_VIEW",
"points": []interface{}{
map[string]interface{}{
"timestamp_ns": float64(1782208800000000000),
"value": float64(21),
},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
"--series", "desktop", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
if env.Data.Items[0].Values["desktop"] != float64(21) {
t.Fatalf("values = %#v, want desktop=21", env.Data.Items[0].Values)
}
if _, ok := env.Data.Items[0].Values["page-view"]; ok {
t.Fatalf("values should not use page-view label: %#v", env.Data.Items[0].Values)
}
}
func TestAppsAnalyticsList_PrettyFormatsTimeFirst(t *testing.T) {
const rawNS = int64(1782208800000000000)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"metric_type": "ACTIVE_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(rawNS), "value": float64(7)},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05")
if !strings.HasPrefix(got, "time") {
t.Fatalf("pretty output should start with time column, got:\n%s", got)
}
if !strings.Contains(got, wantTime) {
t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got)
}
if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782208800000000000") {
t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got)
}
}
func TestAppsAnalyticsList_PrettySkipsRowsWithoutTime(t *testing.T) {
const rawNS = int64(1782208800000000000)
rows := []map[string]interface{}{
{"timestamp_ns": rawNS, "active-users": float64(7)},
{"active-users": float64(0)},
}
sortObservabilityRowsDesc(rows, "timestamp_ns")
rows = filterObservabilityRowsWithTime(rows, "timestamp_ns")
if len(rows) != 1 {
t.Fatalf("rows len = %d, want 1: %#v", len(rows), rows)
}
if rows[0]["timestamp_ns"] != rawNS {
t.Fatalf("remaining row = %#v", rows[0])
}
}
func TestAppsAnalyticsList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"metric_type": "TOTAL_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(20)},
},
},
map[string]interface{}{
"metric_type": "ACTIVE_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(7)},
},
},
map[string]interface{}{
"metric_type": "NEW_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(3)},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
values := env.Data.Items[0].Values
if values["active-users"] != float64(7) || values["new-users"] != float64(3) || values["total-users"] != float64(20) {
t.Fatalf("values = %#v, want active-users=7 new-users=3 total-users=20", values)
}
}
func TestAppsAnalyticsList_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"timestamp_ns": "1782208800000000000",
"values": map[string]interface{}{
"total-users": float64(4),
"active-users": nil,
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
values := env.Data.Items[0].Values
if values["total-users"] != float64(4) || values["active-users"] != float64(0) || values["new-users"] != float64(0) {
t.Fatalf("values = %#v, want total-users=4 active-users=0 new-users=0", values)
}
}
func TestAppsAnalyticsList_DoesNotFillAllNullValues(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"timestamp_ns": "1782208800000000000",
"values": map[string]interface{}{
"total-users": nil,
"active-users": nil,
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
values := env.Data.Items[0].Values
if values["total-users"] != nil || values["active-users"] != nil {
t.Fatalf("values = %#v, want existing nulls preserved", values)
}
if _, ok := values["new-users"]; ok {
t.Fatalf("values should not fill missing labels when all present values are null: %#v", values)
}
}
func TestAppsAnalyticsList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []map[string]interface{} `json:"items"`
HasMore bool `json:"has_more"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if env.Data.Items == nil {
t.Fatalf("items decoded as nil; stdout=%s", stdout.String())
}
if len(env.Data.Items) != 0 || env.Data.HasMore {
t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore)
}
}
func TestAnalyticsTypesMapping(t *testing.T) {
types, labels, filter, err := analyticsTypesForCLI("users", "", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "ACTIVE_USER,NEW_USER,TOTAL_USER" {
t.Fatalf("types = %#v", types)
}
if strings.Join(labels, ",") != "active-users,new-users,total-users" {
t.Fatalf("labels = %#v", labels)
}
if len(filter) != 0 {
t.Fatalf("filter = %#v, want empty", filter)
}
types, labels, filter, err = analyticsTypesForCLI("page-view", "", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "all" {
t.Fatalf("page-view all mapping = %#v %#v", types, labels)
}
if len(filter) != 0 {
t.Fatalf("filter = %#v, want empty", filter)
}
types, labels, filter, err = analyticsTypesForCLI("page-view", "desktop", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "desktop" {
t.Fatalf("page-view mapping = %#v %#v", types, labels)
}
deviceTypes := filter["device_types"].([]string)
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)
}
types, labels, filter, err = analyticsTypesForCLI("page-view", "mobile-view", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "mobile" {
t.Fatalf("page-view mobile mapping = %#v %#v", types, labels)
}
deviceTypes = filter["device_types"].([]string)
if len(deviceTypes) != 1 || deviceTypes[0] != "mobile" {
t.Fatalf("device_types = %#v", deviceTypes)
}
if _, _, _, err := analyticsTypesForCLI("users", "desktop", ""); err == nil {
t.Fatalf("users desktop series should fail")
}
if _, _, _, err := analyticsTypesForCLI("page-view", "tablet", ""); err == nil {
t.Fatalf("page-view tablet series should fail")
}
if _, _, _, err := analyticsTypesForCLI("page-view", "", "tablet"); err == nil {
t.Fatalf("tablet device type should fail")
}
}

View File

@@ -1,302 +0,0 @@
// 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_listcursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
// operator 透传 {id,name}json 还原对象、pretty 取 namebefore/after 是条件出现的 JSON
// INSERT 无 before、DELETE 无 afterjson 还原成对象。
//
// 多表查询时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: append([]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: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{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"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); 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 := dbEnv(rctx)
// 多表查询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": dbEnv(rctx),
"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 把服务端原始审计事件投影为白名单 auditLogItemoperator 解析、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, ", "))
}

View File

@@ -1,144 +0,0 @@
// 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_setbody {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: append([]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"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
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": dbEnv(rctx)}).
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": dbEnv(rctx)},
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_setbody {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: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
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": dbEnv(rctx)}).
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": dbEnv(rctx)},
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}
}

View File

@@ -1,140 +0,0 @@
// 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: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
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": dbEnv(rctx)}
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)
}

View File

@@ -1,316 +0,0 @@
// 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)
// schemaorders/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"},
}}},
})
// statusorders/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()
// skippedcarts(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)
}
}

View File

@@ -1,152 +0,0 @@
// 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 --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
//
// GET /apps/{app_id}/db/changelog_listcursor 分页)。过滤:--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: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{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 (bare date/datetime read in local timezone)"},
{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"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); 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": dbEnv(rctx),
"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 变更记录投影为白名单 changelogItemoperator 解析成对象)。
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)
}

View File

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

View File

@@ -1,194 +0,0 @@
// 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: append([]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)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); 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 := rejectOutputTraversal(rctx.Str("output")); err != nil {
return err
}
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": dbEnv(rctx), "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, dbEnv(rctx), table)
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"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 行,故取 mintotal 查询失败时按导出内容数行兜底。
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 调 GetAppTableRecordListpage_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 → 取其扩展名定 formatcsv/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
}

View File

@@ -1,193 +0,0 @@
// 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 验证越界 --limit0/-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-runformat 跟随 --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")
}
}

View File

@@ -1,144 +0,0 @@
// 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_importmultipart 表单file_name + 可选 table + 文件本体(与
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
// (按 file_name 扩展名推断 csv/jsonCLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 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: append([]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)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); 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": dbEnv(rctx), "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)
// multipartfile_name 走表单字段、文件本体走 form-filesenv / 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{dbEnv(rctx)}, "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))
}

View File

@@ -1,161 +0,0 @@
// 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-runmultipart 上传——file_name + file 走 bodyenv + table 走 querytable 缺省取文件名)。
// 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", "--environment", "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)
}
}

View File

@@ -12,11 +12,11 @@ import (
"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> --environment dev`"
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 an app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
var AppsDBEnvCreate = common.Shortcut{
Service: appsService,
@@ -24,20 +24,19 @@ var AppsDBEnvCreate = common.Shortcut{
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 --environment dev --sync-data --app-id <app_id> --yes",
"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: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "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)"},
}, dbEnvFlags("dev", []string{"dev"}, "environment to create (only dev supported for now)")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -63,7 +62,7 @@ var AppsDBEnvCreate = common.Shortcut{
}
// buildDBEnvCreateBody 构造 db 环境创建 bodysync_databool
// --environment 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"sync_data": rctx.Bool("sync-data"),

View File

@@ -27,7 +27,7 @@ func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--as", "user"},
[]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)
}
@@ -54,7 +54,7 @@ func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -82,7 +82,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
},
})
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
[]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)
}
@@ -103,7 +103,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"},
[]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)
}
@@ -116,7 +116,7 @@ func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "online", "--yes", "--as", "user"},
[]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)

View File

@@ -1,191 +0,0 @@
// 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_migratebody {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_migratebody {dry_run:false} → task_id轮询 env_migrate_status
// 至 success后端 status:appliedCLI 对外统一呈现 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, 2*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 把服务端原始变更项投影为白名单 migrationChangetype/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
}

View File

@@ -1,369 +0,0 @@
// 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_idstatus 立刻 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)
}
}

View File

@@ -12,12 +12,12 @@ import (
"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.
// AppsDBExecute executes SQL against an app database.
//
// POST /apps/{app_id}/sql_commandsCLI 永远带 ?transactional=false 进入 DBA 模式
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON
@@ -31,18 +31,12 @@ import (
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
//
// 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后升级成 typed errs.APIErrorCategoryAPI → exit 1避免 agent 误判 ok:true 假成功。诊断信息
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案errs.* 信封扁平、无
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断
// 后按 partial failure 上报exit 非 0stdout 输出 ok:false 数据,带 results /
// statement_index / error_code / error_message / rolled_back / note避免 agent 误判
// ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit
// 落地,故 rolled_back=false真机 boe 实证)
//
// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串):
// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`
// - 单 DML → data = `{command, rows_affected}`
// - 单 DDL → data = `{command}`
// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
//
// 字段裁剪用框架原生 --jq/-q。
// JSON envelope成功路径CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
//
// Risk: high-risk-write —— SQL 可含 DML/DDL框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
//
@@ -51,45 +45,51 @@ import (
var AppsDBExecute = common.Shortcut{
Service: appsService,
Command: "+db-execute",
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
Description: "Execute SQL (SELECT / DML / DDL) against an 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> --environment dev --file ./migration.sql --yes`,
"Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'",
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
Flags: []common.Flag{
{Name: "app-id", Desc: "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"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
{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
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
sql := strings.TrimSpace(rctx.Str("sql"))
file := strings.TrimSpace(rctx.Str("file"))
if sql != "" && file != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sql and --file are mutually exclusive")
return appsValidationError("--sql and --file are mutually exclusive").
WithParams(
appsInvalidParam("--sql", "mutually exclusive with --file"),
appsInvalidParam("--file", "mutually exclusive with --sql"),
)
}
if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
}
// 仅本地校验非空;不把文件内容写回公开的 --sql flag避免 SQL 内容进入
// flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。
// 归一化:把文件内容写回 --sql下游DryRun/Execute统一从 sql 取。
rctx.Cmd.Flags().Set("sql", string(data))
sql = strings.TrimSpace(string(data))
}
if sql == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --sql or --file is required (use --sql - to read stdin)")
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
WithParams(
appsInvalidParam("--sql", "one of --sql or --file is required"),
appsInvalidParam("--file", "one of --sql or --file is required"),
)
}
return nil
},
@@ -97,7 +97,7 @@ var AppsDBExecute = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appSQLPath(appID)).
Desc("Execute SQL on Miaoda app database").
Desc("Execute SQL on app database").
Params(buildDBSQLParams(rctx)).
Body(buildDBSQLBody(rctx))
},
@@ -110,30 +110,27 @@ var AppsDBExecute = common.Shortcut{
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 `--environment dev`")
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 形态
// server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results
// 让 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)
// 注意data.results 在 json默认路径下原样透出全部行CLI 侧不再二次截断。
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
data := map[string]interface{}{"results": stmts}
// 多语句 / 单语句失败server 仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。
// 升级成 typed api_errorexit 非 0别让 agent 误判 ok:true 假成功。
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→stderr
// 已落地的前序语句 + 失败语句构成 partial failure逐条结果作为 ok:false 数据
// 留在 stdout机器可读+ 非零退出信号,别让 agent 误判 ok:true 假成功
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope仅返回退出信号。
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
if rctx.Format == "pretty" {
renderSQLPretty(rctx.IO().Out, stmts)
return output.PartialFailure(output.ExitAPI)
}
return sqlStatementError(stmts, errIdx, errStmt)
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
@@ -143,70 +140,6 @@ var AppsDBExecute = common.Shortcut{
},
}
// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态:
// - 无语句 → [](空数组)
// - 单条语句 → singleStatementJSONSELECT 是行数组、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) {
@@ -218,48 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
return 0, nil, false
}
// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIErrorCategoryAPI → exit 1
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据
//
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
// message + hint 的人类可读文案errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
// miaoda-clisrc/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 {
// CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果ERROR 哨兵在
// 失败位置note 提示用户别整批重跑(否则会重复写入)。
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
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."
note := "no statements were applied; fix the SQL and re-run."
if errIdx > 0 {
note = fmt.Sprintf(
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
errIdx, stmtNo)
}
return errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint)
}
// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。
// 遍历已完成语句的 sql_typeBEGIN/START TRANSACTION +1COMMIT/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 map[string]interface{}{
"results": stmts,
"statement_index": errIdx,
"error_code": code,
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
"rolled_back": false,
"note": note,
}
return depth > 0
}
// parseErrorSentinel 解析 ERROR 哨兵的 data`{code,message}` JSON返回数值 code 与 message。
@@ -292,34 +205,15 @@ func parseErrorSentinel(data string) (int, string) {
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"env": dbEnv(rctx),
"env": rctx.Str("env"),
"transactional": false,
}
}
// resolveExecuteSQL 返回要执行的 SQL在用时DryRun/Execute现读使 --file 的内容
// 不被写回公开的 --sql flag避免泄露进 flag dump / 结构化日志)。优先 --sql内联或 stdin
// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。
func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) {
if strings.TrimSpace(rctx.Str("sql")) != "" {
return rctx.Str("sql"), nil
}
file := strings.TrimSpace(rctx.Str("file"))
if file == "" {
return "", nil
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return "", err
}
return string(data), nil
}
// buildDBSQLBody 构造 sql 接口的 body仅 sql由 resolveExecuteSQL 在用时解析,--file 不入 flag
// buildDBSQLBody 构造 sql 接口的 body仅 sql来源由 Validate 归一化到 --sql
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
sql, _ := resolveExecuteSQL(rctx)
return map[string]interface{}{
"sql": sql,
"sql": rctx.Str("sql"),
}
}
@@ -460,10 +354,10 @@ func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
}
fmt.Fprintln(w)
if failedIdx >= 0 {
// CLI 永远 transactional=false失败语句之前的语句已逐条 commit 落地、不会整批回滚——
// 如实告诉用户,避免整批重跑导致重复写入。
// CLI 永远 DBA 模式(transactional=false,失败语句之前的语句已 auto-commit 落地
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
if successCount > 0 {
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n",
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n",
failedIdx+1, successCount, plural(int64(successCount)))
} else {
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
@@ -567,7 +461,6 @@ func isDMLType(sqlType string) bool {
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":
@@ -582,7 +475,6 @@ func dmlVerb(sqlType string) string {
return "affected"
}
// plural 返回英文复数后缀n==1 时空串,否则 "s"。
func plural(n int64) string {
if n == 1 {
return ""

View File

@@ -5,18 +5,17 @@ package apps
import (
"encoding/json"
"errors"
"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) {
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -34,134 +33,27 @@ func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// PRD 单 SELECTdata 直接是行数组(不再是 data.results[].data 字符串)
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
var env struct {
Data []map[string]interface{} `json:"data"`
Data struct {
Results []map[string]interface{} `json:"results"`
} `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 len(env.Data.Results) != 1 {
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
}
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])
if env.Data.Results[0]["sql_type"] != "SELECT" {
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
}
}
// 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 单 DMLdata = {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 单 DDLdata = {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=falseDBA 模式)且 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", "--environment", "dev", "--dry-run", "--as", "user"},
[]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)
}
@@ -193,7 +85,6 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
}
}
// TestAppsDBExecute_RejectsEmptySQL 断言 --sql 全空白时校验报错(提示需要 --sql 或 --file
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBExecute,
@@ -203,23 +94,6 @@ func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
}
}
// TestAppsDBExecute_LegacyEnvFlagRejected 钉死:旧名 --env 已移除,显式传入报 validation 错并指向 --environment。
func TestAppsDBExecute_LegacyEnvFlagRejected(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("--env should be rejected; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Fatalf("want a typed validation error, got %T: %v", err, err)
}
if !strings.Contains(p.Message, "--environment") {
t.Errorf("message should point to --environment: %q", p.Message)
}
}
// --sql 与 --file 互斥
func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
@@ -250,7 +124,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--app-id", "app_x", "--environment", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
[]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)
}
@@ -273,7 +147,6 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
// 输入用 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)
@@ -305,9 +178,8 @@ func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
}
}
// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。
func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
// 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态data 直接是行)
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -325,20 +197,24 @@ func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data []map[string]interface{} `json:"data"`
Data struct {
Results []map[string]interface{} `json:"results"`
} `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 len(env.Data.Results) != 1 {
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
}
if env.Data[0]["x"] != float64(1) {
t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"])
if env.Data.Results[0]["sql_type"] != "SELECT" {
t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"])
}
if env.Data.Results[0]["record_count"] != float64(1) {
t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"])
}
}
// 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)
@@ -368,7 +244,6 @@ func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
}
}
// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时legacy DDLpretty 输出 "(empty result)"。
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
// BOE 实测CREATE TABLE → result: "" (空字符串,无 rows
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
@@ -395,7 +270,6 @@ func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
}
}
// TestAppsDBExecute_LegacyWireMultiSelectWithRealTable 断言含 CJK / uuid / int 字段的真实表行能正确显示在 pretty 表格里。
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
// BOE 实测真实表抓包course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
factory, stdout, reg := newAppsExecuteFactory(t)
@@ -454,7 +328,6 @@ func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) {
}
}
// TestAppsDBExecute_PrettyEmptySelect 断言空 SELECT 的 pretty 输出为 "(0 rows)"。
func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -477,7 +350,6 @@ func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
}
}
// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) <verb>"、各类 DDL含细粒度动词渲染 "✓ DDL executed"。
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
cases := []struct {
name string
@@ -514,7 +386,6 @@ func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
}
}
// TestAppsDBExecute_PrettyMultiStatementsAllSuccess 断言多语句全成功时逐条 Statement 摘要 + 末尾 "✓ N statements executed"。
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -584,7 +455,6 @@ func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) {
}
}
// TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel 断言多语句部分失败时 pretty 仍打逐条 ✓/✗ 摘要、声明前序已 commit 未回滚,且返回 typed error、不打成功汇总。
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -616,20 +486,19 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
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)
// DBA 模式transactional=false前序语句已 auto-commit 落地,绝不能误报「rolled back」
if strings.Contains(got, "rolled back") {
t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); 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")。
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout
// 退出信号是 PartialFailureError非零 exit。rolled_back=false 因 CLI 永远 DBA 模式
// (真机 boe 实证:失败前的语句已落地)。
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -649,36 +518,64 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
}
// json 失败路径不得打成功 envelope。
if strings.Contains(stdout.String(), `"ok": true`) {
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
if pfErr.Code != output.ExitAPI {
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
if p.Code != 1300002 {
t.Errorf("code = %d, want 1300002", p.Code)
payload := decodePartialFailureData(t, stdout.String())
if got := payload["statement_index"]; got != float64(1) {
t.Errorf("statement_index = %v, want 1", got)
}
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
t.Errorf("message missing statement locator: %q", p.Message)
if got := payload["error_code"]; got != float64(1300002) {
t.Errorf("error_code = %v, want 1300002", got)
}
// 无 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)
msg, _ := payload["error_message"].(string)
if !strings.Contains(msg, "(at statement 2 of 2)") {
t.Errorf("error_message missing statement locator: %q", msg)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
if got := payload["rolled_back"]; got != false {
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
}
results, _ := payload["results"].([]interface{})
if len(results) != 2 {
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
}
note, _ := payload["note"].(string)
if !strings.Contains(note, "already applied") {
t.Errorf("note should warn prior statements persisted, got %q", note)
}
}
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope返回 data 块。
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
t.Helper()
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
}
if envelope.OK {
t.Fatalf("envelope.ok = true, want false on partial failure")
}
if envelope.Data == nil {
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
}
return envelope.Data
}
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败server 也返 code:0 + ERROR 哨兵)
// 同样升级成 typed errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。
// 同样走 partial failurestatement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -695,92 +592,26 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
payload := decodePartialFailureData(t, stdout.String())
msg, _ := payload["error_message"].(string)
if !strings.Contains(msg, "(at statement 1 of 1)") {
t.Errorf("error_message missing locator: %q", msg)
}
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
t.Errorf("message missing locator: %q", p.Message)
if got := payload["statement_index"]; got != float64(0) {
t.Errorf("statement_index = %v, want 0", got)
}
// 第一条就失败、无落地 的语义写在 hint。
if !strings.Contains(p.Hint, "No statements were applied") {
t.Errorf("hint should state nothing applied: %q", p.Hint)
note, _ := payload["note"].(string)
if !strings.Contains(note, "no statements were applied") {
t.Errorf("note should say nothing was applied, got %q", note)
}
}
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
// 实测后端把 BEGIN 也作为 statement 返回completed 含未配对 BEGIN → inferRolledBack 判定回滚。
// 回滚语义现写在 hintmiaoda 原句 "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 实测 wireBEGIN; 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)
}
// 事务整批回滚 / 前序未落库 的语义写在 hintmiaoda 原句)。
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
@@ -804,7 +635,6 @@ func TestCellString_AllKinds(t *testing.T) {
}
}
// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。
func TestCodeString_Forms(t *testing.T) {
cases := []struct {
name string
@@ -826,7 +656,6 @@ func TestCodeString_Forms(t *testing.T) {
}
}
// TestDmlVerb_AllVerbs 断言 dmlVerb 对 INSERT/UPDATE/DELETE/MERGE 的动词映射(大小写不敏感),非 DML 返回 affected。
func TestDmlVerb_AllVerbs(t *testing.T) {
cases := map[string]string{
"INSERT": "inserted",
@@ -842,7 +671,6 @@ func TestDmlVerb_AllVerbs(t *testing.T) {
}
}
// 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)
@@ -855,7 +683,6 @@ func TestIntOrZero_Cases(t *testing.T) {
}
}
// TestErrorSummary_Cases 断言 errorSummary 对空 / 非法 JSON / 带 code / 无 code 各情形生成 "message [code]" 文案。
func TestErrorSummary_Cases(t *testing.T) {
cases := []struct {
name, in, want string
@@ -874,7 +701,6 @@ func TestErrorSummary_Cases(t *testing.T) {
}
}
// TestParseErrorSentinel_Cases 断言 parseErrorSentinel 解析 ERROR 哨兵 data 得到数值 code 与 message含空 / 非法 / 空 message 回退)。
func TestParseErrorSentinel_Cases(t *testing.T) {
cases := []struct {
name, in string
@@ -896,7 +722,6 @@ func TestParseErrorSentinel_Cases(t *testing.T) {
}
}
// 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")
@@ -909,7 +734,6 @@ func TestIsStructuredResult_Cases(t *testing.T) {
}
}
// 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("")
@@ -940,7 +764,6 @@ func TestNormalizeLegacyStatement_Cases(t *testing.T) {
})
}
// 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.
@@ -949,7 +772,6 @@ func TestCellString_MarshalFallback(t *testing.T) {
}
}
// TestRenderSingleStatementPretty_Branches 断言 renderSingleStatementPretty 对 SELECT/ERROR/DML/legacy OK/DDL 各分支的输出。
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
cases := []struct {
name string
@@ -973,7 +795,6 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) {
}
}
// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
cases := []struct {
name string
@@ -995,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
})
}
}
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
// contract on a statement failure: stdout carries only the per-statement
// human summary (no JSON envelope stacked after it), and the command still
// exits non-zero via the partial-failure signal.
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
out := stdout.String()
if !strings.Contains(out, "✗") {
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
}
if strings.Contains(out, `"ok"`) {
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
}
}

View File

@@ -1,101 +0,0 @@
// 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> --environment dev",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
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": dbEnv(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", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, 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)
}

View File

@@ -1,267 +0,0 @@
// 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_recoverybody {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_recoverybody {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, 2*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, 2*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"]))
}
// describeRecoveryChangeschema 动作 或 数据行变化二选一(无 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, ", ")
}

View File

@@ -11,7 +11,7 @@ import (
"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 --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
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)。
//
@@ -34,17 +34,15 @@ var AppsDBTableGet = common.Shortcut{
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
{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 err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("table")) == "" {
return appsValidationParamError("--table", "--table is required")
}
@@ -80,7 +78,7 @@ var AppsDBTableGet = common.Shortcut{
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": dbEnv(rctx)}
params := map[string]interface{}{"env": rctx.Str("env")}
if rctx.Format == "pretty" {
params["format"] = "ddl"
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const dbTableListHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
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 an app's database.
//
@@ -38,16 +38,15 @@ var AppsDBTableList = common.Shortcut{
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "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"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -111,7 +110,7 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
"env": rctx.Str("env"),
"page_size": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {

View File

@@ -31,7 +31,7 @@ func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
})
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"},
[]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())
@@ -159,7 +159,7 @@ func TestAppsDBTableList_RequiresAppID(t *testing.T) {
func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "dev",
[]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 {
@@ -212,7 +212,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
func TestAppsDBTableList_RejectsBadEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
[]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)
}

View File

@@ -1,412 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultAppsEnvVarEnv = "dev"
defaultAppsEnvVarScene = 2
)
// AppsEnvVarList lists app environment variables without values by default.
var AppsEnvVarList = common.Shortcut{
Service: appsService,
Command: "+env-list",
Description: "List app environment variables",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +env-list --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},
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
{Name: "include-values", Type: "bool", Desc: "include environment variable values"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(envVarCollectionPath(appID)).
Desc("List app environment variables").
Body(buildEnvVarListBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
includeValues := rctx.Bool("include-values")
data, err := rctx.CallAPITyped("POST", envVarCollectionPath(appID), nil, buildEnvVarListBody(rctx))
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := normalizeEnvVarListOutput(data, includeValues)
rctx.OutFormat(out, nil, func(w io.Writer) {
appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues))
})
return nil
},
}
// AppsEnvVarSet sets one app environment variable. Values are never printed.
var AppsEnvVarSet = common.Shortcut{
Service: appsService,
Command: "+env-set",
Description: "Set an app environment variable",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +env-set --app-id <app_id> --key FOO --value bar",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
{Name: "key", Desc: "environment variable key", Required: true},
{Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "yes", Type: "bool", Desc: "confirm setting variables in online"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil {
return err
}
if _, err := requireEnvVarKey(rctx.Str("key")); err != nil {
return err
}
if rctx.Str("value") == "" {
return appsValidationParamError("--value", "--value is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
key, _ := requireEnvVarKey(rctx.Str("key"))
return common.NewDryRunAPI().
POST(envVarCreateOrUpdatePath(appID)).
Desc("Set app environment variable").
Body(map[string]interface{}{
"key": key,
"env": envVarEnv(rctx),
"value": "<redacted>",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
env := envVarEnv(rctx)
if env == "online" && !rctx.Bool("yes") {
return errs.NewConfirmationRequiredError(
errs.RiskWrite,
"apps +env-set --environment online",
"apps +env-set --environment online requires confirmation",
).WithHint("add --yes to confirm")
}
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key, err := requireEnvVarKey(rctx.Str("key"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", envVarCreateOrUpdatePath(appID), nil, map[string]interface{}{
"key": key,
"env": env,
"value": rctx.Str("value"),
})
if err != nil {
return withAppsHint(err, envVarMutationHint(err))
}
action := envVarStringAny(data, "action")
if action == "" {
action = "set"
}
rctx.OutFormat(map[string]interface{}{
"key": key,
"env": env,
"action": action,
}, nil, nil)
return nil
},
}
// AppsEnvVarDelete deletes one or more app environment variables.
var AppsEnvVarDelete = common.Shortcut{
Service: appsService,
Command: "+env-delete",
Description: "Delete app environment variables",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +env-delete --app-id <app_id> --key FOO --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
{Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil {
return err
}
_, err := requireEnvVarKeys(rctx.StrArray("key"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
keys, _ := requireEnvVarKeys(rctx.StrArray("key"))
return common.NewDryRunAPI().
POST(envVarDeletePath(appID)).
Desc("Delete app environment variables").
Body(buildEnvVarDeleteBody(envVarEnv(rctx), keys))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
keys, err := requireEnvVarKeys(rctx.StrArray("key"))
if err != nil {
return err
}
env := envVarEnv(rctx)
data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys))
if err != nil {
return withAppsHint(err, envVarMutationHint(err))
}
deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys")
if len(deletedKeys) == 0 {
deletedKeys = keys
}
rctx.OutFormat(map[string]interface{}{
"env": env,
"deleted_keys": deletedKeys,
}, nil, nil)
return nil
},
}
func envVarEnv(rctx *common.RuntimeContext) string {
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
if env == "" {
return defaultAppsEnvVarEnv
}
return env
}
func envVarCollectionPath(appID string) string {
return appScopedPath(appID, "env_vars")
}
func envVarCreateOrUpdatePath(appID string) string {
return appScopedPath(appID, "create_or_update_env_var")
}
func envVarDeletePath(appID string) string {
return appScopedPath(appID, "delete_env_vars")
}
func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"env": envVarEnv(rctx),
"scene": defaultAppsEnvVarScene,
}
}
func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} {
return map[string]interface{}{
"env": env,
"keys": keys,
}
}
func envVarMutationHint(err error) string {
if isEnvVarNotModifiableError(err) {
return "this environment variable is platform-managed and cannot be modified; remove protected keys from --key and retry only with user-defined variables"
}
return appIDListHint
}
func isEnvVarNotModifiableError(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return strings.Contains(strings.ToLower(p.Message), "not modifiable")
}
func requireEnvVarKey(raw string) (string, error) {
key := strings.TrimSpace(raw)
if key == "" {
return "", appsValidationParamError("--key", "--key is required")
}
if !envKeyPattern.MatchString(key) {
return "", appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*")
}
return key, nil
}
func requireEnvVarKeys(raw []string) ([]string, error) {
keys := cleanRepeatedStrings(raw)
if len(keys) == 0 {
return nil, appsValidationParamError("--key", "--key is required")
}
for _, key := range keys {
if !envKeyPattern.MatchString(key) {
return nil, appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*")
}
}
return keys, nil
}
type envVarListOutput struct {
Items []map[string]interface{} `json:"items"`
PageToken string `json:"page_token"`
HasMore bool `json:"has_more"`
}
func normalizeEnvVarListOutput(data map[string]interface{}, includeValues bool) envVarListOutput {
src := envVarResponseMap(data)
return envVarListOutput{
Items: normalizeEnvVarItems(envVarItemsRaw(src), includeValues),
PageToken: envVarStringAny(src, "page_token", "next_page_token", "nextPageToken"),
HasMore: envVarBoolAny(src, "has_more", "hasMore"),
}
}
func envVarResponseMap(data map[string]interface{}) map[string]interface{} {
if nested, ok := data["data"].(map[string]interface{}); ok {
return nested
}
return data
}
func envVarItemsRaw(data map[string]interface{}) interface{} {
if raw := data["env_vars"]; raw != nil {
return raw
}
if raw := data["envVars"]; raw != nil {
return raw
}
return data["items"]
}
func normalizeEnvVarItems(raw interface{}, includeValues bool) []map[string]interface{} {
switch typed := raw.(type) {
case []interface{}:
out := make([]map[string]interface{}, 0, len(typed))
for _, item := range typed {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
out = append(out, filterEnvVarItem(m, includeValues))
}
return out
case map[string]interface{}:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]map[string]interface{}, 0, len(keys))
for _, key := range keys {
item := map[string]interface{}{"key": key}
if includeValues {
item["value"] = typed[key]
}
out = append(out, item)
}
return out
default:
return []map[string]interface{}{}
}
}
func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[string]interface{} {
out := make(map[string]interface{}, len(item))
for key, value := range item {
if key == "value" && !includeValues {
continue
}
out[key] = value
}
return out
}
func envVarListSchema(includeValues bool) appsOutputSchema {
columns := []appsOutputColumn{
{Key: "key"},
{Key: "env"},
}
if includeValues {
columns = append(columns, appsOutputColumn{Key: "value"})
}
return appsOutputSchema{Columns: columns, Strict: true}
}
func envVarStringAny(data map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok {
return value
}
}
return ""
}
func envVarStringSliceAny(data map[string]interface{}, keys ...string) []string {
for _, key := range keys {
switch raw := data[key].(type) {
case []string:
return append([]string(nil), raw...)
case []interface{}:
out := make([]string, 0, len(raw))
for _, item := range raw {
if value, ok := item.(string); ok {
out = append(out, value)
}
}
if len(out) > 0 {
return out
}
}
}
return nil
}
func envVarBoolAny(data map[string]interface{}, keys ...string) bool {
for _, key := range keys {
if value, ok := data[key].(bool); ok {
return value
}
}
return false
}

View File

@@ -62,9 +62,8 @@ var AppsEnvPull = common.Shortcut{
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(envPullVarsPath(appID)).
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Pull app startup env vars into the local .env.local file").
Body(envPullVarsBody()).
Set("project_path", projectPath).
Set("env_file", envFile)
},
@@ -81,9 +80,10 @@ var AppsEnvPull = common.Shortcut{
return err
}
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
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, envPullAPIErrorHint(err, appID))
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)
@@ -116,37 +116,6 @@ var AppsEnvPull = common.Shortcut{
},
}
func envPullVarsPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
}
func envPullVarsBody() map[string]interface{} {
return map[string]interface{}{
"env": "dev",
}
}
func envPullAPIErrorHint(err error, appID string) string {
if isEnvPullDevDBNotInitializedError(err) {
appID = strings.TrimSpace(appID)
if appID == "" {
appID = "<app_id>"
}
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
}
return appIDListHint
}
func isEnvPullDevDBNotInitializedError(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
message := strings.ToLower(p.Message)
return strings.Contains(message, "multi-environment database is not initialized") ||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
}
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.
@@ -181,19 +150,13 @@ func checkEnvPullTarget(envFile string) error {
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
raw := data["env_vars"]
if raw == nil {
raw = data["envVars"]
}
if raw == nil {
if nested, ok := data["data"].(map[string]interface{}); ok {
raw = nested["env_vars"]
if raw == nil {
raw = nested["envVars"]
}
}
}
if raw == nil {
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
}
var skippedKeys []string
@@ -240,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
}
return out, info, skippedKeys, nil
default:
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
}
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
@@ -32,11 +31,6 @@ func assertValidationError(t *testing.T, err error, wantSubstr string) {
}
}
func assertEnvPullBody(t *testing.T, req *http.Request) {
t.Helper()
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
}
func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) {
cwd := t.TempDir()
oldwd, err := os.Getwd()
@@ -261,7 +255,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) {
}
}
func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) {
func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
projectDir := t.TempDir()
@@ -278,9 +272,6 @@ func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) {
if !strings.Contains(got, `/open-apis/spark/v1/apps/app_x/env_vars`) {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"env": "dev"`) || strings.Contains(got, `"include_values"`) {
t.Fatalf("dry-run must include only env=dev in the request body: %s", got)
}
if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) {
t.Fatalf("dry-run must include resolved env file path: %s", got)
}
@@ -292,9 +283,6 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -562,68 +550,6 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) {
}
}
func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
RawBody: []byte("[]"),
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout,
)
if err == nil {
t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
}
if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") {
t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint)
}
}
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": -1,
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branchdev",
},
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout,
)
p := requireAppsAPIProblem(t, err)
if p.Code != -1 {
t.Fatalf("code = %d, want -1", p.Code)
}
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
if !strings.Contains(p.Hint, want) {
t.Fatalf("hint missing %q: %q", want, p.Hint)
}
}
if strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
}
}
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()

View File

@@ -1,409 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface{}) {
t.Helper()
if req.URL.RawQuery != "" {
t.Fatalf("query should be empty, got %q", req.URL.RawQuery)
}
var got map[string]interface{}
if err := json.NewDecoder(req.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("body = %#v, want %#v", got, want)
}
}
func expectedEnvVarSceneJSON() float64 {
return float64(defaultAppsEnvVarScene)
}
func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} {
t.Helper()
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if !envelope.OK {
t.Fatalf("expected ok envelope, got %s", stdout)
}
return envelope.Data
}
func requireEnvVarValidationProblem(t *testing.T, err error, param string) {
t.Helper()
p := requireAppsProblem(t, err, errs.CategoryValidation)
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("validation subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validation.Param != param {
t.Fatalf("validation param = %q, want %q", validation.Param, param)
}
}
func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
OnMatch: func(req *http.Request) {
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()})
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"envVars": []interface{}{
map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) {
t.Fatalf("stdout must not expose values by default: %s", got)
}
data := decodeEnvVarEnvelopeData(t, got)
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("items = %#v, want one item", data["items"])
}
item, ok := items[0].(map[string]interface{})
if !ok || item["key"] != "SECRET_TOKEN" {
t.Fatalf("item = %#v, want SECRET_TOKEN", items[0])
}
if _, ok := item["value"]; ok {
t.Fatalf("item must not contain value by default: %#v", item)
}
}
func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
OnMatch: func(req *http.Request) {
assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()})
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"envVars": []interface{}{
map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "super-secret") {
t.Fatalf("stdout should include values when requested: %s", got)
}
}
func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") {
t.Fatalf("expected unknown -e shorthand, got %v", err)
}
}
func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsEnvVarList, []string{
"+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() {
t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String())
}
}
func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"envVars": []interface{}{
map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"},
},
},
},
})
if err := runAppsShortcut(t, AppsEnvVarList, []string{
"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.HasPrefix(got, "key") {
t.Fatalf("pretty output should start with key column, got:\n%s", got)
}
for _, want := range []string{"API_HOST", "online", "https://example.com"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) {
t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got)
}
}
func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout)
p := requireAppsProblem(t, err, errs.CategoryConfirmation)
if p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("confirmation subtype = %q, want %q", p.Subtype, errs.SubtypeConfirmationRequired)
}
if !strings.Contains(p.Hint, "add --yes") {
t.Fatalf("confirmation hint missing --yes guidance: %#v", p)
}
}
func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "super-secret") {
t.Fatalf("dry-run must redact value: %s", got)
}
for _, want := range []string{`"method": "POST"`, `/open-apis/spark/v1/apps/app_x/create_or_update_env_var`} {
if !strings.Contains(got, want) {
t.Fatalf("dry-run missing %q: %s", want, got)
}
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)
}
if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "<redacted>" || dryRun.API[0].Body["key"] != "SECRET_TOKEN" {
t.Fatalf("dry-run body = %#v, want redacted value and key", dryRun.API)
}
}
func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/create_or_update_env_var",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"action": "updated"}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_TOKEN", "--value", "super-secret", "--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["key"] != "SECRET_TOKEN" || sent["env"] != "online" || sent["value"] != "super-secret" {
t.Fatalf("body = %#v, want real online value", sent)
}
got := stdout.String()
if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) {
t.Fatalf("stdout must not echo value: %s", got)
}
for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "updated"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q: %s", want, got)
}
}
}
func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) {
if AppsEnvVarDelete.Risk != "high-risk-write" {
t.Fatalf("risk = %q, want high-risk-write", AppsEnvVarDelete.Risk)
}
}
func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"deleted_keys": []interface{}{"SECRET_ONE", "SECRET_TWO"}}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--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["env"] != "online" {
t.Fatalf("body.env = %v, want online", sent["env"])
}
keys, ok := sent["keys"].([]interface{})
if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" {
t.Fatalf("body.keys = %#v, want SECRET_ONE/SECRET_TWO", sent["keys"])
}
got := stdout.String()
for _, want := range []string{`"env": "online"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q: %s", want, got)
}
}
}
func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
Body: map[string]interface{}{
"code": 400000072,
"msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable",
},
})
err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Code != 400000072 {
t.Fatalf("code = %d, want 400000072", p.Code)
}
if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") {
t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint)
}
if strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint)
}
}
func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
got := stdout.String()
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)
}
if len(dryRun.API) != 1 || dryRun.API[0].Method != "POST" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/delete_env_vars" {
t.Fatalf("dry-run api = %#v", dryRun.API)
}
if dryRun.API[0].Body["env"] != "online" {
t.Fatalf("dry-run body.env = %v, want online", dryRun.API[0].Body["env"])
}
keys, ok := dryRun.API[0].Body["keys"].([]interface{})
if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" {
t.Fatalf("dry-run body.keys = %#v, want SECRET_ONE/SECRET_TWO", dryRun.API[0].Body["keys"])
}
}
func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
requireEnvVarValidationProblem(t, err, "--environment")
}
func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarList,
[]string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") {
t.Fatalf("expected old --env to be rejected, got %v", err)
}
}
func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarSet,
[]string{"+env-set", "--app-id", "app_x", "--key", "bad-key",
"--value", "super-secret", "--as", "user"}, factory, stdout)
requireEnvVarValidationProblem(t, err, "--key")
}
func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsEnvVarDelete,
[]string{"+env-delete", "--app-id", "app_x", "--key", "bad-key",
"--yes", "--as", "user"}, factory, stdout)
requireEnvVarValidationProblem(t, err, "--key")
}

View File

@@ -14,9 +14,6 @@ func TestAppsShortcutsHaveExamples(t *testing.T) {
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
for _, s := range Shortcuts() {
if s.Hidden {
continue
}
hasExample := false
for _, tip := range s.Tips {
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
@@ -53,62 +50,3 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
}
}
}
func TestAppsEnvTipsCoverConfirmations(t *testing.T) {
envSet := requireShortcutForExamples(t, "+env-set")
if !tipsContainAll(envSet.Tips, "--environment online", "--yes") {
t.Fatalf("+env-set tips must include an online write example with --environment online --yes: %#v", envSet.Tips)
}
envDelete := requireShortcutForExamples(t, "+env-delete")
if !tipsContainAll(envDelete.Tips, "--yes") {
t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips)
}
}
func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) {
for _, cmd := range []string{
"+log-list",
"+log-get",
"+trace-list",
"+trace-get",
"+metric-list",
"+analytics-list",
} {
shortcut := requireShortcutForExamples(t, cmd)
if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") {
t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips)
}
}
}
func requireShortcutForExamples(t *testing.T, command string) shortcutForExamples {
t.Helper()
for _, sc := range Shortcuts() {
if sc.Command == command {
return shortcutForExamples{Tips: sc.Tips}
}
}
t.Fatalf("missing shortcut %s", command)
return shortcutForExamples{}
}
type shortcutForExamples struct {
Tips []string
}
func tipsContainAll(tips []string, needles ...string) bool {
for _, tip := range tips {
ok := true
for _, needle := range needles {
if !strings.Contains(tip, needle) {
ok = false
break
}
}
if ok {
return true
}
}
return false
}

View File

@@ -1,148 +0,0 @@
// 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"
)
// AppsFileDelete batch-deletes files by remote pathhigh-risk-write框架自动注入 --yes 确认)。
//
// POST /apps/{app_id}/storage/file_batch_removebody {paths:[...]}。网关把该路由注册为 POST
// DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200。后端 results[] 与请求 paths
// 顺序一一对应:成功项带 file失败项带 error_codeCLI 据下标回填 path
// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error不翻成非 0 退出码lark-cli 信封语义)。
var AppsFileDelete = common.Shortcut{
Service: appsService,
Command: "+file-delete",
Description: "Delete one or more files by remote path (batch)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +file-delete --app-id <app_id> --path /1858537546760216.png --yes",
"Repeat --path for batch delete.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if len(cleanDeletePaths(rctx)) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required (at least one remote path)").WithParam("--path")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appFileBatchRemovePath(appID)).
Desc("Batch delete Miaoda app files").
Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
paths := cleanDeletePaths(rctx)
data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths})
if err != nil {
return err
}
results := projectDeleteResults(data["results"], paths)
out := map[string]interface{}{"results": results}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderFileDeletePretty(w, results)
})
return nil
},
}
// cleanDeletePaths 取 --path 切片trim 去空。
func cleanDeletePaths(rctx *common.RuntimeContext) []string {
out := make([]string, 0)
for _, p := range rctx.StrSlice("path") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// projectDeleteResults 把后端 results[] 按下标 zip 回请求 paths回填 path
// 失败项把 error_code 包成 {code,message} 便于消费。
func projectDeleteResults(raw interface{}, inputs []string) []map[string]interface{} {
arr, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(inputs))
for i, input := range inputs {
var r map[string]interface{}
if i < len(arr) {
r, _ = arr[i].(map[string]interface{})
}
status := "ok"
if r != nil && common.GetString(r, "status") != "" {
status = common.GetString(r, "status")
}
item := map[string]interface{}{"status": status, "path": input}
if status == "ok" {
if r != nil {
if f, ok := r["file"].(map[string]interface{}); ok {
item["file_name"] = common.GetString(f, "file_name")
}
}
} else {
code := ""
if r != nil {
code = common.GetString(r, "error_code")
}
if code == "" {
code = "DELETE_FAILED"
}
item["error"] = map[string]interface{}{
"code": code,
"message": deleteErrorMessage(code, input),
}
}
out = append(out, item)
}
return out
}
// deleteErrorMessage 据 error_code 生成删除失败文案FILE_NOT_FOUND 提示文件不存在,其余统一删除失败。
func deleteErrorMessage(code, path string) string {
if code == "FILE_NOT_FOUND" {
return fmt.Sprintf("File '%s' does not exist", path)
}
return fmt.Sprintf("Failed to delete '%s'", path)
}
// renderFileDeletePretty 逐项打 ✓ / ✗,末行汇总 deleted 计数。
func renderFileDeletePretty(w io.Writer, results []map[string]interface{}) {
okCount := 0
for _, r := range results {
path := common.GetString(r, "path")
if common.GetString(r, "status") == "ok" {
fmt.Fprintf(w, "✓ %s\n", path)
okCount++
continue
}
code := ""
if e, ok := r["error"].(map[string]interface{}); ok {
code = common.GetString(e, "code")
}
fmt.Fprintf(w, "✗ %s (%s)\n", path, code)
}
fmt.Fprintf(w, "\n%d/%d deleted\n", okCount, len(results))
}

View File

@@ -1,132 +0,0 @@
// 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 fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove"
// TestAppsFileDelete_RequiresAppIDAndPath 验证仅含空白的 --path 去空后为空时Validate 报 --path typed 校验错误。
func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// 传入仅含空白的 --path满足 cobra 的 Required 检查,但 cleanDeletePaths 去空后为空,
// 触发 Validate 内的 typed --path 校验。
err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", " ", "--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 != "--path" {
t.Fatalf("Param = %q, want --path", ve.Param)
}
}
// high-risk-write无 --yes → confirmation_requiredexit 10
func TestAppsFileDelete_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required, got %v", err)
}
}
// TestAppsFileDelete_DryRunSendsPaths 验证 dry-run 输出 POST file_batch_removebody.paths 按序携带多个 --path。
func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--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 != fileDeleteURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
paths, _ := a.Body["paths"].([]interface{})
if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" {
t.Fatalf("body.paths = %v", a.Body["paths"])
}
}
// 部分失败仍 ok:trueresults 按下标 zip 回 path失败项带 error{code,message}。
func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileDeleteURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"results": []interface{}{
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}},
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
},
}},
})
err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err)
}
got := stdout.String()
var env struct {
Data struct {
Results []map[string]interface{} `json:"results"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(got), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, got)
}
if len(env.Data.Results) != 2 {
t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got)
}
r0, r1 := env.Data.Results[0], env.Data.Results[1]
if r0["status"] != "ok" || r0["path"] != "/a.png" {
t.Errorf("result[0] = %v", r0)
}
if r1["status"] != "error" || r1["path"] != "/missing.png" {
t.Errorf("result[1] = %v (path must be back-filled by index)", r1)
}
if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" {
t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"])
}
}
// TestAppsFileDelete_PrettySummary 验证 pretty 输出逐项 ✓/✗ 标记并汇总 "1/2 deleted"。
func TestAppsFileDelete_PrettySummary(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileDeleteURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"results": []interface{}{
map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}},
map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"},
},
}},
})
if err := runAppsShortcut(t, AppsFileDelete,
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
}

View File

@@ -1,125 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"net/http"
"path"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileDownload downloads a file to a local path via a signed URL。
//
// 两步POST /apps/{app_id}/storage/file_sign 拿 signed_urlpresigned直连对象存储
// 再客户端 GET signed_url 落盘到 --output默认远端 basename。不单设 download 接口。
var AppsFileDownload = common.Shortcut{
Service: appsService,
Command: "+file-download",
Description: "Download a file to a local path (via a signed URL)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png --output ./logo.png",
"Example (omit --output): lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png # saves to ./1858537546760216.png",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Desc: "remote file path", Required: true},
{Name: "output", Desc: "local output path (default: remote file basename in cwd)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
return err
}
_, err := requireFilePath(rctx.Str("path"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
remotePath, _ := requireFilePath(rctx.Str("path"))
return common.NewDryRunAPI().
POST(appFileSignPath(appID)).
Desc("Sign a download URL, then GET it to --output").
Body(map[string]interface{}{"path": remotePath})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
remotePath, err := requireFilePath(rctx.Str("path"))
if err != nil {
return err
}
// 1. 签名拿 presigned signed_url。
signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath})
if err != nil {
return err
}
signedURL := common.GetString(signData, "signed_url")
if signedURL == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url")
}
// 2. 直连 GET signed_url 落盘。
out := strings.TrimSpace(rctx.Str("output"))
if out == "" {
out = path.Base(strings.TrimPrefix(remotePath, "/"))
if out == "" || out == "." || out == "/" {
out = "download"
}
}
req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call).
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err)
}
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply.
if err != nil {
// dial/transport 失败是典型可重试场景。
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
// 5xx 是上游瞬时故障,标 retryable4xx如签名过期需重新签名而非盲重试不标。
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable()
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
}
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err)
}
resolved, perr := rctx.FileIO().ResolvePath(out)
if perr != nil || resolved == "" {
resolved = out
}
result := map[string]interface{}{
"path": remotePath,
"output": resolved,
"size_bytes": saved.Size(),
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size()))
})
return nil
},
}

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const fileSignURLForDownload = "/open-apis/spark/v1/apps/app_x/storage/file_sign"
// TestAppsFileDownload_RequiresAppIDAndPath 验证仅含空白的 --path 触发 --path typed 校验错误。
func TestAppsFileDownload_RequiresAppIDAndPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", " ", "--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 != "--path" {
t.Fatalf("Param = %q, want --path", ve.Param)
}
}
// TestAppsFileDownload_DryRunSignsFirst 验证 dry-run 第一步是 POST file_sign。
func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--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"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)
}
}
// sign → 客户端 GET presigned signed_url → 落盘 --output。
func TestAppsFileDownload_EndToEnd(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "image/png")
io.WriteString(w, "PNGDATA")
}))
defer srv.Close()
dir := t.TempDir()
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileSignURLForDownload,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}},
})
if err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--output", "out.png", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
b, err := os.ReadFile(filepath.Join(dir, "out.png"))
if err != nil {
t.Fatalf("read output file: %v", err)
}
if string(b) != "PNGDATA" {
t.Fatalf("downloaded content = %q, want PNGDATA", b)
}
if !strings.Contains(stdout.String(), `"size_bytes": 7`) {
t.Errorf("output json missing size_bytes:7\n%s", stdout.String())
}
}
// 不传 --output → 默认远端 basename。
func TestAppsFileDownload_DefaultsOutputToBasename(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "DATA")
}))
defer srv.Close()
dir := t.TempDir()
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileSignURLForDownload,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}},
})
if err := runAppsShortcut(t, AppsFileDownload,
[]string{"+file-download", "--app-id", "app_x", "--path", "/1858537546760216.png", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if _, err := os.Stat(filepath.Join(dir, "1858537546760216.png")); err != nil {
t.Fatalf("default output basename not written: %v", err)
}
}

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileGet gets one file's metadata by exact remote path动词对齐 +file-list
//
// GET /apps/{app_id}/storage/file?path=<path>。file 仅按 path 精确寻址,无按名寻址。
// pretty 渲染 key/valuefile_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at /
// download_url条件出现。server created_at/created_by → uploaded_at/uploaded_by。
var AppsFileGet = common.Shortcut{
Service: appsService,
Command: "+file-get",
Description: "Get a single file's metadata by path",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-get --app-id <app_id> --path /1858537546760216.png",
"Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Desc: "remote file path", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, err := requireFilePath(rctx.Str("path"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appFileGetPath(appID)).
Desc("Get Miaoda app file metadata").
Params(buildFileGetParams(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", appFileGetPath(appID), buildFileGetParams(rctx), nil)
if err != nil {
return err
}
info := projectFileInfo(data)
rctx.OutFormat(info, nil, func(w io.Writer) {
renderFileGetPretty(w, info)
})
return nil
},
}
// buildFileGetParams 组装 file_get 查询参数:按 path 精确寻址单文件。
func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} {
path, _ := requireFilePath(rctx.Str("path"))
return map[string]interface{}{"path": path}
}
// renderFileGetPretty 输出对齐 key/valueuploaded_by 只展示 nameid 仅 json 保留)。
func renderFileGetPretty(w io.Writer, info fileInfo) {
pairs := [][2]string{
{"file_name", dashIfEmpty(info.FileName)},
{"path", info.Path},
{"size", fileSizeDetail(info.SizeBytes)},
{"type", dashIfEmpty(info.Type)},
}
if info.UploadedBy != nil {
pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name})
}
pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)})
if info.DownloadURL != "" {
pairs = append(pairs, [2]string{"download_url", info.DownloadURL})
}
renderKeyValuePairs(w, pairs)
}

View File

@@ -1,89 +0,0 @@
// 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 fileGetURL = "/open-apis/spark/v1/apps/app_x/storage/file"
// TestAppsFileGet_RequiresAppIDAndPath 验证空白 --app-id 与空白 --path 分别触发对应的 typed 校验错误。
func TestAppsFileGet_RequiresAppIDAndPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", " ", "--path", "/x.png", "--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)
}
factory2, stdout2, _ := newAppsExecuteFactory(t)
err2 := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory2, stdout2)
var ve2 *errs.ValidationError
if !errors.As(err2, &ve2) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err2, err2)
}
if ve2.Param != "--path" {
t.Fatalf("Param = %q, want --path", ve2.Param)
}
}
// TestAppsFileGet_DryRunSendsPathQuery 验证 dry-run 输出 GET filepath 作为 query 参数下发。
func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--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)
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)
}
}
// TestAppsFileGet_SuccessAndPrettyKeyValue 验证 pretty key/value 展示 size 含 bytes、uploaded_by 只显示 name 且不泄漏 user id。
func TestAppsFileGet_SuccessAndPrettyKeyValue(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileGetURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "logo.png", "path": "/1858537546760216.png",
"size_bytes": 24580, "type": "image/png",
"created_at": "2026-04-15T10:30:00Z",
"created_by": `{"id":"7311","name":"alice"}`,
}},
})
if err := runAppsShortcut(t, AppsFileGet,
[]string{"+file-get", "--app-id", "app_x", "--path", "/1858537546760216.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// pretty key/valuesize 含 bytes、uploaded_by 只展示 name。
for _, want := range []string{"file_name:", "24 KB (24580 bytes)", "uploaded_by: alice", "uploaded_at: 2026-04-15T10:30:00Z"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
// pretty 不该泄漏 user id。
if strings.Contains(got, "7311") {
t.Errorf("pretty should show name only, not id:\n%s", got)
}
}

View File

@@ -1,145 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
// server 字段 created_at → 产品语义 uploaded_at。
var AppsFileList = common.Shortcut{
Service: appsService,
Command: "+file-list",
Description: "List files in a Miaoda app's storage (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-list --app-id <app_id>",
"Tip: filter fields with --jq, e.g. -q '.data.items[].path'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "filter by exact file name"},
{Name: "path", Desc: "filter by exact remote path"},
{Name: "type", Desc: "filter by MIME type"},
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{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
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {
continue
}
n, err := normalizeTimestamp(rctx.Str(f))
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
}
_ = rctx.Cmd.Flags().Set(f, n)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appFileListPath(appID)).
Desc("List Miaoda app files").
Params(buildFileListParams(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", appFileListPath(appID), buildFileListParams(rctx), nil)
if err != nil {
return err
}
// 白名单投影server created_at/created_by → uploaded_at/uploaded_by替换原始 items[]。
items := projectFileItems(data["items"])
data["items"] = items
rctx.OutFormat(data, nil, func(w io.Writer) {
renderFileListPretty(w, items)
})
return nil
},
}
// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfocreated_*→uploaded_*)。
func projectFileItems(raw interface{}) []fileInfo {
arr, _ := raw.([]interface{})
out := make([]fileInfo, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
out = append(out, projectFileInfo(m))
}
}
return out
}
// buildFileListParams 组装 file_list 查询参数page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。
func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v
}
}
addStr("name", "name")
addStr("path", "path")
addStr("type", "type")
addStr("uploaded-since", "uploaded_since")
addStr("uploaded-until", "uploaded_until")
addStr("page-token", "page_token")
if v := rctx.Int("size-gt"); v > 0 {
params["size_gt"] = v
}
if v := rctx.Int("size-lt"); v > 0 {
params["size_lt"] = v
}
return params
}
// renderFileListPretty 5 列对齐表file_name / path / size / type / uploaded_at。
func renderFileListPretty(w io.Writer, items []fileInfo) {
if len(items) == 0 {
io.WriteString(w, "No files found.\n")
return
}
headers := []string{"file_name", "path", "size", "type", "uploaded_at"}
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{
dashIfEmpty(it.FileName),
it.Path,
humanBytes(it.SizeBytes),
dashIfEmpty(it.Type),
dashIfEmpty(it.UploadedAt),
})
}
renderAlignedTable(w, headers, rows)
}

View File

@@ -1,252 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// 设计原则三:<timestamp> 四种格式 → 统一 RFC3339 UTC。
func TestNormalizeTimestamp_AllFormats(t *testing.T) {
// 空串透传
if got, err := normalizeTimestamp(" "); err != nil || got != "" {
t.Fatalf("empty → %q,%v want \"\",nil", got, err)
}
// ISO 8601 带 TZZ 原样、显式偏移换算到 UTC
mustEq := func(in, want string) {
got, err := normalizeTimestamp(in)
if err != nil || got != want {
t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want)
}
}
mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z")
mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h
// date / local datetime按本地时区解释再转 UTC与 time.ParseInLocation 对齐)
dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local)
mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339))
ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local)
mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339))
// 相对:从现在往前推,结果应 ≈ now-dur5s 容差)
for _, c := range []struct {
in string
dur time.Duration
}{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} {
got, err := normalizeTimestamp(c.in)
if err != nil {
t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err)
continue
}
ts, perr := time.Parse(time.RFC3339, got)
if perr != nil {
t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got)
continue
}
want := time.Now().Add(-c.dur)
if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second {
t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur)
}
}
// 非法格式 → error
for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} {
if _, err := normalizeTimestamp(bad); err == nil {
t.Errorf("normalizeTimestamp(%q) expected error", bad)
}
}
}
const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list"
// TestAppsFileList_RequiresAppID 验证空白 --app-id 触发 --app-id typed 校验错误。
func TestAppsFileList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-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)
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x",
"--name", "logo.png", "--path", "/x.png", "--type", "image/png",
"--size-gt", "100", "--size-lt", "9000",
"--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01",
"--page-size", "5", "--page-token", "cur-1",
"--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())
}
a := env.API[0]
if a.Method != "GET" || a.URL != fileListURL {
t.Fatalf("method/url = %s %s", a.Method, a.URL)
}
// 设计原则三date 入参会被归一化为 RFC3339 UTC期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。
sinceN, _ := normalizeTimestamp("2026-01-01")
untilN, _ := normalizeTimestamp("2026-02-01")
wantStr := map[string]string{
"name": "logo.png", "path": "/x.png", "type": "image/png",
"uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1",
}
for k, v := range wantStr {
if a.Params[k] != v {
t.Errorf("params.%s = %v, want %v", k, a.Params[k], v)
}
}
// 且确实归一化成了 UTC以 Z 结尾),不是原样透传。
if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") {
t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"])
}
for _, k := range []string{"size_gt", "size_lt", "page_size"} {
if _, ok := a.Params[k]; !ok {
t.Errorf("params missing %s: %v", k, a.Params)
}
}
}
// 0 值过滤器不下发size-gt/lt 缺省 0、空字符串过滤器
func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-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"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
if _, ok := env.API[0].Params[banned]; ok {
t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params)
}
}
if _, ok := env.API[0].Params["page_size"]; !ok {
t.Errorf("params should always carry page_size: %v", env.API[0].Params)
}
}
// created_at/created_by → uploaded_at/uploaded_bycreated_by 是 JSON 字符串 → parse 成对象。
func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: fileListURL,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"file_name": "logo.png",
"path": "/1858537546760216.png",
"size_bytes": 24580,
"type": "image/png",
"created_at": "2026-04-15T10:30:00Z",
"created_by": `{"id":"7311","name":"alice"}`,
"download_url": "/spark/app/x/1858537546760216.png",
},
},
},
},
})
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} {
if !strings.Contains(got, want) {
t.Errorf("stdout missing %q:\n%s", want, got)
}
}
// created_* 不应再出现在输出。
for _, banned := range []string{"created_at", "created_by"} {
if strings.Contains(got, banned) {
t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got)
}
}
}
// TestAppsFileList_PrettyTableAndEmpty 验证 pretty 非空时渲染表头与人类可读 size空结果时输出 "No files found."。
func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) {
// 非空5 列表头。
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{
"file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png",
"created_at": "2026-04-15T10:30:00Z",
}},
}},
})
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--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, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") {
t.Fatalf("pretty table malformed:\n%s", got)
}
// 空No files found.
factory2, stdout2, reg2 := newAppsExecuteFactory(t)
reg2.Register(&httpmock.Stub{
Method: "GET", URL: fileListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout2.String(), "No files found.") {
t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String())
}
}
// TestParseFileUser_Cases 验证 parseFileUser合法 JSON 解析成对象,空串/非法/全空字段均返回 nil。
func TestParseFileUser_Cases(t *testing.T) {
if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" {
t.Fatalf("valid parse failed: %#v", u)
}
if u := parseFileUser(""); u != nil {
t.Errorf("empty → nil, got %#v", u)
}
if u := parseFileUser("not json"); u != nil {
t.Errorf("invalid → nil, got %#v", u)
}
if u := parseFileUser(`{"id":"","name":""}`); u != nil {
t.Errorf("all-empty → nil, got %#v", u)
}
}

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsFileQuotaGet reports an app's file-storage usage动词对齐 +db-quota-get
//
// GET /apps/{app_id}/storage/file_quota。storage_quota_bytes / usage_percent 在配额未对接(=0
// 不输出json 删字段、pretty 只打已用量)。
var AppsFileQuotaGet = common.Shortcut{
Service: appsService,
Command: "+file-quota-get",
Description: "Get an app's file-storage usage",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-quota-get --app-id <app_id>",
"Tip: get just the usage percent with -q '.usage_percent'",
},
Scopes: []string{"spark:app:read"},
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().
GET(appFileQuotaPath(appID)).
Desc("Get Miaoda app file-storage usage")
},
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", appFileQuotaPath(appID), nil, nil)
if err != nil {
return err
}
out := projectFileQuota(data)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderFileQuotaPretty(w, out)
})
return nil
},
}
// projectFileQuota 白名单投影 file quota 字段:只保留 agent 需要的 storage_used_bytes / files
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段避免无用字段消耗上下文。
func projectFileQuota(data map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
if v, ok := data["files"]; ok {
out["files"] = 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
}
// renderFileQuotaPretty 打 Storage已用 / 配额 (百分比))与 Files 行(标签对齐 miaoda-cli
func renderFileQuotaPretty(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["files"]); ok {
pairs = append(pairs, [2]string{"Files", fmt.Sprintf("%d", int64(f))})
}
renderKeyValuePairs(w, pairs)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
const fileQuotaURL = "/open-apis/spark/v1/apps/app_x/storage/file_quota"
// TestAppsFileQuotaGet_QuotaConnectedShowsAllFields 验证配额已对接时输出 storage_quota_bytes/usage_percent/files 全字段。
func TestAppsFileQuotaGet_QuotaConnectedShowsAllFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileQuotaURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"storage_used_bytes": 157286400,
"storage_quota_bytes": 1073741824,
"usage_percent": 14.6,
"files": 42,
}},
})
if err := runAppsShortcut(t, AppsFileQuotaGet,
[]string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"storage_quota_bytes"`, `"usage_percent"`, `"files"`} {
if !strings.Contains(got, want) {
t.Errorf("quota json missing %q:\n%s", want, got)
}
}
}
// 配额未对接(=0storage_quota_bytes / usage_percent 不输出。
func TestAppsFileQuotaGet_UnconnectedOmitsQuotaFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: fileQuotaURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"storage_used_bytes": 157286400,
"storage_quota_bytes": 0,
"usage_percent": 0,
"files": 42,
}},
})
if err := runAppsShortcut(t, AppsFileQuotaGet,
[]string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, banned := range []string{"storage_quota_bytes", "usage_percent"} {
if strings.Contains(got, banned) {
t.Errorf("unconnected quota should omit %q:\n%s", banned, got)
}
}
if !strings.Contains(got, `"storage_used_bytes"`) || !strings.Contains(got, `"files"`) {
t.Errorf("should still show used/files:\n%s", got)
}
}
// TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields 验证 projectFileQuota 白名单投影:
// quota=0 时不输出 storage_quota_bytes/usage_percent非零时保留后端额外字段不透传。
func TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields(t *testing.T) {
out := projectFileQuota(map[string]interface{}{
"storage_used_bytes": 100, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
"files": 3, "tenant_key": "leak", "request_id": "rid",
})
if _, ok := out["storage_quota_bytes"]; ok {
t.Errorf("zero quota should be omitted: %v", out)
}
if _, ok := out["usage_percent"]; ok {
t.Errorf("usage_percent should be omitted when quota=0: %v", out)
}
if out["storage_used_bytes"] != 100 || out["files"] != 3 {
t.Errorf("whitelisted fields should be kept: %v", out)
}
// 白名单外的字段必须被丢弃,避免无用字段消耗 agent 上下文。
for _, leaked := range []string{"tenant_key", "request_id"} {
if _, ok := out[leaked]; ok {
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
}
}
out2 := projectFileQuota(map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(1024), "usage_percent": float64(9.8), "files": 3})
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)
}
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// fileSignMaxExpiresSeconds 是签名链接最长有效期30 天)。超出 → 校验失败。
const fileSignMaxExpiresSeconds = 30 * 24 * 60 * 60
// AppsFileSign generates a temporary signed download URL for a file。
//
// POST /apps/{app_id}/storage/file_signbody {path, expires_in}。
// pretty 模式只打 signed_url便于直接管道 / curljson 返 {file_name,path,signed_url,expires_at}。
var AppsFileSign = common.Shortcut{
Service: appsService,
Command: "+file-sign",
Description: "Generate a temporary signed download URL for a file",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +file-sign --app-id <app_id> --path /1858537546760216.png",
"Tip: curl the signed_url directly to download.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "path", Desc: "remote file path", Required: true},
{Name: "expires-in", Type: "int", Default: "86400", Desc: "link validity in seconds (max 2592000 = 30d)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if _, err := requireFilePath(rctx.Str("path")); err != nil {
return err
}
if rctx.Int("expires-in") > fileSignMaxExpiresSeconds {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expires-in exceeds the maximum of %d seconds (30d)", fileSignMaxExpiresSeconds).WithParam("--expires-in")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appFileSignPath(appID)).
Desc("Sign a temporary download URL").
Body(buildFileSignBody(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", appFileSignPath(appID), nil, buildFileSignBody(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintln(w, common.GetString(data, "signed_url"))
})
return nil
},
}
// buildFileSignBody 组装 file_sign 请求体path 及可选 expires_in
func buildFileSignBody(rctx *common.RuntimeContext) map[string]interface{} {
path, _ := requireFilePath(rctx.Str("path"))
body := map[string]interface{}{"path": path}
if v := rctx.Int("expires-in"); v > 0 {
body["expires_in"] = v
}
return body
}

View File

@@ -1,74 +0,0 @@
// 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 fileSignURL = "/open-apis/spark/v1/apps/app_x/storage/file_sign"
// TestAppsFileSign_DryRunBody 验证 dry-run 输出 POST file_signbody 携带 path 与 expires_in。
func TestAppsFileSign_DryRunBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileSign,
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--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 != fileSignURL || a.Body["path"] != "/x.png" {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
if ei, _ := a.Body["expires_in"].(float64); int(ei) != 3600 {
t.Fatalf("body.expires_in = %v, want 3600", a.Body["expires_in"])
}
}
// TestAppsFileSign_RejectsDurationOverMax 验证 --expires-in 超过上限时触发 --expires-in typed 校验错误。
func TestAppsFileSign_RejectsDurationOverMax(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileSign,
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "9999999", "--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 != "--expires-in" {
t.Fatalf("Param = %q, want --expires-in", ve.Param)
}
}
// TestAppsFileSign_PrettyPrintsSignedURL 验证 pretty 只输出 signed_url 本身。
func TestAppsFileSign_PrettyPrintsSignedURL(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: fileSignURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "x.png", "path": "/x.png",
"signed_url": "https://tos.example/x.png?sig=abc", "expires_at": "2026-04-16T10:30:00Z",
}},
})
if err := runAppsShortcut(t, AppsFileSign,
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := strings.TrimSpace(stdout.String())
if got != "https://tos.example/x.png?sig=abc" {
t.Fatalf("pretty should print only signed_url, got: %q", got)
}
}

View File

@@ -1,218 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
// fileUploadMaxBytes 是单文件上传上限100 MB对齐 miaoda
const fileUploadMaxBytes = 100 * 1024 * 1024
// AppsFileUpload uploads a local file to an app's storage三步直传
//
// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id}
// 2. 客户端 PUT 文件字节到 presigned upload_url取响应 ETag
// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据
// file_name 取本地 basenamepath 由平台生成 16 位 ID不可指定。仅收 --file。
var AppsFileUpload = common.Shortcut{
Service: appsService,
Command: "+file-upload",
Description: "Upload a local file to an app's storage",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./logo.png",
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./report.pdf -q '.path' # print the platform-generated file path",
},
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 file to upload (file_name = basename)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appFilePreUploadPath(appID)).
Desc("Pre-upload → client PUT bytes → callback (3-step)").
Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)
// 1. pre-upload
pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{
"file_name": fileName,
"file_size": len(content),
"content_type": contentType,
})
if err != nil {
return err
}
uploadURL := common.GetString(pre, "upload_url")
uploadID := common.GetString(pre, "upload_id")
if uploadURL == "" || uploadID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id")
}
// 2. PUT 文件字节到 presigned URL取 ETag带 Content-Disposition 透传原始文件名)
etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName)
if err != nil {
return err
}
// 3. callback
result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{
"upload_id": uploadID,
"etag": etag,
})
if err != nil {
return err
}
info := projectFileInfo(result)
rctx.OutFormat(info, nil, func(w io.Writer) {
renderFileUploadPretty(w, fileName, info)
})
return nil
},
}
// putFileBytes 直连 PUT 文件字节到 presigned URL返回响应的 ETag。
//
// Content-Disposition 透传原始文件名TOS 把它存成对象 metadatacallback 阶段后端
// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key
// (平台 16 位 ID当文件名 —— 即「上传后文件名变成 ID」的根因。
//
//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL.
func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content))
if err != nil {
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err)
}
req.ContentLength = int64(len(content))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// 用 mime.FormatMediaType 规范生成 Content-Disposition自动按 RFC 2045 处理引号/转义),
// 不手工拼接 header杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName
// 做 encodeURIComponent控制字符/分隔符均 %XX 化),此处是第二道防线。
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)})
if disposition == "" {
disposition = "attachment"
}
req.Header.Set("Content-Disposition", disposition)
resp, err := newFileTransferClient().Do(req)
if err != nil {
// dial/transport 失败是典型可重试场景。
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable()
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
// 5xx 是上游瞬时故障,标 retryable4xx如签名过期需重新签名而非盲重试不标。
if resp.StatusCode >= 500 {
return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable()
}
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode)
}
return resp.Header.Get("ETag"), nil
}
// sanitizeUploadFileName 对齐 miaoda先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent
// UTF-8 百分号编码,兼容中文等非 ASCII且让 Content-Disposition header 合法),空则兜底 download_file。
func sanitizeUploadFileName(name string) string {
var b strings.Builder
for _, r := range name {
switch r {
case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';':
continue
default:
b.WriteRune(r)
}
}
enc := encodeURIComponent(b.String())
if enc == "" {
return "download_file"
}
// 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh——下载落地可能覆盖本地隐藏文件
// 前置下划线消除隐藏文件语义。
if strings.HasPrefix(enc, ".") {
enc = "_" + enc
}
return enc
}
// encodeURIComponent 复刻 JS encodeURIComponent除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。
func encodeURIComponent(s string) string {
const keep = "-_.!~*'()"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 {
b.WriteByte(c)
} else {
b.WriteString(fmt.Sprintf("%%%02X", c))
}
}
return b.String()
}
// mimeByExt 按扩展名推断 Content-Type未知回退 application/octet-stream。
func mimeByExt(name string) string {
if t := mime.TypeByExtension(filepath.Ext(name)); t != "" {
return t
}
return "application/octet-stream"
}
// renderFileUploadPretty 打 ✓ Uploaded <local> → <path> + size / download_url。
func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) {
fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path)
fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes))
if info.DownloadURL != "" {
fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL)
}
}

View File

@@ -1,182 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。
func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// --file is a cobra-required flag; pass whitespace so cobra's required check
// passes and our Validate (which trims) rejects it with a typed error.
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", " ", "--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 != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。
func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--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 != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--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 != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["file_name"] != "logo.png" {
t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"])
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
var putContentType, putCD string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
putContentType = r.Header.Get("Content-Type")
putCD = r.Header.Get("Content-Disposition")
w.Header().Set("ETag", `"etag-123"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png",
"download_url": "/spark/app/x/1858537546760216.png",
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if string(putBody) != "PNGBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
if putContentType != "image/png" {
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
}
// 原始文件名必须经 Content-Disposition 透传给 TOS否则后端用 storage key 当文件名)。
// 断言按解析结果format-agnosticmime.FormatMediaType 对无 tspecial 的名不加引号,
// 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。
if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" {
t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err)
}
got := stdout.String()
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
t.Errorf("output missing uploaded path:\n%s", got)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{
{"logo.png", "logo.png"},
{"a b.png", "a%20b.png"}, // 空格 → %20encodeURIComponent
{`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符
{"///", "download_file"}, // 全非法 → 兜底
{"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码
}
for _, c := range cases {
if got := sanitizeUploadFileName(c.in); got != c.want {
t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want)
}
}
}
// TestMimeByExt_Cases 验证 mimeByExt按扩展名识别 image/png未知扩展名兜底 application/octet-stream。
func TestMimeByExt_Cases(t *testing.T) {
if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") {
t.Errorf("mimeByExt(a.png)=%q want image/png", got)
}
if got := mimeByExt("data.unknownext"); got != "application/octet-stream" {
t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got)
}
}

View File

@@ -80,7 +80,7 @@ func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) {
func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}},
"+db-table-list")
@@ -96,7 +96,7 @@ func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) {
func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"},
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}},
"+db-env-create")

View File

@@ -21,9 +21,6 @@ func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) {
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Status: http.StatusForbidden,
Body: map[string]interface{}{"msg": "permission denied"},
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,

View File

@@ -1,877 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
defaultAppsLogEnv = "online"
logSearchEndpoint = "search_logs"
resolveStackEndpoint = "resolve_stack_trace"
sourceStackStatusOK = "resolved"
sourceStackStatusError = "unresolved"
sourceStackMaxScanDepth = 8
sourceStackMaxFrames = 2000
defaultSourceMapPrefix = "client/assets/"
)
var (
jsStackFrameParenRe = regexp.MustCompile(`^\s*(?:at\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)\s*$`)
jsStackFrameBareRe = regexp.MustCompile(`^\s*(?:at\s+)?(.+):(\d+):(\d+)\s*$`)
)
// AppsLogList searches online app logs with observability filters.
var AppsLogList = common.Shortcut{
Service: appsService,
Command: "+log-list",
Description: "Search online app logs with observability filters",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +log-list --app-id <app_id> --level error --keyword timeout --since 1h",
"Tip: use --page-token from the response to fetch the next page.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"},
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"},
{Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"},
{Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"},
{Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"},
{Name: "keyword", Desc: "keyword filter applied by the log search backend"},
{Name: "module", Desc: "module name filter"},
{Name: "user-id", Desc: "end user ID filter"},
{Name: "page", Desc: "frontend page or route filter"},
{Name: "api", Desc: "API path/name filter"},
{Name: "min-duration", Type: "int", Desc: "minimum duration in milliseconds; must be non-negative"},
{Name: "max-duration", Type: "int", Desc: "maximum duration in milliseconds; must be non-negative and >= --min-duration"},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"},
{Name: "page-token", Desc: "pagination cursor from a previous log search response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, err := buildLogSearchBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _ := buildLogSearchBody(rctx)
return common.NewDryRunAPI().
POST(logSearchPath(rctx.Str("app-id"))).
Desc("Search online app logs").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
body, err := buildLogSearchBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := normalizeLogSearchResponse(data)
rctx.OutFormat(out, nil, func(w io.Writer) {
appsPrintSchemaTable(w, appsProjectRows(logListRows(out.Items), logSummarySchema), logSummarySchema)
})
return nil
},
}
// AppsLogGet fetches one log by log ID through the search_logs endpoint.
var AppsLogGet = common.Shortcut{
Service: appsService,
Command: "+log-get",
Description: "Get one online app log by log ID",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +log-get --app-id <app_id> --log-id <log_id>",
"Tip: +log-get searches online logs with limit=1; use +log-list first if the log ID is unknown.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true},
{Name: "log-id", Desc: "log ID to fetch", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"},
},
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("log-id")) == "" {
return appsValidationParamError("--log-id", "--log-id is required")
}
return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag))
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(logSearchPath(rctx.Str("app-id"))).
Desc("Search online app logs by log ID").
Body(buildLogGetSearchBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
data, err := callLogGetSearch(rctx, appID, buildLogGetSearchBody(rctx))
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := normalizeLogSearchResponse(data)
if len(out.Items) == 0 {
return appsFailedPreconditionParamError("--log-id", "log not found").
WithHint("verify --log-id and --environment online")
}
log := out.Items[0]
enrichLogSourceStack(rctx, appID, log)
rctx.OutFormat(log, nil, func(w io.Writer) {
appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{logSummaryRow(log)}, logSummarySchema), logSummarySchema)
})
return nil
},
}
func callLogGetSearch(rctx *common.RuntimeContext, appID string, body map[string]interface{}) (map[string]interface{}, error) {
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: "POST",
ApiPath: logSearchPath(appID),
Body: body,
})
if err != nil {
return nil, err
}
data, err := rctx.ClassifyAPIResponse(resp)
if err == nil && data != nil {
return data, nil
}
if flex, ok := flexibleLogSearchData(resp.RawBody); ok && (err == nil || isNonObjectInvalidResponse(err)) {
return flex, nil
}
return data, err
}
type logSearchOutput struct {
Items []map[string]interface{} `json:"items"`
PageToken string `json:"page_token,omitempty"`
HasMore bool `json:"has_more"`
}
func logSearchPath(appID string) string {
return appScopedPath(appID, logSearchEndpoint)
}
func resolveStackPath(appID string) string {
return appScopedPath(appID, resolveStackEndpoint)
}
func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
if env == "" {
env = defaultAppsLogEnv
}
if err := validateObservabilityEnv(env); err != nil {
return nil, err
}
if err := validateAppsPageSize(rctx.Int("page-size")); err != nil {
return nil, err
}
body := map[string]interface{}{
"app_env": appsObservabilityBackendEnv,
"limit": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
body["page_token"] = token
}
if err := addLogSearchTimeRange(body, rctx); err != nil {
return nil, err
}
filter, err := buildLogSearchFilter(rctx)
if err != nil {
return nil, err
}
if len(filter) > 0 {
body["filter"] = filter
}
return body, nil
}
func buildLogGetSearchBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"app_env": appsObservabilityBackendEnv,
"limit": 1,
"filter": map[string]interface{}{
"log_ids": []string{strings.TrimSpace(rctx.Str("log-id"))},
},
}
}
func addLogSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeContext) error {
since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", rctx.Str("since"), "--until", rctx.Str("until"))
if err != nil {
return err
}
if hasSince {
body["start_timestamp_ns"] = nsNumber(since)
}
if hasUntil {
body["end_timestamp_ns"] = nsNumber(until)
}
return nil
}
func buildLogSearchFilter(rctx *common.RuntimeContext) (map[string]interface{}, error) {
filter := make(map[string]interface{})
levels, err := normalizeLogLevels(rctx.StrArray("level"))
if err != nil {
return nil, err
}
if len(levels) > 0 {
filter["levels"] = levels
}
if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 {
filter["trace_ids"] = traceIDs
}
addTrimmedLogFilterString(filter, "keyword", rctx.Str("keyword"))
addTrimmedLogFilterStrings(filter, "modules", rctx.Str("module"))
addTrimmedLogFilterStrings(filter, "user_ids", rctx.Str("user-id"))
addTrimmedLogFilterStrings(filter, "pages", rctx.Str("page"))
addTrimmedLogFilterStrings(filter, "apis", rctx.Str("api"))
if err := addDurationFilters(filter, rctx); err != nil {
return nil, err
}
return filter, nil
}
func addTrimmedLogFilterStrings(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = []string{value}
}
}
func addTrimmedLogFilterString(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = value
}
}
func addDurationFilters(filter map[string]interface{}, rctx *common.RuntimeContext) error {
hasMin := rctx.Changed("min-duration")
hasMax := rctx.Changed("max-duration")
minDuration := rctx.Int("min-duration")
maxDuration := rctx.Int("max-duration")
if hasMin {
if minDuration < 0 {
return appsValidationParamError("--min-duration", "--min-duration must be non-negative")
}
filter["min_duration_ms"] = minDuration
}
if hasMax {
if maxDuration < 0 {
return appsValidationParamError("--max-duration", "--max-duration must be non-negative")
}
filter["max_duration_ms"] = maxDuration
}
if hasMin && hasMax && minDuration > maxDuration {
return appsValidationParamError("--max-duration", "--max-duration must be greater than or equal to --min-duration")
}
return nil
}
func normalizeLogLevels(values []string) ([]string, error) {
values = cleanRepeatedStrings(values)
if len(values) == 0 {
return nil, nil
}
out := make([]string, 0, len(values))
for _, value := range values {
level := strings.ToUpper(strings.TrimSpace(value))
switch level {
case "DEBUG", "INFO", "WARN", "ERROR":
out = append(out, level)
default:
return nil, appsValidationParamError("--level", "--level must be one of DEBUG, INFO, WARN, ERROR")
}
}
return out, nil
}
func normalizeLogSearchResponse(data map[string]interface{}) logSearchOutput {
items := firstMapSlice(data, "items", "log_items", "logItems")
normalized := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
normalized = append(normalized, normalizeLogItem(item))
}
return logSearchOutput{
Items: normalized,
PageToken: firstLogString(data, "page_token", "next_page_token", "pageToken", "nextPageToken"),
HasMore: firstLogBool(data, "has_more", "hasMore"),
}
}
func normalizeLogItem(item map[string]interface{}) map[string]interface{} {
out := cloneMap(item)
normalizeObservabilityAttributes(out)
copyFirstAlias(out, item, "log_id", "log_id", "id", "logID", "logId")
copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId")
copyFirstAlias(out, item, "timestamp_ns", "timestamp_ns", "timestampNs")
copyFirstAlias(out, item, "severity_text", "severity_text", "severityText")
if level := firstItemString(out, "level", "severity_text", "severityText"); level != "" {
out["level"] = level
}
return out
}
func firstMapSlice(data map[string]interface{}, keys ...string) []map[string]interface{} {
for _, key := range keys {
raw, ok := data[key]
if !ok {
continue
}
switch items := raw.(type) {
case []map[string]interface{}:
return items
case []interface{}:
out := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
}
return nil
}
func flexibleLogSearchData(raw []byte) (map[string]interface{}, bool) {
var result interface{}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, false
}
switch value := result.(type) {
case []interface{}:
return map[string]interface{}{"items": value}, true
case map[string]interface{}:
data, ok := value["data"]
if !ok {
return nil, false
}
items, ok := data.([]interface{})
if !ok {
return nil, false
}
out := map[string]interface{}{"items": items}
for _, key := range []string{"page_token", "next_page_token", "pageToken", "nextPageToken", "has_more", "hasMore"} {
if v, present := value[key]; present {
out[key] = v
}
}
return out, true
default:
return nil, false
}
}
func isNonObjectInvalidResponse(err error) bool {
p, ok := errs.ProblemOf(err)
return ok && p.Category == errs.CategoryInternal && p.Subtype == errs.SubtypeInvalidResponse
}
func firstLogString(data map[string]interface{}, keys ...string) string {
for _, key := range keys {
if s, ok := data[key].(string); ok && strings.TrimSpace(s) != "" {
return s
}
}
return ""
}
func firstLogBool(data map[string]interface{}, keys ...string) bool {
for _, key := range keys {
if b, ok := data[key].(bool); ok {
return b
}
}
return false
}
func copyFirstAlias(dst, src map[string]interface{}, canonical string, keys ...string) {
for _, key := range keys {
if value, ok := src[key]; ok {
dst[canonical] = value
return
}
}
}
func cloneMap(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src)+4)
for key, value := range src {
dst[key] = value
}
return dst
}
func logListRows(items []map[string]interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
rows = append(rows, logSummaryRow(item))
}
return rows
}
var logSummarySchema = appsOutputSchema{
Columns: []appsOutputColumn{
{Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05.000")},
{Key: "level"},
{Key: "module"},
{Key: "user_id"},
{Key: "duration_ms", Format: appsFormatDurationMS},
{Key: "trace_id"},
{Key: "log_id"},
{Key: "message"},
},
Strict: true,
}
func logSummaryRow(item map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"log_id": item["log_id"],
"level": firstItemString(item, "level", "severity_text"),
"trace_id": item["trace_id"],
"timestamp_ns": item["timestamp_ns"],
"module": firstLogDetailValue(item, "module"),
"user_id": firstLogDetailValue(item, "user_id"),
"duration_ms": firstLogDetailValue(item, "duration_ms"),
"message": firstItemString(item, "message", "body"),
}
}
func firstLogDetailValue(item map[string]interface{}, key string) interface{} {
if value, ok := item[key]; ok {
return value
}
return appsAttributeValue(item["attributes"], key)
}
func firstItemString(item map[string]interface{}, keys ...string) string {
for _, key := range keys {
if s, ok := item[key].(string); ok && strings.TrimSpace(s) != "" {
return s
}
}
return ""
}
func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[string]interface{}) {
if !shouldResolveSourceStack(log) {
return
}
body, ok := extractSourceStackResolveBody(log)
if !ok {
log["source_stack_status"] = sourceStackStatusError
log["source_stack_reason"] = "source stack fields incomplete"
return
}
data, err := rctx.CallAPITyped("POST", resolveStackPath(appID), nil, body)
if err != nil {
if _, typed := errs.ProblemOf(err); typed {
markSourceStackResolveError(log, err)
}
return
}
stack := firstLogValue(data, "source_stack", "sourceStack", "frames")
if stack == nil {
stack = data
}
log["source_stack_status"] = sourceStackStatusOK
log["source_stack"] = stack
}
func markSourceStackResolveError(log map[string]interface{}, err error) {
log["source_stack_status"] = sourceStackStatusError
log["source_stack_reason"] = "resolve_stack_trace failed"
if problem, ok := errs.ProblemOf(err); ok {
if problem.Code != 0 {
log["source_stack_error_code"] = problem.Code
log["source_stack_reason"] = fmt.Sprintf("resolve_stack_trace failed: code %d", problem.Code)
}
if problem.LogID != "" {
log["source_stack_log_id"] = problem.LogID
}
}
}
func shouldResolveSourceStack(log map[string]interface{}) bool {
level := strings.ToUpper(firstItemString(log, "level", "severity_text", "severityText"))
if level != "ERROR" {
return false
}
if _, ok := extractSourceStackResolveBody(log); ok {
return true
}
return hasFrontendSourceMapSignal(log)
}
func hasFrontendSourceMapSignal(value interface{}) bool {
switch v := value.(type) {
case map[string]interface{}:
for key, nested := range v {
if isSourceMapSignal(key) || hasFrontendSourceMapSignal(nested) {
return true
}
}
case []interface{}:
for _, nested := range v {
if hasFrontendSourceMapSignal(nested) {
return true
}
}
case string:
return isSourceMapSignal(v) || strings.Contains(strings.ToLower(v), ".js")
}
return false
}
func isSourceMapSignal(value string) bool {
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(value))
return strings.Contains(normalized, "source_map") || strings.Contains(normalized, "sourcemap")
}
func extractSourceStackResolveBody(log map[string]interface{}) (map[string]interface{}, bool) {
sources := collectSourceStackMaps(log)
commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId", "release_commit_id", "releaseCommitID", "releaseCommitId")
prefix := firstStringInMaps(sources, "source_map_file_prefix", "sourceMapFilePrefix", "source_map_prefix", "sourceMapPrefix")
if prefix == "" && firstStringInMaps(sources, "release_commit_id", "releaseCommitID", "releaseCommitId") != "" {
prefix = defaultSourceMapPrefix
}
frames := firstFramesInMaps(
sources,
"frames",
"stack_frames",
"stackFrames",
"source_stack_frames",
"sourceStackFrames",
"stack",
"stack_trace",
"stackTrace",
"error_stack",
"errorStack",
"exception_stack",
"exceptionStack",
"message",
"body",
)
if commitID == "" || prefix == "" || len(frames) == 0 {
return nil, false
}
body := map[string]interface{}{
"commit_id": commitID,
"source_map_file_prefix": prefix,
"frames": frames,
}
if tenantID := firstStringInMaps(sources, "tenant_id", "tenantID", "tenantId"); tenantID != "" {
body["tenant_id"] = tenantID
}
return body, true
}
func collectSourceStackMaps(value interface{}) []map[string]interface{} {
out := make([]map[string]interface{}, 0, 8)
collectSourceStackMapsInto(value, 0, &out)
return out
}
func collectSourceStackMapsInto(value interface{}, depth int, out *[]map[string]interface{}) {
if depth > sourceStackMaxScanDepth || value == nil {
return
}
switch v := value.(type) {
case map[string]interface{}:
*out = append(*out, v)
for _, nested := range v {
collectSourceStackMapsInto(nested, depth+1, out)
}
case []interface{}:
if attrs := observabilityKVList(v); len(attrs) > 0 {
*out = append(*out, attrs)
for _, nested := range attrs {
collectSourceStackMapsInto(nested, depth+1, out)
}
}
for _, nested := range v {
collectSourceStackMapsInto(nested, depth+1, out)
}
case []map[string]interface{}:
for _, nested := range v {
collectSourceStackMapsInto(nested, depth+1, out)
}
case string:
if parsed := parseJSONObjectString(v); parsed != nil {
collectSourceStackMapsInto(parsed, depth+1, out)
}
}
}
func firstStringInMaps(sources []map[string]interface{}, keys ...string) string {
for _, source := range sources {
if s := firstLogString(source, keys...); s != "" {
return s
}
}
return ""
}
func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} {
for _, key := range keys {
for _, source := range sources {
frames := normalizeFrames(source[key])
if len(frames) > 0 {
return frames
}
}
}
return nil
}
func normalizeFrames(raw interface{}) []interface{} {
switch frames := raw.(type) {
case []interface{}:
out := make([]interface{}, 0, len(frames))
for _, frame := range frames {
if normalized, ok := normalizeFrame(frame); ok {
out = append(out, normalized)
if len(out) >= sourceStackMaxFrames {
return out
}
}
}
return out
case []map[string]interface{}:
out := make([]interface{}, 0, len(frames))
for _, frame := range frames {
if normalized, ok := normalizeFrame(frame); ok {
out = append(out, normalized)
if len(out) >= sourceStackMaxFrames {
return out
}
}
}
return out
case string:
return parseFrameString(frames)
default:
return nil
}
}
func normalizeFrame(frame interface{}) (map[string]interface{}, bool) {
switch f := frame.(type) {
case map[string]interface{}:
return normalizeFrameMap(f)
case map[string]string:
m := make(map[string]interface{}, len(f))
for key, value := range f {
m[key] = value
}
return normalizeFrameMap(m)
case string:
parsed := parseJSStackFrameLine(f)
if _, ok := parsed["file_name"]; !ok {
return nil, false
}
return parsed, true
default:
return nil, false
}
}
func normalizeFrameMap(frame map[string]interface{}) (map[string]interface{}, bool) {
fileName := normalizeSourceFrameFileName(firstLogString(frame, "file_name", "fileName", "filename", "file", "url"))
line, lineOK := firstFrameInt(frame, "line", "line_number", "lineNumber")
column, columnOK := firstFrameInt(frame, "column", "col", "column_number", "columnNumber")
if fileName == "" || !lineOK || !columnOK {
return nil, false
}
out := map[string]interface{}{
"file_name": fileName,
"line": line,
"column": column,
}
if fn := firstLogString(frame, "function", "function_name", "functionName", "method", "methodName"); fn != "" {
out["function"] = fn
}
return out, true
}
func normalizeSourceFrameFileName(fileName string) string {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return ""
}
parts := strings.FieldsFunc(fileName, func(r rune) bool {
return r == '/' || r == '?' || r == '#'
})
for i := len(parts) - 1; i >= 0; i-- {
if part := strings.TrimSpace(parts[i]); part != "" {
return part
}
}
return fileName
}
func firstFrameInt(frame map[string]interface{}, keys ...string) (int, bool) {
for _, key := range keys {
if value, ok := frame[key]; ok {
if n, valid := frameInt(value); valid {
return n, true
}
}
}
return 0, false
}
func frameInt(value interface{}) (int, bool) {
switch v := value.(type) {
case int:
return positiveFrameInt(v)
case int64:
if v > int64(^uint(0)>>1) {
return 0, false
}
return positiveFrameInt(int(v))
case float64:
if v != float64(int(v)) {
return 0, false
}
return positiveFrameInt(int(v))
case json.Number:
n, err := strconv.Atoi(v.String())
if err != nil {
return 0, false
}
return positiveFrameInt(n)
case string:
return parsePositiveInt(v)
default:
return 0, false
}
}
func positiveFrameInt(n int) (int, bool) {
if n < 1 {
return 0, false
}
return n, true
}
func parseFrameString(raw string) []interface{} {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var decoded []interface{}
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
return normalizeFrames(decoded)
}
lines := strings.Split(raw, "\n")
out := make([]interface{}, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if frame, ok := normalizeFrame(parseJSStackFrameLine(line)); ok {
out = append(out, frame)
if len(out) >= sourceStackMaxFrames {
return out
}
}
}
return out
}
func parseJSStackFrameLine(line string) map[string]interface{} {
if frame := parseJSStackFrameMatch(line, jsStackFrameParenRe.FindStringSubmatch(line)); frame != nil {
return frame
}
if frame := parseJSStackFrameMatch(line, jsStackFrameBareRe.FindStringSubmatch(line)); frame != nil {
return frame
}
return map[string]interface{}{"raw": line}
}
func parseJSStackFrameMatch(raw string, match []string) map[string]interface{} {
if match == nil {
return nil
}
switch len(match) {
case 4:
line, lineOK := parsePositiveInt(match[2])
column, columnOK := parsePositiveInt(match[3])
if lineOK && columnOK {
return map[string]interface{}{"file_name": normalizeSourceFrameFileName(match[1]), "line": line, "column": column}
}
case 5:
line, lineOK := parsePositiveInt(match[3])
column, columnOK := parsePositiveInt(match[4])
if lineOK && columnOK {
out := map[string]interface{}{
"file_name": normalizeSourceFrameFileName(match[2]),
"line": line,
"column": column,
}
if fn := strings.TrimSpace(match[1]); fn != "" {
out["function"] = fn
}
return out
}
}
return map[string]interface{}{"raw": raw}
}
func parseJSONObjectString(raw string) map[string]interface{} {
raw = strings.TrimSpace(raw)
if raw == "" || !strings.HasPrefix(raw, "{") {
return nil
}
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return nil
}
return parsed
}
func parsePositiveInt(raw string) (int, bool) {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 1 {
return 0, false
}
return n, true
}
func firstLogValue(data map[string]interface{}, keys ...string) interface{} {
for _, key := range keys {
if value, ok := data[key]; ok {
return value
}
}
return nil
}

View File

@@ -1,664 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsLogList, []string{
"+log-list", "--app-id", "app_x", "--level", "error",
"--trace-id", "trace-1",
"--keyword", "timeout", "--module", "frontend", "--user-id", "ou_1",
"--page", "/home", "--api", "/api/orders", "--min-duration", "200",
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
"--page-size", "20", "--dry-run", "--as", "user",
}, factory, stdout)
if 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"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_logs" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["limit"] != float64(20) {
t.Fatalf("body = %#v", env.API[0].Body)
}
filter := env.API[0].Body["filter"].(map[string]interface{})
if got := filter["keyword"]; got != "timeout" {
t.Fatalf("filter.keyword = %v", got)
}
for key, want := range map[string]string{
"modules": "frontend",
"user_ids": "ou_1",
"pages": "/home",
"apis": "/api/orders",
} {
values, ok := filter[key].([]interface{})
if !ok || len(values) != 1 || values[0] != want {
t.Fatalf("filter.%s = %#v, want [%q]", key, filter[key], want)
}
}
if env.API[0].Body["start_timestamp_ns"] != "1782208800000000000" ||
env.API[0].Body["end_timestamp_ns"] != "1782208860000000000" {
t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"])
}
}
func TestAppsLogList_DoesNotAcceptLogIDFlag(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsLogList, []string{
"+log-list", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --log-id") {
t.Fatalf("expected unknown --log-id flag, got %v", err)
}
}
func TestAppsLogList_RejectsDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout)
requireAppsValidationParam(t, err, "--environment")
}
func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{"log_id": "LOG1", "level": "INFO"},
},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--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.Fatal(err)
}
if sent["limit"] != float64(1) {
t.Fatalf("limit = %v, want 1", sent["limit"])
}
if sent["app_env"] != "runtime" {
t.Fatalf("app_env = %v, want runtime", sent["app_env"])
}
}
func TestAppsLogGet_AcceptsDataArraySearchResponse(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
RawBody: []byte(`{
"code": 0,
"data": [
{
"log_id": "LOG7655249917057764881",
"level": "ERROR",
"attributes": {
"commit_id": "commit_array",
"source_map_file_prefix": "sourcemaps/array",
"frames": [{"file":"main.js","line":10,"column":20}]
}
}
]
}`),
}
resolve := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"source_stack": []interface{}{
map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9},
},
},
},
}
reg.Register(search)
reg.Register(resolve)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
t.Fatalf("stdout missing resolved source stack from data array response: %s", got)
}
}
func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"logItems": []interface{}{
map[string]interface{}{
"id": "LOG1",
"traceID": "trace-1",
"timestampNs": "1782209472123456789",
"severityText": "ERROR",
},
},
"nextPageToken": "tok-next",
"hasMore": true,
},
},
})
if err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []map[string]interface{} `json:"items"`
PageToken string `json:"page_token"`
HasMore bool `json:"has_more"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if env.Data.PageToken != "tok-next" || !env.Data.HasMore {
t.Fatalf("pagination = token %q has_more %v", env.Data.PageToken, env.Data.HasMore)
}
if len(env.Data.Items) != 1 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
item := env.Data.Items[0]
if item["level"] != "ERROR" || item["severity_text"] != "ERROR" || item["severityText"] != "ERROR" {
t.Fatalf("level fields = %#v", item)
}
}
func TestAppsLogList_NormalizesKVAttributesToObject(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG1",
"attributes": []interface{}{
map[string]interface{}{"key": "app_env", "value": "runtime"},
map[string]interface{}{"key": "duration_ms", "value": "8263"},
map[string]interface{}{"key": "module", "value": "gateway"},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
attrs, ok := env.Data.Items[0]["attributes"].(map[string]interface{})
if !ok {
t.Fatalf("attributes = %#v, want object", env.Data.Items[0]["attributes"])
}
if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" {
t.Fatalf("attributes = %#v", attrs)
}
}
func TestAppsLogGet_PrettyFormatsTimestamp(t *testing.T) {
const rawNS = int64(1782209472123456789)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG1",
"level": "ERROR",
"trace_id": "trace-1",
"timestamp_ns": rawNS,
"message": "boom",
},
},
},
},
})
if err := runAppsShortcut(t, AppsLogGet, []string{
"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05.000")
if !strings.HasPrefix(got, "time") {
t.Fatalf("pretty output should start with time column, got:\n%s", got)
}
if !strings.Contains(got, wantTime) {
t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got)
}
if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782209472123456789") {
t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got)
}
}
func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG1",
"level": "ERROR",
"attributes": map[string]interface{}{
"commit_id": "commit_1",
"source_map_file_prefix": "sourcemaps/app",
"frames": []interface{}{
map[string]interface{}{"file": "main.js", "line": 10, "column": 20},
},
},
},
},
},
},
}
resolve := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"source_stack": []interface{}{
map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9},
},
},
},
}
reg.Register(search)
reg.Register(resolve)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
t.Fatal(err)
}
if sent["commit_id"] != "commit_1" || sent["source_map_file_prefix"] != "sourcemaps/app" {
t.Fatalf("resolve body missing source map fields: %#v", sent)
}
frames, ok := sent["frames"].([]interface{})
if !ok || len(frames) != 1 {
t.Fatalf("resolve frames = %#v", sent["frames"])
}
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
t.Fatalf("stdout missing resolved source stack: %s", got)
}
}
func TestAppsLogGet_ResolvesSourceStackFromNestedKVAttributes(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG7655249917057764881",
"severityText": "ERROR",
"attributes": []interface{}{
map[string]interface{}{"key": "commit_id", "value": "commit_nested"},
map[string]interface{}{"key": "source_map_file_prefix", "value": "sourcemaps/nested"},
map[string]interface{}{
"key": "exception",
"value": map[string]interface{}{
"stackTrace": strings.Join([]string{
"TypeError: failed to render",
" at render (https://cdn.example.com/assets/main.js:12:34)",
" at https://cdn.example.com/assets/chunk.js:56:78",
}, "\n"),
},
},
},
},
},
},
},
}
resolve := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"source_stack": []interface{}{
map[string]interface{}{"file": "src/App.tsx", "line": 12, "column": 34},
},
},
},
}
reg.Register(search)
reg.Register(resolve)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
t.Fatal(err)
}
if sent["commit_id"] != "commit_nested" || sent["source_map_file_prefix"] != "sourcemaps/nested" {
t.Fatalf("resolve body missing nested source map fields: %#v", sent)
}
frames, ok := sent["frames"].([]interface{})
if !ok || len(frames) != 2 {
t.Fatalf("resolve frames = %#v, want parsed stack frames", sent["frames"])
}
frame, ok := frames[0].(map[string]interface{})
if !ok {
t.Fatalf("parsed frame = %#v, want object", frames[0])
}
if frame["function"] != "render" || frame["file_name"] != "main.js" || frame["line"] != float64(12) || frame["column"] != float64(34) {
t.Fatalf("parsed frame = %#v", frame)
}
bare, ok := frames[1].(map[string]interface{})
if !ok {
t.Fatalf("bare frame = %#v, want object", frames[1])
}
if bare["file_name"] != "chunk.js" || bare["line"] != float64(56) || bare["column"] != float64(78) {
t.Fatalf("bare frame = %#v", bare)
}
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
t.Fatalf("stdout missing resolved source stack: %s", got)
}
}
func TestAppsLogGet_ResolvesSourceStackFromReleaseCommitJSONStack(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG7655249917057764881",
"severityText": "ERROR",
"attributes": map[string]interface{}{
"tenant_id": "110564",
"release_commit_id": "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f",
"stack": `[{"fileName":"main.js","line":3348,"column":540585},` +
`{"fileName":"main.js","line":3107,"column":51935},` +
`{"fileName":"main.js","line":62,"column":12516}]`,
},
},
},
},
},
}
resolve := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"source_stack": []interface{}{
map[string]interface{}{"file": "src/App.tsx", "line": 42, "column": 7},
},
},
},
}
reg.Register(search)
reg.Register(resolve)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
t.Fatal(err)
}
if sent["commit_id"] != "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f" || sent["source_map_file_prefix"] != defaultSourceMapPrefix || sent["tenant_id"] != "110564" {
t.Fatalf("resolve body missing release source map fields: %#v", sent)
}
frames, ok := sent["frames"].([]interface{})
if !ok || len(frames) != 3 {
t.Fatalf("resolve frames = %#v, want all valid generated frames", sent["frames"])
}
first, ok := frames[0].(map[string]interface{})
if !ok {
t.Fatalf("first frame = %#v, want object", frames[0])
}
if first["file_name"] != "main.js" || first["line"] != float64(3348) || first["column"] != float64(540585) {
t.Fatalf("first frame = %#v", first)
}
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
t.Fatalf("stdout missing resolved source stack: %s", got)
}
}
func TestAppsLogGet_ResolvesSourceStackFromJSONBodyStack(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG_BODY_STACK",
"severityText": "ERROR",
"attributes": map[string]interface{}{
"release_commit_id": "commit_body",
},
"body": `{"error":{"stack":"AxiosError: failed\n at request (https://cdn.example.com/client/assets/body.js:9:88)"}}`,
},
},
},
},
}
resolve := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"source_stack": []interface{}{
map[string]interface{}{"file": "src/request.ts", "line": 9, "column": 88},
},
},
},
}
reg.Register(search)
reg.Register(resolve)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG_BODY_STACK", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
t.Fatal(err)
}
if sent["commit_id"] != "commit_body" || sent["source_map_file_prefix"] != defaultSourceMapPrefix {
t.Fatalf("resolve body missing body stack source map fields: %#v", sent)
}
frames, ok := sent["frames"].([]interface{})
if !ok || len(frames) != 1 {
t.Fatalf("resolve frames = %#v, want parsed JSON body stack frame", sent["frames"])
}
frame, ok := frames[0].(map[string]interface{})
if !ok {
t.Fatalf("frame = %#v, want object", frames[0])
}
if frame["function"] != "request" || frame["file_name"] != "body.js" || frame["line"] != float64(9) || frame["column"] != float64(88) {
t.Fatalf("frame = %#v", frame)
}
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/request.ts") {
t.Fatalf("stdout missing resolved source stack: %s", got)
}
}
func TestAppsLogGet_SourceStackMissingFieldsDoesNotFail(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG1",
"level": "ERROR",
"message": "TypeError at https://cdn.example.com/main.js:10:20",
"attributes": map[string]interface{}{"commit_id": "commit_1"},
},
},
},
},
}
reg.Register(search)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"log_id": "LOG1"`) {
t.Fatalf("stdout missing original log: %s", got)
} else if !strings.Contains(got, `"source_stack_status": "unresolved"`) {
t.Fatalf("stdout missing unresolved source stack status: %s", got)
} else if !strings.Contains(got, `"source_stack_reason"`) {
t.Fatalf("stdout missing sanitized source stack reason: %s", got)
}
for _, banned := range []string{"secret", "token", "raw request payload"} {
if strings.Contains(strings.ToLower(stdout.String()), banned) {
t.Fatalf("stdout leaked %q: %s", banned, stdout.String())
}
}
}
func TestAppsLogGet_ErrorNonFrontendMissingFieldsDoesNotMarkUnresolved(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG1",
"level": "ERROR",
"message": "go stack trace: database query failed",
},
},
},
},
})
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); strings.Contains(got, "source_stack_status") {
t.Fatalf("non-frontend error log should not be marked unresolved: %s", got)
}
}
func TestAppsLogGet_SourceStackResolveFailureIsRedacted(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
search := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"log_items": []interface{}{
map[string]interface{}{
"log_id": "LOG1",
"level": "ERROR",
"attributes": map[string]interface{}{
"commit_id": "commit_1",
"source_map_file_prefix": "sourcemaps/app",
"frames": []interface{}{
map[string]interface{}{"file": "main.js", "line": 10, "column": 20},
},
},
},
},
},
},
}
resolve := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
Body: map[string]interface{}{
"code": 999,
"msg": "secret token raw request payload should be redacted",
},
}
reg.Register(search)
reg.Register(resolve)
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"source_stack_status": "unresolved"`) {
t.Fatalf("stdout missing unresolved status: %s", got)
}
if !strings.Contains(got, `"source_stack_error_code": 999`) {
t.Fatalf("stdout missing resolve error code: %s", got)
}
for _, banned := range []string{"secret", "token", "raw request payload"} {
if strings.Contains(strings.ToLower(got), banned) {
t.Fatalf("stdout leaked %q: %s", banned, got)
}
}
}

View File

@@ -1,587 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultAppsMetricEnv = "online"
defaultAppsMetricDownSample = "1m"
metricListEndpoint = "query_metrics_data"
defaultObservabilityRangeDays = 30
)
// AppsMetricList lists online app observability metrics.
var AppsMetricList = common.Shortcut{
Service: appsService,
Command: "+metric-list",
Description: "List online app request, latency, CPU, and memory metrics",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +metric-list --app-id <app_id> --metric requests --series total --since 1d",
"Tip: metric timestamps use seconds; use +analytics-list for PV/UV-style analytics.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID whose online metrics should be listed", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"},
{Name: "metric", Desc: "metric family to list", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}},
{Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"},
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
{Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"},
{Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"},
{Name: "api", Type: "string_array", Desc: "API path/name filter; repeatable"},
{Name: "down-sample", Default: defaultAppsMetricDownSample, Desc: "metric down-sample interval", Enum: []string{"1m", "1h", "1d"}},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, _, _, _, err := buildMetricListBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _, _, _, _ := buildMetricListBody(rctx)
return common.NewDryRunAPI().
POST(metricListPath(rctx.Str("app-id"))).
Desc("List online app metrics").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
body, names, labels, fillZero, err := buildMetricListBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", metricListPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := observabilitySeriesOutput{
Items: normalizeMetricSeries(data, names, labels, fillZero),
HasMore: false,
}
rctx.OutFormat(out, nil, func(w io.Writer) {
rows := observabilitySeriesRows(out.Items)
sortObservabilityRowsDesc(rows, "timestamp")
rows = filterObservabilityRowsWithTime(rows, "timestamp")
appsPrintSchemaTable(w, rows, metricSeriesSchema(labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "latency"))
})
return nil
},
}
type observabilitySeriesOutput struct {
Items []map[string]interface{} `json:"items"`
HasMore bool `json:"has_more"`
}
func metricListPath(appID string) string {
return appScopedPath(appID, metricListEndpoint)
}
func buildMetricListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) {
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
if env == "" {
env = defaultAppsMetricEnv
}
if err := validateObservabilityEnv(env); err != nil {
return nil, nil, nil, false, err
}
names, labels, err := metricNamesForCLI(rctx.Str("metric"), rctx.Str("series"))
if err != nil {
return nil, nil, nil, false, err
}
since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until"))
if err != nil {
return nil, nil, nil, false, err
}
downSample := strings.TrimSpace(rctx.Str("down-sample"))
if !rctx.Changed("down-sample") {
downSample = appsMetricDownSampleForRange(since, until)
} else if downSample == "" {
downSample = defaultAppsMetricDownSample
}
body := map[string]interface{}{
"metric_names": names,
"start_timestamp": secNumber(since),
"end_timestamp": secNumber(until),
"down_sample": downSample,
"need_pack_lack_point": false,
}
if filter := buildMetricListFilter(rctx); len(filter) > 0 {
body["filter"] = filter
}
return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil
}
func appsMetricDownSampleForRange(since, until time.Time) string {
d := until.Sub(since)
switch {
case d <= 6*time.Hour:
return "1m"
case d <= 7*24*time.Hour:
return "1h"
default:
return "1d"
}
}
func buildMetricListFilter(rctx *common.RuntimeContext) map[string]interface{} {
filter := make(map[string]interface{})
if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 {
filter["pages"] = pages
}
if apis := cleanRepeatedStrings(rctx.StrArray("api")); len(apis) > 0 {
filter["apis"] = apis
}
return filter
}
func defaultedObservabilityTimeRange(sinceRaw, untilRaw string) (time.Time, time.Time, error) {
since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", sinceRaw, "--until", untilRaw)
if err != nil {
return time.Time{}, time.Time{}, err
}
if !hasUntil {
until = time.Now()
}
if !hasSince {
since = until.Add(-defaultObservabilityRangeDays * 24 * time.Hour)
}
if since.After(until) {
return time.Time{}, time.Time{}, appsValidationParamError("--until", "--until must be greater than or equal to --since")
}
return since, until, nil
}
func metricNamesForCLI(metric, series string) ([]string, []string, error) {
metric = strings.TrimSpace(strings.ToLower(metric))
series = strings.TrimSpace(strings.ToLower(series))
switch metric {
case "requests":
switch series {
case "":
return []string{"client_api_request_count", "client_api_request_error_count"}, []string{"total", "error"}, nil
case "total":
return []string{"client_api_request_count"}, []string{"total"}, nil
case "error":
return []string{"client_api_request_error_count"}, []string{"error"}, nil
default:
return nil, nil, appsValidationParamError("--series", "--series for --metric requests must be total or error")
}
case "latency":
switch series {
case "":
return []string{"client_api_request_latency_p50", "client_api_request_latency_p99"}, []string{"p50", "p99"}, nil
case "p50":
return []string{"client_api_request_latency_p50"}, []string{"p50"}, nil
case "p99":
return []string{"client_api_request_latency_p99"}, []string{"p99"}, nil
default:
return nil, nil, appsValidationParamError("--series", "--series for --metric latency must be p50 or p99")
}
case "cpu":
if series != "" {
return nil, nil, appsValidationParamError("--series", "--metric cpu does not support --series")
}
return []string{"cpu_usage"}, []string{"cpu"}, nil
case "memory":
if series != "" {
return nil, nil, appsValidationParamError("--series", "--metric memory does not support --series")
}
return []string{"mem_usage"}, []string{"memory"}, nil
default:
return nil, nil, appsValidationParamError("--metric", "--metric must be one of requests, latency, cpu, memory")
}
}
func normalizeMetricSeries(data map[string]interface{}, names, labels []string, fillZero bool) []map[string]interface{} {
return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp")
}
func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} {
if series := observabilityMapSlice(data["series"]); len(series) > 0 {
return mergeObservabilitySeries(series, labels, nameLabels, fillZero, timeField)
}
if items := observabilityMapSlice(data["items"]); len(items) > 0 {
if observabilityHasNestedPoints(items) {
return mergeObservabilitySeries(items, labels, nameLabels, fillZero, timeField)
}
return normalizeObservabilityPoints(items, labels, nameLabels, fillZero, timeField)
}
for _, key := range []string{"points", "data_points", "dataPoints"} {
if points := observabilityMapSlice(data[key]); len(points) > 0 {
return normalizeObservabilityPoints(points, labels, nameLabels, fillZero, timeField)
}
}
return []map[string]interface{}{}
}
func observabilityHasNestedPoints(items []map[string]interface{}) bool {
for _, item := range items {
if len(observabilityNestedPoints(item)) > 0 {
return true
}
}
return false
}
func mergeObservabilitySeries(series []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} {
index := make(map[string]int)
items := make([]map[string]interface{}, 0)
for i, serie := range series {
label := observabilitySeriesLabel(serie, labels, nameLabels, i)
if label == "" {
continue
}
points := observabilityNestedPoints(serie)
if len(points) == 0 {
points = []map[string]interface{}{serie}
}
for _, point := range points {
timestamp := observabilityTimestamp(point, timeField)
dimensions := observabilityDimensions(point)
key := observabilityPointKey(timestamp, dimensions)
pos, ok := index[key]
if !ok {
pos = len(items)
index[key] = pos
items = append(items, map[string]interface{}{
timeField: timestamp,
"dimensions": dimensions,
"values": map[string]interface{}{},
})
}
values := items[pos]["values"].(map[string]interface{})
values[label] = observabilityPointValue(point, label, nameLabels)
}
}
if fillZero {
fillObservabilityZeroes(items, labels)
}
return items
}
func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} {
items := make([]map[string]interface{}, 0, len(points))
for _, point := range points {
values := observabilityPointValues(point, labels, nameLabels, fillZero)
items = append(items, map[string]interface{}{
timeField: observabilityTimestamp(point, timeField),
"dimensions": observabilityDimensions(point),
"values": values,
})
}
return items
}
func fillObservabilityZeroes(items []map[string]interface{}, labels []string) {
for _, item := range items {
values, ok := item["values"].(map[string]interface{})
if !ok {
values = map[string]interface{}{}
item["values"] = values
}
for _, label := range labels {
if value, ok := values[label]; !ok || value == nil {
values[label] = 0
}
}
}
}
func fillObservabilityZeroesWhenPartiallyPresent(items []map[string]interface{}, labels []string) {
for _, item := range items {
values, ok := item["values"].(map[string]interface{})
if !ok || !observabilityHasAnyNonNullValue(values) {
continue
}
for _, label := range labels {
if value, ok := values[label]; !ok || value == nil {
values[label] = 0
}
}
}
}
func observabilityHasAnyNonNullValue(values map[string]interface{}) bool {
for _, value := range values {
if value != nil {
return true
}
}
return false
}
func observabilityPointValues(point map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool) map[string]interface{} {
values := make(map[string]interface{}, len(labels))
switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); v := raw.(type) {
case map[string]interface{}:
for _, label := range labels {
if value, ok := v[label]; ok {
values[label] = value
}
}
for name, label := range nameLabels {
if value, ok := v[name]; ok {
values[label] = value
}
}
case []interface{}:
for i, rawItem := range v {
if item, ok := rawItem.(map[string]interface{}); ok {
name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name")))
label := nameLabels[name]
if label == "" && i < len(labels) {
label = labels[i]
}
if label != "" {
values[label] = firstObservabilityValue(item, "value")
}
continue
}
if i < len(labels) {
values[labels[i]] = rawItem
}
}
}
for _, label := range labels {
if value, ok := point[label]; ok {
values[label] = value
}
}
if len(labels) == 1 {
if value, ok := point["value"]; ok {
values[labels[0]] = value
}
}
if fillZero {
for _, label := range labels {
if value, ok := values[label]; !ok || value == nil {
values[label] = 0
}
}
}
return values
}
func observabilityPointValue(point map[string]interface{}, label string, nameLabels map[string]string) interface{} {
if value, ok := point["value"]; ok {
return value
}
switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); values := raw.(type) {
case map[string]interface{}:
for name, mappedLabel := range nameLabels {
if mappedLabel == label {
if value, ok := values[name]; ok {
return value
}
}
}
return values[label]
case []interface{}:
for _, rawItem := range values {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name")))
if nameLabels[name] == label {
return firstObservabilityValue(item, "value")
}
}
for _, rawItem := range values {
if _, ok := rawItem.(map[string]interface{}); !ok {
return rawItem
}
}
}
return nil
}
func observabilityNestedPoints(item map[string]interface{}) []map[string]interface{} {
for _, key := range []string{"data_points", "dataPoints", "points", "items"} {
if points := observabilityMapSlice(item[key]); len(points) > 0 {
return points
}
}
return nil
}
func observabilityMapSlice(raw interface{}) []map[string]interface{} {
switch items := raw.(type) {
case []map[string]interface{}:
return items
case []interface{}:
out := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
default:
return nil
}
}
func observabilitySeriesLabel(serie map[string]interface{}, labels []string, nameLabels map[string]string, index int) string {
for _, key := range []string{"label", "series", "name", "metric_name", "metricName", "metric_type", "metricType"} {
if value, ok := serie[key].(string); ok {
value = strings.TrimSpace(value)
if label := nameLabels[value]; label != "" {
return label
}
if containsObservabilityLabel(labels, value) {
return value
}
}
}
if index >= 0 && index < len(labels) {
return labels[index]
}
return ""
}
func containsObservabilityLabel(labels []string, value string) bool {
for _, label := range labels {
if value == label {
return true
}
}
return false
}
func observabilityTimestamp(point map[string]interface{}, timeField string) interface{} {
keys := []string{timeField}
if timeField == "timestamp_ns" {
keys = append(keys, "timestampNs", "time_ns", "timeNs", "time", "ts")
} else {
keys = append(keys, "timestampSec", "time", "ts")
}
return firstObservabilityValue(point, keys...)
}
func observabilityDimensions(point map[string]interface{}) map[string]interface{} {
for _, key := range []string{"dimensions", "dimension", "labels", "tags"} {
if dimensions, ok := point[key].(map[string]interface{}); ok {
return cloneMap(dimensions)
}
if dimensions := observabilityKVList(point[key]); len(dimensions) > 0 {
return dimensions
}
}
return map[string]interface{}{}
}
func observabilityNameLabels(names, labels []string) map[string]string {
out := make(map[string]string, len(names))
for i, name := range names {
if i < len(labels) {
out[name] = labels[i]
}
}
return out
}
func observabilityKVList(raw interface{}) map[string]interface{} {
items := observabilityMapSlice(raw)
if len(items) == 0 {
return nil
}
out := make(map[string]interface{}, len(items))
for _, item := range items {
key := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name")))
if key == "" {
continue
}
out[key] = firstObservabilityValue(item, "value")
}
return out
}
func firstObservabilityValue(m map[string]interface{}, keys ...string) interface{} {
for _, key := range keys {
if value, ok := m[key]; ok {
return value
}
}
return nil
}
func observabilityPointKey(timestamp interface{}, dimensions map[string]interface{}) string {
encoded, err := json.Marshal(dimensions)
if err != nil {
return fmt.Sprintf("%v|%v", timestamp, dimensions)
}
return fmt.Sprintf("%v|%s", timestamp, string(encoded))
}
func observabilitySeriesRows(items []map[string]interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
row := map[string]interface{}{}
for key, value := range item {
if key == "values" {
if values, ok := value.(map[string]interface{}); ok {
for label, metricValue := range values {
row[label] = metricValue
}
}
continue
}
row[key] = value
}
rows = append(rows, row)
}
return rows
}
func metricSeriesSchema(labels []string, durationValues bool) appsOutputSchema {
columns := []appsOutputColumn{
{Key: "timestamp", Label: "time", Format: appsFormatSec("2006-01-02 15:04:05")},
}
for _, label := range labels {
col := appsOutputColumn{Key: label}
if durationValues {
col.Format = appsFormatDurationMS
}
columns = append(columns, col)
}
return appsOutputSchema{Columns: columns, Strict: true}
}
func sortObservabilityRowsDesc(rows []map[string]interface{}, key string) {
sort.SliceStable(rows, func(i, j int) bool {
left, leftOK := appsInt64Value(rows[i][key])
right, rightOK := appsInt64Value(rows[j][key])
if !leftOK || !rightOK {
return false
}
return left > right
})
}
func filterObservabilityRowsWithTime(rows []map[string]interface{}, key string) []map[string]interface{} {
out := rows[:0]
for _, row := range rows {
if _, ok := appsInt64Value(row[key]); ok {
out = append(out, row)
}
}
return out
}

View File

@@ -1,298 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/httpmock"
)
func TestMetricNamesMapping(t *testing.T) {
got, labels, err := metricNamesForCLI("requests", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" {
t.Fatalf("names = %#v", got)
}
if strings.Join(labels, ",") != "total,error" {
t.Fatalf("labels = %#v", labels)
}
if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil {
t.Fatalf("cpu with p99 should fail")
}
}
func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "requests",
"--series", "total", "--since", "2026-06-23T10:00:00Z",
"--until", "2026-06-23T10:01:00Z", "--down-sample", "1m",
"--dry-run", "--as", "user",
}, factory, stdout)
if 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"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
body := env.API[0].Body
if _, ok := body["start_timestamp"]; !ok {
t.Fatalf("metric dry-run missing start_timestamp: %#v", body)
}
if _, ok := body["start_timestamp_ns"]; ok {
t.Fatalf("metric should not use start_timestamp_ns: %#v", body)
}
if _, ok := body["app_env"]; ok {
t.Fatalf("metric OpenAPI body should not include app_env: %#v", body)
}
if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" {
t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"])
}
if body["down_sample"] != "1m" {
t.Fatalf("down_sample = %v", body["down_sample"])
}
}
func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) {
for _, tc := range []struct {
name string
since string
until string
want string
}{
{name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"},
{name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"},
{name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "requests",
"--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user",
}, factory, stdout)
if 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(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if got := env.API[0].Body["down_sample"]; got != tc.want {
t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String())
}
})
}
}
func TestAppsMetricList_RejectsDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user",
}, factory, stdout)
requireAppsValidationParam(t, err, "--environment")
}
func TestAppsMetricList_FillsMissingRequestValuesWithZero(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"points": []interface{}{
map[string]interface{}{
"timestamp": float64(1782208800),
"dimensions": map[string]interface{}{"page": "/home"},
"values": []interface{}{
map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)},
},
},
map[string]interface{}{
"timestamp": float64(1782208860),
"dimensions": map[string]interface{}{"page": "/settings"},
"values": []interface{}{
map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)},
map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
HasMore bool `json:"has_more"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if env.Data.HasMore {
t.Fatalf("has_more = true, want false")
}
if len(env.Data.Items) != 2 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
for i, item := range env.Data.Items {
if item.Values["error"] != float64(0) {
t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values)
}
}
}
func TestAppsMetricList_PrettyFormatsTimeFirst(t *testing.T) {
const rawSec = int64(1782208800)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"points": []interface{}{
map[string]interface{}{
"timestamp": float64(rawSec),
"values": []interface{}{
map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)},
map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05")
if !strings.HasPrefix(got, "time") {
t.Fatalf("pretty output should start with time column, got:\n%s", got)
}
if !strings.Contains(got, wantTime) {
t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got)
}
if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") {
t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got)
}
}
func TestAppsMetricList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"name": "client_api_request_error_count",
"points": []interface{}{
map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)},
},
},
map[string]interface{}{
"name": "client_api_request_count",
"points": []interface{}{
map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
values := env.Data.Items[0].Values
if values["total"] != float64(10) || values["error"] != float64(2) {
t.Fatalf("values = %#v, want total=10 error=2", values)
}
}
func TestAppsMetricList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
if err := runAppsShortcut(t, AppsMetricList, []string{
"+metric-list", "--app-id", "app_x", "--metric", "latency", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []map[string]interface{} `json:"items"`
HasMore bool `json:"has_more"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if env.Data.Items == nil {
t.Fatalf("items decoded as nil; stdout=%s", stdout.String())
}
if len(env.Data.Items) != 0 || env.Data.HasMore {
t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore)
}
}

View File

@@ -1,202 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
)
const (
defaultAppsPageSize = 50
maxAppsPageSize = 100
appsEnvironmentFlag = "environment"
// The CLI exposes the user-facing online environment, while the
// observability backend stores online app runtime telemetry under runtime.
appsObservabilityBackendEnv = "runtime"
)
func appScopedPath(appID, suffix string) string {
base := apiBasePath + "/apps/" + validate.EncodePathSegment(strings.TrimSpace(appID))
suffix = strings.TrimLeft(strings.TrimSpace(suffix), "/")
if suffix == "" {
return base
}
return base + "/" + suffix
}
func validateObservabilityEnv(env string) error {
switch strings.TrimSpace(env) {
case "", "online":
return nil
default:
return appsValidationParamError("--environment", "observability commands only support online (got %q)", env).
WithHint("only online is supported; omit --environment to use the default online environment")
}
}
func validateEnvVarEnv(env string) error {
switch strings.TrimSpace(env) {
case "dev", "online":
return nil
default:
return appsValidationParamError("--environment", "env var commands only support --environment dev or --environment online (got %q)", env)
}
}
func validateAppsPageSize(n int) error {
if n < 1 || n > maxAppsPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxAppsPageSize)
}
return nil
}
func cleanRepeatedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func normalizeObservabilityAttributes(item map[string]interface{}) {
kv := observabilityKVList(item["attributes"])
if len(kv) > 0 {
item["attributes"] = kv
}
}
func parseAppsTimeRange(sinceName, sinceRaw, untilName, untilRaw string) (time.Time, time.Time, bool, bool, error) {
var since, until time.Time
var hasSince, hasUntil bool
now := time.Now()
if strings.TrimSpace(sinceRaw) != "" {
parsed, err := parseAppsTimeFlag(sinceName, sinceRaw, now)
if err != nil {
return time.Time{}, time.Time{}, false, false, err
}
since = parsed
hasSince = true
}
if strings.TrimSpace(untilRaw) != "" {
parsed, err := parseAppsTimeFlag(untilName, untilRaw, now)
if err != nil {
return since, time.Time{}, hasSince, false, err
}
until = parsed
hasUntil = true
}
if hasSince && hasUntil && since.After(until) {
return since, until, true, true, appsValidationParamError(untilName, "%s must be greater than or equal to %s", untilName, sinceName)
}
return since, until, hasSince, hasUntil, nil
}
func parseAppsTimeFlag(param, raw string, now time.Time) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, appsValidationParamError(param, "%s is required", param)
}
if d, ok := parseAppsRelativeDuration(raw); ok {
return now.Add(-d), nil
}
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return t, nil
}
for _, layout := range []string{
"2006-01-02",
"2006-01-02T15:04:05",
"2006-01-02T15:04:05.000",
} {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw)
}
func parseAppsRelativeDuration(s string) (time.Duration, bool) {
s = strings.TrimSpace(s)
if len(s) < 2 {
return 0, false
}
unit := s[len(s)-1]
number := s[:len(s)-1]
if number == "" {
return 0, false
}
seenDot := false
seenFractionDigit := false
for i := 0; i < len(number); i++ {
ch := number[i]
if ch == '.' {
if seenDot || i == 0 {
return 0, false
}
seenDot = true
continue
}
if ch < '0' || ch > '9' {
return 0, false
}
if seenDot {
seenFractionDigit = true
}
}
if seenDot && !seenFractionDigit {
return 0, false
}
n, err := strconv.ParseFloat(number, 64)
if err != nil || n <= 0 {
return 0, false
}
var unitDuration time.Duration
switch unit {
case 's':
unitDuration = time.Second
case 'm':
unitDuration = time.Minute
case 'h':
unitDuration = time.Hour
case 'd':
unitDuration = 24 * time.Hour
case 'w':
unitDuration = 7 * 24 * time.Hour
default:
return 0, false
}
const maxDuration = time.Duration(1<<63 - 1)
if n > float64(maxDuration)/float64(unitDuration) {
return 0, false
}
duration := time.Duration(n * float64(unitDuration))
if duration <= 0 {
return 0, false
}
return duration, true
}
func nsNumber(t time.Time) string {
return strconv.FormatInt(t.UnixNano(), 10)
}
func secNumber(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}

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