Compare commits

..

7 Commits

Author SHA1 Message Date
zhangheng.023
7eabb27272 fix: include hint in update JSON errors and explain skill-name charset 2026-06-26 13:01:37 +08:00
zhangheng.023
45399ca70f docs: document update --skills suite mode in lark-shared 2026-06-26 13:01:37 +08:00
zhangheng.023
9e2cac6b6c chore: gofmt align SyncResult struct comment 2026-06-26 13:01:36 +08:00
zhangheng.023
95ee561bf0 feat: add --skills flag to update for suite mode 2026-06-26 12:09:07 +08:00
zhangheng.023
eab2063131 feat: apply skills suite selection in SyncSkills 2026-06-26 12:04:12 +08:00
zhangheng.023
d592022b6e feat: add ParseSuiteSelection for --skills flag parsing 2026-06-26 12:02:07 +08:00
zhangheng.023
6d4f458683 feat: add SuiteSkills field to skills state 2026-06-26 12:01:01 +08:00
194 changed files with 1652 additions and 18910 deletions

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

@@ -86,10 +86,12 @@ func symArrow() string {
// UpdateOptions holds inputs for the update command.
type UpdateOptions struct {
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
Skills []string
SuiteProvided bool
}
// NewCmdUpdate creates the update command.
@@ -108,6 +110,7 @@ Detects the installation method automatically:
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.SuiteProvided = cmd.Flags().Changed("skills")
return updateRun(opts)
},
}
@@ -115,6 +118,8 @@ Use --check to only check for updates without installing.`,
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
cmd.Flags().StringSliceVar(&opts.Skills, "skills", nil,
"comma-separated lark skill names to install and remember (the suite); use --skills all to reset to all official skills")
cmdutil.SetRisk(cmd, "high-risk-write")
return cmd
@@ -125,6 +130,18 @@ func updateRun(opts *UpdateOptions) error {
cur := currentVersion()
updater := newUpdater()
// 早期格式校验:在任何网络/安装动作之前 fail-fast。
var suite *skillscheck.SuiteSelection
if opts.SuiteProvided {
parsed, err := skillscheck.ParseSuiteSelection(opts.Skills)
if err != nil {
return reportError(opts, io, "validation",
errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithHint("e.g. --skills lark-calendar,lark-im (or --skills all to reset)"))
}
suite = parsed
}
if !opts.Check {
updater.CleanupStaleFiles()
}
@@ -147,7 +164,10 @@ func updateRun(opts *UpdateOptions) error {
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, suite)
if err := suiteInputError(opts, io, skillsResult); err != nil {
return err
}
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -162,22 +182,26 @@ func updateRun(opts *UpdateOptions) error {
// 6. Execute update
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater)
return doManualUpdate(opts, io, cur, latest, detect, updater, suite)
}
return doNpmUpdate(opts, io, cur, latest, updater)
return doNpmUpdate(opts, io, cur, latest, updater, suite)
}
// --- Output helpers ---
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
// {ok:false, error:{type, message, hint?}} envelope to stdout and signals the
// typed error's exit code bare; human mode returns the typed error for the
// dispatcher to render. The hint is included only when the typed error carries
// one, so AI-agent/script consumers reading JSON get the same actionable
// guidance humans see on stderr.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
})
errObj := map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message}
if hint := typedErr.ProblemDetail().Hint; hint != "" {
errObj["hint"] = hint
}
output.PrintJson(io.Out, map[string]interface{}{"ok": false, "error": errObj})
return output.ErrBare(output.ExitCodeOf(typedErr))
}
return typedErr
@@ -207,8 +231,11 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
return nil
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater, suite *skillscheck.SuiteSelection) error {
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, suite)
if err := suiteInputError(opts, io, skillsResult); err != nil {
return err
}
reason := detect.ManualReason()
if opts.JSON {
@@ -231,7 +258,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
return nil
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater, suite *skillscheck.SuiteSelection) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
@@ -287,7 +314,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, suite)
if err := suiteInputError(opts, io, skillsResult); err != nil {
return err
}
if opts.JSON {
result := map[string]interface{}{
@@ -324,8 +354,8 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
if !force {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, suite *skillscheck.SuiteSelection) *skillscheck.SyncResult {
if !force && suite == nil {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
}
@@ -334,6 +364,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
Version: stateVersion,
Force: force,
Runner: updater,
Suite: suite,
})
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
@@ -341,6 +372,17 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
return result
}
// suiteInputError 把 suite 名字非法(InvalidInput)的 sync 结果映射为退出码 2 的 validation 错误。
// 返回 nil 表示不是输入错误,调用方继续正常输出流程。
func suiteInputError(opts *UpdateOptions, io *cmdutil.IOStreams, r *skillscheck.SyncResult) error {
if r == nil || !r.InvalidInput {
return nil
}
return reportError(opts, io, "validation",
errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", r.Err).
WithHint("use a valid official skill name; nothing was installed"))
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
// already-up-to-date branch, including any skills_action / skills_warning
// fields derived from skillsResult. When check is true, this is the pure
@@ -402,6 +444,9 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
}
if r != nil && len(r.Suite) > 0 {
env["skills_suite"] = r.Suite
}
}
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
@@ -434,4 +479,7 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
}
if r != nil && len(r.Suite) > 0 {
fmt.Fprintf(io.ErrOut, " Suite: %s (run `lark-cli update --skills all` to restore all)\n", strings.Join(r.Suite, ", "))
}
}

View File

@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"os/exec"
"reflect"
"strings"
"testing"
"time"
@@ -1006,7 +1007,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil)
if got != nil {
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
@@ -1027,7 +1028,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, nil)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
@@ -1039,7 +1040,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
@@ -1064,7 +1065,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
return r
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
@@ -1357,7 +1358,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
t.Cleanup(func() { syncSkills = origSync })
f, _, stderr := newTestFactory(t)
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, nil)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}
@@ -1594,3 +1595,179 @@ func containsString(values []string, target string) bool {
}
return false
}
// captureSyncSkills 替换 syncSkills,记录传入的 SyncOptions 并返回固定结果。
func captureSyncSkills(t *testing.T, result *skillscheck.SyncResult) *skillscheck.SyncOptions {
t.Helper()
var captured skillscheck.SyncOptions
orig := syncSkills
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
captured = opts
return result
}
t.Cleanup(func() { syncSkills = orig })
return &captured
}
func TestUpdate_SkillsFlagParsedIntoSuite(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--skills", "lark-calendar,lark-im"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" } // already up to date path
defer func() { currentVersion = origVersion }()
captured := captureSyncSkills(t, &skillscheck.SyncResult{
Action: "synced", Official: []string{"lark-calendar", "lark-im"},
Updated: []string{"lark-calendar", "lark-im"}, Suite: []string{"lark-calendar", "lark-im"},
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if captured.Suite == nil || captured.Suite.All {
t.Fatalf("captured.Suite = %#v, want explicit list", captured.Suite)
}
if !reflect.DeepEqual(captured.Suite.Skills, []string{"lark-calendar", "lark-im"}) {
t.Fatalf("captured.Suite.Skills = %#v, want [lark-calendar lark-im]", captured.Suite.Skills)
}
}
func TestUpdate_SkillsAllParsedAsReset(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--skills", "all"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
captured := captureSyncSkills(t, &skillscheck.SyncResult{Action: "synced"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if captured.Suite == nil || !captured.Suite.All {
t.Fatalf("captured.Suite = %#v, want All=true", captured.Suite)
}
}
func TestUpdate_InvalidSkillsFlag_JSONExit2(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--skills", "all,lark-im"})
// fetchLatest must NOT be called — validation happens first.
origFetch := fetchLatest
fetchLatest = func() (string, error) {
t.Fatal("fetchLatest called before --skills validation")
return "", nil
}
defer func() { fetchLatest = origFetch }()
err := cmd.Execute()
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
}
if !strings.Contains(stdout.String(), `"type": "validation"`) {
t.Fatalf("JSON output missing validation type: %s", stdout.String())
}
// spec §3.8: JSON validation errors must carry the actionable hint too.
if !strings.Contains(stdout.String(), `"hint"`) {
t.Fatalf("JSON output missing hint field: %s", stdout.String())
}
}
func TestUpdate_UnknownSkillResult_Exit2(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--skills", "lark-bogus"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
captureSyncSkills(t, &skillscheck.SyncResult{
Action: "failed", InvalidInput: true,
Err: errors.New("unknown skill(s) not in official list: lark-bogus"),
})
err := cmd.Execute()
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestUpdate_SkillsSuiteInJSONOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--skills", "lark-im"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
captureSyncSkills(t, &skillscheck.SyncResult{
Action: "synced", Official: []string{"lark-im"}, Updated: []string{"lark-im"},
Suite: []string{"lark-im"},
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if !strings.Contains(stdout.String(), `"skills_suite"`) {
t.Fatalf("JSON output missing skills_suite: %s", stdout.String())
}
}
func TestUpdate_SkillsFlagBypassesVersionEarlyReturn(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// State synced at the same version → without --skills this would skip sync.
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.0", UpdatedAt: "2026-06-26T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--skills", "lark-im"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
called := false
orig := syncSkills
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
called = true
return &skillscheck.SyncResult{Action: "synced", Suite: []string{"lark-im"}}
}
t.Cleanup(func() { syncSkills = orig })
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if !called {
t.Fatal("syncSkills not called — --skills must bypass the same-version early return")
}
}

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

@@ -27,6 +27,7 @@ type SkillsState struct {
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
SuiteSkills []string `json:"suite_skills,omitempty"`
UpdatedAt string `json:"updated_at"`
}

View File

@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
@@ -137,3 +138,57 @@ func TestReadSyncedVersionFromState(t *testing.T) {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
}
}
func TestSkillsStateSuiteRoundTrip(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.2.3",
SuiteSkills: []string{"lark-calendar", "lark-im"},
UpdatedAt: "2026-06-26T10:00:00Z",
}); err != nil {
t.Fatalf("WriteState() err = %v, want nil", err)
}
got, ok, err := ReadState()
if err != nil || !ok || got == nil {
t.Fatalf("ReadState() = (_, %v, %v), want readable state", ok, err)
}
if !reflect.DeepEqual(got.SuiteSkills, []string{"lark-calendar", "lark-im"}) {
t.Fatalf("SuiteSkills = %#v, want [lark-calendar lark-im]", got.SuiteSkills)
}
}
func TestSkillsStateSuiteBackCompat(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Old state file without suite_skills field.
old := `{"version":"1.0.0","official_skills":["lark-im"],"updated_at":"2026-01-01T00:00:00Z"}`
if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(old), 0o644); err != nil {
t.Fatal(err)
}
got, ok, err := ReadState()
if err != nil || !ok || got == nil {
t.Fatalf("ReadState() = (_, %v, %v), want readable", ok, err)
}
if got.SuiteSkills != nil {
t.Fatalf("SuiteSkills = %#v, want nil for old state without the field", got.SuiteSkills)
}
}
func TestWriteStateOmitsEmptySuite(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{Version: "1.0.0"}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "suite_skills") {
t.Fatalf("empty suite must be omitted from JSON, got: %s", raw)
}
}

View File

@@ -28,6 +28,56 @@ type SyncInput struct {
Force bool
}
// SuiteSelection 表示本次调用通过 --skills 传入的 suite 选择。
// All 为 true 表示 "--skills all"(重置为全部官方 skill);否则 Skills 为显式名单。
type SuiteSelection struct {
All bool
Skills []string
}
// ParseSuiteSelection 解析 --skills 的原始值,只做格式校验(不校验名字是否是真实官方 skill)。
// 调用方仅在用户显式传入 --skills 时调用本函数。
func ParseSuiteSelection(rawNames []string) (*SuiteSelection, error) {
seen := map[string]bool{}
cleaned := []string{}
hasAll := false
for _, raw := range rawNames {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if strings.EqualFold(name, "all") {
hasAll = true
continue
}
if seen[name] {
continue
}
seen[name] = true
cleaned = append(cleaned, name)
}
if hasAll {
if len(cleaned) > 0 {
return nil, fmt.Errorf("--skills all cannot be combined with other skill names")
}
return &SuiteSelection{All: true}, nil
}
if len(cleaned) == 0 {
return nil, fmt.Errorf("--skills requires at least one skill name")
}
invalid := []string{}
for _, name := range cleaned {
if !skillNamePattern.MatchString(name) {
invalid = append(invalid, name)
}
}
if len(invalid) > 0 {
return nil, fmt.Errorf("invalid skill name(s): %s (skill names use only letters, digits, and _ : -)", strings.Join(invalid, ", "))
}
sort.Strings(cleaned)
return &SuiteSelection{Skills: cleaned}, nil
}
type SyncPlan struct {
Version string
OfficialSkills []string
@@ -259,6 +309,7 @@ type SyncOptions struct {
Force bool
Runner SkillsRunner
Now func() time.Time
Suite *SuiteSelection // nil = 本次未传 --skills(沿用 state 中的 sticky suite)
}
type SyncResult struct {
@@ -271,6 +322,31 @@ type SyncResult struct {
Err error
Detail string
Force bool
Suite []string // 生效的 suite(nil/空 = 全部模式)
InvalidInput bool // true 表示因用户输入非法(未知 skill 名)而失败 → 命令层映射为 exit 2
}
// resolveEffectiveSuite 决定本次实际生效的 suite。
// 返回 (suite 名单, suiteActive)。suiteActive=false 表示全部模式。
func resolveEffectiveSuite(optSuite *SuiteSelection, previous *SkillsState, readable bool) ([]string, bool) {
if optSuite != nil {
if optSuite.All {
return nil, false // 显式重置为全部
}
return optSuite.Skills, true
}
if readable && previous != nil && len(previous.SuiteSkills) > 0 {
return previous.SuiteSkills, true // 沿用 sticky suite
}
return nil, false
}
// suiteSkillsForState 返回写入 state 的 SuiteSkills(全部模式时为 nil,使其被 omitempty 省略/清空)。
func suiteSkillsForState(active bool, suite []string) []string {
if !active {
return nil
}
return suite
}
func SyncSkills(opts SyncOptions) *SyncResult {
@@ -281,25 +357,67 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
// --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
// --- Step 3: Read previous state ---
// 先读 previous state——解析 sticky suite 需要它,且即便后续官方列表失败也要能判断是否处于 suite 模式。
previous, readable, err := ReadState()
if err != nil {
readable = false
previous = nil
}
effectiveSuite, suiteActive := resolveEffectiveSuite(opts.Suite, previous, readable)
// --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
if suiteActive {
// suite 模式绝不 fallback 装全部(会违背"只要子集"的意图)。
return &SyncResult{
Action: "failed",
Err: fmt.Errorf("cannot apply skills suite: official skills list unavailable (%s)", reason),
Detail: reason,
Force: opts.Force,
Suite: effectiveSuite,
}
}
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 1.5: suite 模式下校验名字 + 收窄官方集合 ---
if suiteActive {
officialSet := toSet(official)
unknown := []string{}
for _, name := range effectiveSuite {
if !officialSet[name] {
unknown = append(unknown, name)
}
}
if len(unknown) > 0 {
return &SyncResult{
Action: "failed",
InvalidInput: true,
Err: fmt.Errorf("unknown skill(s) not in official list: %s", strings.Join(unknown, ", ")),
Force: opts.Force,
Suite: effectiveSuite,
}
}
official = intersection(official, toSet(effectiveSuite))
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
if suiteActive {
return &SyncResult{
Action: "failed",
Err: fmt.Errorf("cannot apply skills suite: local skills list unavailable"),
Force: opts.Force,
Suite: effectiveSuite,
}
}
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
// --- Step 3: Plan (previous state already read above) ---
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
@@ -309,32 +427,49 @@ func SyncSkills(opts SyncOptions) *SyncResult {
Force: opts.Force,
})
toInstall := plan.ToUpdate
// suite 模式:若增量计算出"无需更新",仍要确保 suite 被安装(用户显式要这些 skill)。
if suiteActive && len(toInstall) == 0 {
toInstall = official
}
result := &SyncResult{
Action: "synced",
Official: plan.OfficialSkills,
Updated: plan.ToUpdate,
Updated: toInstall,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
Suite: suiteSkillsForState(suiteActive, effectiveSuite),
}
if len(plan.ToUpdate) == 0 {
if len(toInstall) == 0 {
// 仅非 suite 模式才会到这里。
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(installResult), official)
installResult := opts.Runner.InstallSkill(toInstall)
if installResult == nil || installResult.Err != nil {
if suiteActive {
// suite 模式安装失败也不 fallback 装全部。
return &SyncResult{
Action: "failed",
Err: fmt.Errorf("skills suite install failed: %s", resultDetail(installResult)),
Detail: resultDetail(installResult),
Force: opts.Force,
Suite: effectiveSuite,
}
}
return fallbackFullInstall(opts, resultDetail(installResult), official)
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
UpdatedSkills: toInstall,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
SuiteSkills: suiteSkillsForState(suiteActive, effectiveSuite),
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {

View File

@@ -853,3 +853,190 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
}
}
func TestParseSuiteSelection(t *testing.T) {
tests := []struct {
name string
input []string
wantAll bool
wantList []string
wantErr string // substring; "" means no error
}{
{name: "explicit list", input: []string{"lark-calendar", "lark-im"}, wantList: []string{"lark-calendar", "lark-im"}},
{name: "trim and dedup and sort", input: []string{" lark-im ", "lark-im", "lark-calendar"}, wantList: []string{"lark-calendar", "lark-im"}},
{name: "all keyword", input: []string{"all"}, wantAll: true},
{name: "all case insensitive", input: []string{"ALL"}, wantAll: true},
{name: "all mixed", input: []string{"all", "lark-im"}, wantErr: "cannot be combined"},
{name: "empty", input: []string{"", " "}, wantErr: "at least one"},
{name: "invalid name", input: []string{"bad name"}, wantErr: "invalid skill name"},
{name: "invalid name explains charset", input: []string{"bad name"}, wantErr: "letters, digits, and _ : -"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSuiteSelection(tt.input)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ParseSuiteSelection(%v) err = %v, want substring %q", tt.input, err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("ParseSuiteSelection(%v) err = %v, want nil", tt.input, err)
}
if got.All != tt.wantAll {
t.Fatalf("All = %v, want %v", got.All, tt.wantAll)
}
if !tt.wantAll && !reflect.DeepEqual(got.Skills, tt.wantList) {
t.Fatalf("Skills = %#v, want %#v", got.Skills, tt.wantList)
}
})
}
}
func TestSyncSkills_SuiteNarrowsAndPersists(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im", "lark-doc"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-im", "lark-doc"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: time.Now,
Suite: &SuiteSelection{Skills: []string{"lark-calendar", "lark-im"}},
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
// Only suite skills installed, never the full set.
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-im"})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (suite must not full-install)", runner.installedAll)
}
assertStrings(t, result.Suite, []string{"lark-calendar", "lark-im"})
st, ok, err := ReadState()
if err != nil || !ok {
t.Fatalf("ReadState() = (_, %v, %v), want readable", ok, err)
}
assertStrings(t, st.SuiteSkills, []string{"lark-calendar", "lark-im"})
assertStrings(t, st.OfficialSkills, []string{"lark-calendar", "lark-im"})
}
func TestSyncSkills_StickySuiteWhenFlagAbsent(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Previous run set a sticky suite.
if err := WriteState(SkillsState{
Version: "1.0.32",
OfficialSkills: []string{"lark-calendar"},
SuiteSkills: []string{"lark-calendar"},
UpdatedAt: "2026-06-26T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im", "lark-doc"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
// No Suite in opts → must reuse sticky {lark-calendar}, NOT install all.
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v", result.Err)
}
assertStrings(t, result.Suite, []string{"lark-calendar"})
assertStrings(t, suiteState(t).SuiteSkills, []string{"lark-calendar"})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
for _, batch := range runner.installed {
for _, name := range batch {
if name != "lark-calendar" {
t.Fatalf("installed %q, want only lark-calendar (sticky suite)", name)
}
}
}
}
func TestSyncSkills_AllResetsSuite(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.32",
SuiteSkills: []string{"lark-calendar"},
UpdatedAt: "2026-06-26T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-im"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33", Runner: runner, Now: time.Now,
Suite: &SuiteSelection{All: true},
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v", result.Err)
}
if len(result.Suite) != 0 {
t.Fatalf("result.Suite = %#v, want empty after --skills all", result.Suite)
}
if got := suiteState(t).SuiteSkills; len(got) != 0 {
t.Fatalf("state.SuiteSkills = %#v, want cleared after --skills all", got)
}
}
func TestSyncSkills_UnknownSuiteNameFailsWithoutInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33", Runner: runner, Now: time.Now,
Suite: &SuiteSelection{Skills: []string{"lark-bogus"}},
})
if result.Err == nil || !result.InvalidInput {
t.Fatalf("result = %#v, want InvalidInput error for unknown skill", result)
}
if !strings.Contains(result.Err.Error(), "lark-bogus") {
t.Fatalf("err = %v, want mention of lark-bogus", result.Err)
}
if len(runner.installed) != 0 || runner.installedAll != 0 {
t.Fatalf("installed=%v installedAll=%d, want nothing installed", runner.installed, runner.installedAll)
}
}
func TestSyncSkills_SuiteNoFallbackWhenOfficialUnavailable(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("network down"),
officialErr: fmt.Errorf("network down"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33", Runner: runner, Now: time.Now,
Suite: &SuiteSelection{Skills: []string{"lark-calendar"}},
})
if result.Err == nil {
t.Fatalf("result.Err = nil, want error when official list unavailable in suite mode")
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (suite must never fall back to full install)", runner.installedAll)
}
}
// suiteState is a small helper to read state in assertions.
func suiteState(t *testing.T) *SkillsState {
t.Helper()
s, ok, err := ReadState()
if err != nil || !ok {
t.Fatalf("ReadState() = (_, %v, %v), want readable", ok, err)
}
return s
}

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"},
{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"},
{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,191 +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 := 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, 10*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
case "success", "applied", "migrated":
return true, nil
case "failed":
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
}
return false, nil
})
if perr != nil {
return perr
}
if n := intFromAny(final["changes_applied"]); n > 0 {
applied = n
}
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
})
return nil
},
}
type migrationChange struct {
Type string `json:"type"`
Table string `json:"table"`
Statement string `json:"statement"`
}
// projectMigrationChanges 把服务端原始变更项投影为白名单 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下游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,7 +205,7 @@ 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,
}
}
@@ -441,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)
@@ -548,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":
@@ -563,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, 30*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
case "success", "restored", "ready":
return true, nil
case "failed":
msg := common.GetString(d, "error_message")
if msg == "" {
msg = fmt.Sprintf("recovery to %s failed", target)
}
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
}
return false, nil
})
if perr != nil {
return perr
}
stop()
out := map[string]interface{}{"status": "restored", "target": target}
if n := intFromAny(final["restore_time_sec"]); n > 0 {
out["restore_time_sec"] = n
}
rctx.OutFormat(out, nil, func(w io.Writer) {
if n, ok := out["restore_time_sec"].(int); ok {
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
} else {
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
}
})
return nil
},
}
// runRecoveryPreview 触发 PITR 预览dry_run=true拿 preview_request_id轮询 diff_status 至终态。
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
prid := common.GetString(submit, "preview_request_id")
if prid == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
}
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
case "success":
return true, nil
case "failed":
msg := common.GetString(d, "error_message")
if msg == "" {
msg = "recovery preview failed"
}
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
}
return false, nil
})
}
type recoveryChange struct {
Table string `json:"table"`
Inserted interface{} `json:"inserted,omitempty"`
Deleted interface{} `json:"deleted,omitempty"`
Action string `json:"action,omitempty"`
DroppedAt string `json:"dropped_at,omitempty"`
}
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
changes := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
changes = append(changes, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
Action: common.GetString(m, "action"),
DroppedAt: common.GetString(m, "dropped_at"),
})
}
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": tablesAffected,
"changes": changes, "estimated_seconds": est,
}
}
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
changes, _ := out["changes"].([]recoveryChange)
if len(changes) == 0 {
io.WriteString(w, "No changes — database is already at this state.\n")
return
}
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
for _, c := range changes {
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
}
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
}
// 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,7 +80,8 @@ 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, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
}
@@ -116,16 +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 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.
@@ -160,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
@@ -219,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,36 +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_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,122 +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
}
_, 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"},
{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"},
{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,206 +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)
}
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
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"
}
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,179 +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"
)
// 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 当文件名)。
if putCD != `attachment; filename="logo.png"` {
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
}
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)
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
)
func requireAppsValidationParam(t *testing.T, err error, want string) *errs.Problem {
t.Helper()
p := requireAppsValidationProblem(t, err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error with param %q, got %T: %v", want, err, err)
}
if validationErr.Param != want {
t.Fatalf("param = %q, want %s", validationErr.Param, want)
}
return p
}
func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) {
if err := validateObservabilityEnv(""); err != nil {
t.Fatalf("empty env should default/pass as online: %v", err)
}
if err := validateObservabilityEnv("online"); err != nil {
t.Fatalf("online should pass: %v", err)
}
err := validateObservabilityEnv("dev")
p := requireAppsValidationParam(t, err, "--environment")
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %#v, want invalid_argument param --environment", p)
}
if !strings.Contains(p.Hint, "only online is supported") {
t.Fatalf("hint = %q, want only-online guidance", p.Hint)
}
}
func TestAppsObservabilityPageSizeRange(t *testing.T) {
for _, n := range []int{1, 50, 100} {
if err := validateAppsPageSize(n); err != nil {
t.Fatalf("page size %d should pass: %v", n, err)
}
}
for _, n := range []int{0, 101} {
err := validateAppsPageSize(n)
requireAppsValidationParam(t, err, "--page-size")
}
}
func TestAppsObservabilityCommonHelpers(t *testing.T) {
if got := appScopedPath("app/x", "observability/logs"); got != "/open-apis/spark/v1/apps/app%2Fx/observability/logs" {
t.Fatalf("appScopedPath = %q", got)
}
for _, env := range []string{"dev", "online"} {
if err := validateEnvVarEnv(env); err != nil {
t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err)
}
}
requireAppsValidationParam(t, validateEnvVarEnv(""), "--environment")
requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--environment")
got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"})
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("cleanRepeatedStrings len=%d, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("cleanRepeatedStrings[%d]=%q, want %q", i, got[i], want[i])
}
}
ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC)
if got := nsNumber(ts); got != "1782209472123456789" {
t.Fatalf("nsNumber = %q", got)
}
if got := secNumber(ts); got != "1782209472" {
t.Fatalf("secNumber = %q", got)
}
}
func TestParseAppsTimeAcceptsSupportedInputs(t *testing.T) {
now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.Local)
cases := []struct {
raw string
want time.Time
wantOffset *int
}{
{raw: "30s", want: now.Add(-30 * time.Second)},
{raw: "5m", want: now.Add(-5 * time.Minute)},
{raw: "2h", want: now.Add(-2 * time.Hour)},
{raw: "1.5h", want: now.Add(-90 * time.Minute)},
{raw: "0.5d", want: now.Add(-12 * time.Hour)},
{raw: "3d", want: now.Add(-72 * time.Hour)},
{raw: "1w", want: now.Add(-7 * 24 * time.Hour)},
{raw: "2026-06-23", want: time.Date(2026, 6, 23, 0, 0, 0, 0, time.Local)},
{raw: "2026-06-23T10:11:12", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.Local)},
{raw: "2026-06-23T10:11:12.123", want: time.Date(2026, 6, 23, 10, 11, 12, 123000000, time.Local)},
{raw: "2026-06-23T10:11:12Z", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.UTC), wantOffset: ptrInt(0)},
{raw: "2026-06-23T10:11:12+08:00", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.FixedZone("", 8*60*60)), wantOffset: ptrInt(8 * 60 * 60)},
}
for _, tc := range cases {
got, err := parseAppsTimeFlag("--since", tc.raw, now)
if err != nil {
t.Fatalf("parseAppsTimeFlag(%q) err=%v", tc.raw, err)
}
if !got.Equal(tc.want) {
t.Fatalf("parseAppsTimeFlag(%q)=%s, want %s", tc.raw, got.Format(time.RFC3339Nano), tc.want.Format(time.RFC3339Nano))
}
if tc.wantOffset != nil {
_, offset := got.Zone()
if offset != *tc.wantOffset {
t.Fatalf("parseAppsTimeFlag(%q) zone offset=%d, want %d", tc.raw, offset, *tc.wantOffset)
}
}
}
}
func TestParseAppsTimeRejectsUnsupportedInputs(t *testing.T) {
for _, in := range []string{"2026/06/23", "yesterday", "2026-06-23 10:11:12", "999999999999999999w", "2147483647w"} {
_, _, _, _, err := parseAppsTimeRange("--since", in, "--until", "")
requireAppsValidationParam(t, err, "--since")
}
}
func TestParseAppsTimeRangeRejectsSinceAfterUntil(t *testing.T) {
_, _, _, _, err := parseAppsTimeRange("--since", "2026-06-24", "--until", "2026-06-23")
requireAppsValidationParam(t, err, "--until")
}
func ptrInt(n int) *int {
return &n
}

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// API Key 端点 path 模板。前缀复用 apiBasePath = "/open-apis/spark/v1"(同包)。
const (
oapiKeyListPath = apiBasePath + "/apps/%s/oapi_apikeys" // GET(list) / POST(create)
oapiKeyItemPath = apiBasePath + "/apps/%s/oapi_apikeys/%s" // GET / PATCH / DELETE
oapiKeyRefreshPath = apiBasePath + "/apps/%s/oapi_apikeys/%s/refresh" // POST(reset)
)
// maskAPIKey 把原始 api_key 收敛为非敏感预览:末 4 位前缀 "****"。
// 空串或 <=4 位统一返回 "****"。
func maskAPIKey(s string) string {
if len(s) <= 4 {
return "****"
}
return "****" + s[len(s)-4:]
}
// redactKeyInfo 返回 app_open_api_key_info 的副本,剥离原始 api_key 并补 masked
// key_preview。非颁发命令list/get/update/enable/disable一律经此处理确保原始
// 密钥不从这些路径泄露。不修改入参。
func redactKeyInfo(info map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(info)+1)
for k, v := range info {
if k == "api_key" {
continue
}
out[k] = v
}
if raw, ok := info["api_key"].(string); ok {
out["key_preview"] = maskAPIKey(raw)
} else {
out["key_preview"] = "****"
}
return out
}
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo.
func parseScopeAPI(s string) (map[string]interface{}, error) {
fields := strings.Fields(strings.TrimSpace(s))
if len(fields) != 2 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s)
}
return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil
}
// buildRequestScope assembles config.request_scope (snake_case) from the scope flags.
// Returns (nil, nil) when no scope flag is set. Raw --scope is the escape hatch and
// is mutually exclusive with --scope-all / --scope-api.
func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (interface{}, error) {
scopeRaw = strings.TrimSpace(scopeRaw)
hasFriendly := scopeAll || len(scopeAPIs) > 0
if scopeRaw != "" {
if hasFriendly {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope")
}
var rs interface{}
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
return nil, err
}
return rs, nil
}
if !hasFriendly {
return nil, nil
}
rs := map[string]interface{}{"allow_all": scopeAll}
if len(scopeAPIs) > 0 {
infos := make([]interface{}, 0, len(scopeAPIs))
for _, a := range scopeAPIs {
info, err := parseScopeAPI(a)
if err != nil {
return nil, err
}
infos = append(infos, info)
}
rs["http_infos"] = infos
}
return rs, nil
}
// buildKeyConfig assembles the snake_case config object. Returns nil when nothing is set.
func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllowPreview, allowPreview bool) (map[string]interface{}, error) {
rs, err := buildRequestScope(scopeAll, scopeAPIs, scopeRaw)
if err != nil {
return nil, err
}
if rs == nil && !hasAllowPreview {
return nil, nil
}
cfg := map[string]interface{}{}
if rs != nil {
cfg["request_scope"] = rs
}
if hasAllowPreview {
cfg["is_allow_access_preview"] = allowPreview
}
return cfg, nil
}
// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update).
func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error {
scopeRaw := strings.TrimSpace(rctx.Str("scope"))
if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) {
return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api").
WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both")
}
if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) {
return appsValidationParamError("--scope", "--scope must be valid JSON").
WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'")
}
for _, a := range rctx.StrArray("scope-api") {
if len(strings.Fields(strings.TrimSpace(a))) != 2 {
return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a).
WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'")
}
}
return nil
}

View File

@@ -1,254 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"reflect"
"testing"
)
func TestMaskAPIKey(t *testing.T) {
cases := map[string]string{
"": "****",
"abcd": "****",
"xxxxxxxxxxxx": "****xxxx",
}
for in, want := range cases {
if got := maskAPIKey(in); got != want {
t.Errorf("maskAPIKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestRedactKeyInfo_StripsRawKey(t *testing.T) {
in := map[string]interface{}{
"api_key_id": "k1",
"api_key": "xxxxxxxxxxxx",
"name": "partner-test",
"status": float64(1),
}
out := redactKeyInfo(in)
if _, ok := out["api_key"]; ok {
t.Fatalf("redactKeyInfo must strip api_key, got %v", out)
}
if out["key_preview"] != "****xxxx" {
t.Errorf("key_preview = %v, want ****xxxx", out["key_preview"])
}
if out["name"] != "partner-test" || out["api_key_id"] != "k1" {
t.Errorf("non-secret fields must be preserved, got %v", out)
}
// input not mutated
if _, ok := in["api_key"]; !ok {
t.Errorf("redactKeyInfo must not mutate input")
}
}
func TestParseScopeAPI(t *testing.T) {
t.Run("valid", func(t *testing.T) {
info, err := parseScopeAPI("GET /openapi/v1/orders")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info["http_method"] != "GET" {
t.Errorf("http_method = %v, want GET", info["http_method"])
}
if info["http_path"] != "/openapi/v1/orders" {
t.Errorf("http_path = %v, want /openapi/v1/orders", info["http_path"])
}
})
t.Run("lowercase method uppercased", func(t *testing.T) {
info, err := parseScopeAPI("post /openapi/x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info["http_method"] != "POST" {
t.Errorf("http_method = %v, want POST", info["http_method"])
}
})
t.Run("too few fields", func(t *testing.T) {
if _, err := parseScopeAPI("GET"); err == nil {
t.Errorf("one-word input must error")
}
})
t.Run("too many fields", func(t *testing.T) {
if _, err := parseScopeAPI("GET /openapi/x extra"); err == nil {
t.Errorf("three-word input must error")
}
})
}
func TestBuildRequestScope(t *testing.T) {
t.Run("nothing set -> nil", func(t *testing.T) {
rs, err := buildRequestScope(false, nil, "")
if err != nil || rs != nil {
t.Fatalf("expected nil,nil got rs=%v err=%v", rs, err)
}
})
t.Run("scope-all only", func(t *testing.T) {
rs, err := buildRequestScope(true, nil, "")
if err != nil {
t.Fatalf("err = %v", err)
}
m := rs.(map[string]interface{})
if m["allow_all"] != true {
t.Errorf("allow_all = %v, want true", m["allow_all"])
}
if _, ok := m["http_infos"]; ok {
t.Errorf("http_infos should not appear when no scope-api provided")
}
})
t.Run("scope-api adds http_infos", func(t *testing.T) {
rs, err := buildRequestScope(false, []string{"GET /openapi/x"}, "")
if err != nil {
t.Fatalf("err = %v", err)
}
m := rs.(map[string]interface{})
if m["allow_all"] != false {
t.Errorf("allow_all = %v, want false", m["allow_all"])
}
infos := m["http_infos"].([]interface{})
if len(infos) != 1 {
t.Fatalf("http_infos len = %d, want 1", len(infos))
}
info := infos[0].(map[string]interface{})
if info["http_method"] != "GET" || info["http_path"] != "/openapi/x" {
t.Errorf("info = %v", info)
}
})
t.Run("raw scope passthrough", func(t *testing.T) {
rs, err := buildRequestScope(false, nil, `{"allow_all":true}`)
if err != nil {
t.Fatalf("err = %v", err)
}
m := rs.(map[string]interface{})
if m["allow_all"] != true {
t.Errorf("allow_all = %v, want true", m["allow_all"])
}
})
t.Run("raw + scope-all -> error", func(t *testing.T) {
if _, err := buildRequestScope(true, nil, `{"allow_all":true}`); err == nil {
t.Errorf("raw + scope-all must error")
}
})
t.Run("raw + scope-api -> error", func(t *testing.T) {
if _, err := buildRequestScope(false, []string{"GET /openapi/x"}, `{"allow_all":true}`); err == nil {
t.Errorf("raw + scope-api must error")
}
})
t.Run("invalid raw json -> error", func(t *testing.T) {
if _, err := buildRequestScope(false, nil, "{bad"); err == nil {
t.Errorf("invalid json must error")
}
})
}
func TestBuildKeyConfig(t *testing.T) {
t.Run("nothing set -> nil", func(t *testing.T) {
cfg, err := buildKeyConfig(false, nil, "", false, false)
if err != nil || cfg != nil {
t.Fatalf("empty -> nil, got cfg=%v err=%v", cfg, err)
}
})
t.Run("scope-all -> snake_case request_scope", func(t *testing.T) {
cfg, err := buildKeyConfig(true, nil, "", false, false)
if err != nil {
t.Fatalf("err = %v", err)
}
rs := cfg["request_scope"].(map[string]interface{})
if rs["allow_all"] != true {
t.Errorf("allow_all = %v, want true", rs["allow_all"])
}
if _, ok := cfg["is_allow_access_preview"]; ok {
t.Errorf("is_allow_access_preview should not appear")
}
})
t.Run("scope-api -> snake_case http_infos", func(t *testing.T) {
cfg, err := buildKeyConfig(false, []string{"GET /openapi/x"}, "", false, false)
if err != nil {
t.Fatalf("err = %v", err)
}
rs := cfg["request_scope"].(map[string]interface{})
if rs["allow_all"] != false {
t.Errorf("allow_all = %v, want false", rs["allow_all"])
}
infos := rs["http_infos"].([]interface{})
if len(infos) != 1 {
t.Fatalf("http_infos len = %d, want 1", len(infos))
}
info := infos[0].(map[string]interface{})
if info["http_method"] != "GET" || info["http_path"] != "/openapi/x" {
t.Errorf("info = %v", info)
}
})
t.Run("raw scope passthrough", func(t *testing.T) {
cfg, err := buildKeyConfig(false, nil, `{"allow_all":true}`, false, false)
if err != nil {
t.Fatalf("err = %v", err)
}
rs := cfg["request_scope"].(map[string]interface{})
if rs["allow_all"] != true {
t.Errorf("allow_all = %v", rs["allow_all"])
}
})
t.Run("allow-preview only -> is_allow_access_preview", func(t *testing.T) {
cfg, err := buildKeyConfig(false, nil, "", true, true)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := cfg["request_scope"]; ok {
t.Errorf("request_scope should not appear when not set")
}
if cfg["is_allow_access_preview"] != true {
t.Errorf("is_allow_access_preview = %v, want true", cfg["is_allow_access_preview"])
}
})
t.Run("scope-all + allow-preview -> both snake_case keys", func(t *testing.T) {
cfg, err := buildKeyConfig(true, nil, "", true, false)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := cfg["request_scope"]; !ok {
t.Errorf("request_scope missing")
}
if cfg["is_allow_access_preview"] != false {
t.Errorf("is_allow_access_preview = %v, want false", cfg["is_allow_access_preview"])
}
// ensure no camelCase keys
if _, ok := cfg["requestScope"]; ok {
t.Errorf("found camelCase key requestScope — must use snake_case")
}
if _, ok := cfg["isAllowAccessPreview"]; ok {
t.Errorf("found camelCase key isAllowAccessPreview — must use snake_case")
}
})
t.Run("raw + scope-all -> error", func(t *testing.T) {
if _, err := buildKeyConfig(true, nil, `{"allow_all":true}`, false, false); err == nil {
t.Errorf("raw + scope-all must error")
}
})
t.Run("invalid json -> error", func(t *testing.T) {
if _, err := buildKeyConfig(false, nil, "{bad", false, false); err == nil {
t.Errorf("invalid json must error")
}
})
t.Run("no camelCase keys emitted", func(t *testing.T) {
cfg, err := buildKeyConfig(false, []string{"GET /openapi/x"}, "", true, true)
if err != nil {
t.Fatalf("err = %v", err)
}
if _, ok := cfg["requestScope"]; ok {
t.Errorf("camelCase requestScope must not appear")
}
if _, ok := cfg["isAllowAccessPreview"]; ok {
t.Errorf("camelCase isAllowAccessPreview must not appear")
}
rs := cfg["request_scope"].(map[string]interface{})
infos := rs["http_infos"].([]interface{})
info := infos[0].(map[string]interface{})
wantInfo := map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"}
if !reflect.DeepEqual(info, wantInfo) {
t.Errorf("info = %v, want %v", info, wantInfo)
}
})
}

View File

@@ -1,110 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsOpenAPIKeyCreate creates an open API key. The raw secret is returned ONCE.
var AppsOpenAPIKeyCreate = common.Shortcut{
Service: appsService,
Command: "+openapi-key-create",
Description: "Create an open API key (returns the raw secret once)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +openapi-key-create --app-id <app_id> --name partner-test",
"Example: lark-cli apps +openapi-key-create --app-id <app_id> --name orders-readonly --scope-api 'GET /openapi/orders'",
"Example: lark-cli apps +openapi-key-create --app-id <app_id> --name full-access --scope-all",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "name", Desc: "API key name", Required: true},
{Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"},
{Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"},
{Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"},
{Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := oapiKeyValidateAppID(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required").
WithHint("provide a human-readable key name, e.g. --name partner-readonly")
}
return oapiKeyValidateScopeFlags(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
body, _ := buildOpenAPIKeyCreateBody(rctx)
return common.NewDryRunAPI().
POST(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))).
Desc("Create open API key").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
body, err := buildOpenAPIKeyCreateBody(rctx)
if err != nil {
return appsValidationParamError("--scope", "invalid scope: %v", err).
WithHint("--scope must be valid JSON for config.request_scope; or use --scope-all / --scope-api")
}
path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("POST", path, nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
return outputIssuedKey(rctx, data)
},
}
// buildOpenAPIKeyCreateBody builds {name, config?}.
func buildOpenAPIKeyCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{"name": strings.TrimSpace(rctx.Str("name"))}
cfg, err := buildKeyConfig(rctx.Bool("scope-all"), rctx.StrArray("scope-api"), rctx.Str("scope"), rctx.Changed("allow-preview"), rctx.Bool("allow-preview"))
if err != nil {
return nil, err
}
if cfg != nil {
body["config"] = cfg
}
return body, nil
}
// outputIssuedKey emits {api_key_id, api_key(raw, once), info(redacted)} for
// create/reset, plus a one-time stderr warning. The raw secret is NEVER persisted.
func outputIssuedKey(rctx *common.RuntimeContext, data map[string]interface{}) error {
info := common.GetMap(data, "info")
raw := common.GetString(info, "api_key")
if raw == "" {
raw = common.GetString(data, "api_key") // reset returns top-level api_key
}
out := map[string]interface{}{
"api_key_id": firstNonEmpty(common.GetString(data, "api_key_id"), common.GetString(info, "api_key_id")),
"api_key": raw,
"info": redactKeyInfo(info),
}
fmt.Fprintln(rctx.IO().ErrOut, "warning: this api_key is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.")
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "API key ID: %v\nAPI key: %v (shown once)\n", out["api_key_id"], raw)
})
return nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// createFlagDefs returns the flag type map for +openapi-key-create tests.
func createFlagDefs() map[string]string {
return map[string]string{
"app-id": "string",
"name": "string",
"scope-all": "bool",
"scope-api": "string_array",
"scope": "string",
"allow-preview": "bool",
}
}
func TestOpenAPIKeyCreateExecute_ReturnsRawOnce(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
createFlagDefs(),
map[string]string{"app-id": "app_x", "name": "partner-test"})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{
"api_key_id": "k1",
"info": map[string]interface{}{
"api_key_id": "k1", "name": "partner-test",
"api_key": "xxxxxxxxxxxx", "status": float64(1),
},
},
},
})
if err := AppsOpenAPIKeyCreate.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
// create surfaces the raw secret ONCE at top-level api_key
if !strings.Contains(out, "xxxxxxxxxxxx") {
t.Fatalf("create must surface raw api_key once: %s", out)
}
// nested info must be redacted — raw key appears exactly once (top-level only)
if strings.Count(out, "xxxxxxxxxxxx") != 1 {
t.Errorf("raw key must appear exactly once (top-level only): %s", out)
}
if !strings.Contains(out, "****xxxx") {
t.Errorf("redacted info must carry key_preview: %s", out)
}
}
func TestOpenAPIKeyCreate_MissingName(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
createFlagDefs(),
map[string]string{"app-id": "app_x"})
if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil {
t.Errorf("missing --name must fail validation")
}
}
func TestOpenAPIKeyCreate_InvalidScope(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
createFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "scope": "{bad"})
if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil {
t.Errorf("invalid --scope json must fail validation")
}
}
func TestOpenAPIKeyCreate_ScopeRawAndFriendlyMutuallyExclusive(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
createFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "scope": `{"allowAll":true}`, "scope-all": "true"})
if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil {
t.Errorf("--scope + --scope-all must fail validation")
}
}

View File

@@ -1,47 +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"
)
// AppsOpenAPIKeyDelete permanently deletes an open API key (irreversible).
var AppsOpenAPIKeyDelete = common.Shortcut{
Service: appsService,
Command: "+openapi-key-delete",
Description: "Delete an open API key (irreversible; prefer +openapi-key-disable)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +openapi-key-delete --app-id <app_id> --key-id <key_id> --yes",
"Preview: add --dry-run to see the request without deleting",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "key-id", Desc: "API key ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().DELETE(oapiKeyItemURL(rctx)).Desc("Delete open API key")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
id := strings.TrimSpace(rctx.Str("key-id"))
if _, err := rctx.CallAPITyped("DELETE", oapiKeyItemURL(rctx), nil, nil); err != nil {
return withAppsHint(err, oapiKeyNotFoundHint(rctx))
}
out := map[string]interface{}{"api_key_id": id, "deleted": true}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "deleted API key ID: %s\n", id)
})
return nil
},
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestOpenAPIKeyDeleteMeta_HighRisk(t *testing.T) {
if AppsOpenAPIKeyDelete.Risk != "high-risk-write" {
t.Errorf("delete must be high-risk-write, got %q", AppsOpenAPIKeyDelete.Risk)
}
}
func TestOpenAPIKeyDeleteExecute(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "key-id": "string", "yes": "bool"},
map[string]string{"app-id": "app_x", "key-id": "1", "yes": "true"})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1",
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{}},
})
if err := AppsOpenAPIKeyDelete.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "\"deleted\"") && !strings.Contains(stdoutBuf.String(), "deleted") {
t.Errorf("expected deleted marker: %s", stdoutBuf.String())
}
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsOpenAPIKeyDisable disables (status=0) an open API key — the minimal safety brake.
var AppsOpenAPIKeyDisable = common.Shortcut{
Service: appsService,
Command: "+openapi-key-disable",
Description: "Disable an open API key (minimal safety brake)",
Risk: "write",
Tips: []string{"Example: lark-cli apps +openapi-key-disable --app-id <app_id> --key-id <key_id>"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "key-id", Desc: "API key ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Disable open API key").Body(openAPIKeyStatusBody(keyStatusDisable))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
return execOpenAPIKeyStatus(rctx, keyStatusDisable)
},
}

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// app_open_api_key_status enum: 0=DISABLE, 1=ENABLE.
const (
keyStatusDisable = 0
keyStatusEnable = 1
)
// AppsOpenAPIKeyEnable enables (status=1) an open API key.
var AppsOpenAPIKeyEnable = common.Shortcut{
Service: appsService,
Command: "+openapi-key-enable",
Description: "Enable an open API key",
Risk: "write",
Tips: []string{"Example: lark-cli apps +openapi-key-enable --app-id <app_id> --key-id <key_id>"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "key-id", Desc: "API key ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Enable open API key").Body(openAPIKeyStatusBody(keyStatusEnable))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
return execOpenAPIKeyStatus(rctx, keyStatusEnable)
},
}
// openAPIKeyStatusBody builds the PATCH body for a status change.
func openAPIKeyStatusBody(status int) map[string]interface{} {
return map[string]interface{}{"status": status}
}
// execOpenAPIKeyStatus PATCHes status and prints the redacted info.
func execOpenAPIKeyStatus(rctx *common.RuntimeContext, status int) error {
data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, openAPIKeyStatusBody(status))
if err != nil {
return withAppsHint(err, oapiKeyNotFoundHint(rctx))
}
return outputRedactedInfo(rctx, data)
}

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsOpenAPIKeyGet returns one open API key's detail (redacted).
var AppsOpenAPIKeyGet = common.Shortcut{
Service: appsService,
Command: "+openapi-key-get",
Description: "Get an open API key detail (secret redacted)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +openapi-key-get --app-id <app_id> --key-id <key_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "key-id", Desc: "API key ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return oapiKeyValidateKeyID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(oapiKeyItemURL(rctx)).
Desc("Get open API key detail")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", oapiKeyItemURL(rctx), nil, nil)
if err != nil {
return withAppsHint(err, oapiKeyNotFoundHint(rctx))
}
return outputRedactedInfo(rctx, data)
},
}
// oapiKeyItemURL builds the per-key item path from --app-id / --key-id.
func oapiKeyItemURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(oapiKeyItemPath,
validate.EncodePathSegment(strings.TrimSpace(rctx.Str("app-id"))),
validate.EncodePathSegment(strings.TrimSpace(rctx.Str("key-id"))))
}
// oapiKeyNotFoundHint points a failed per-key call at +openapi-key-list.
func oapiKeyNotFoundHint(rctx *common.RuntimeContext) string {
return "verify --key-id; list keys with `lark-cli apps +openapi-key-list --app-id " +
strings.TrimSpace(rctx.Str("app-id")) + "`"
}
// outputRedactedInfo emits {info: <redacted>} for get/update/enable/disable.
func outputRedactedInfo(rctx *common.RuntimeContext, data map[string]interface{}) error {
info := common.GetMap(data, "info")
red := redactKeyInfo(info)
out := map[string]interface{}{"info": red}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "API key ID: %v\nname: %v\nstatus: %v\nkey_preview: %v\n",
red["api_key_id"], red["name"], red["status"], red["key_preview"])
})
return nil
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestOpenAPIKeyGetExecute_Redacts(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "key-id": "string"},
map[string]string{"app-id": "app_x", "key-id": "1"})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{
"info": map[string]interface{}{
"api_key_id": "k1", "name": "partner-test",
"api_key": "xxxxxxxxxxxx", "status": float64(1),
},
},
},
})
if err := AppsOpenAPIKeyGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") {
t.Fatalf("get output leaked raw api key: %s", stdoutBuf.String())
}
if !strings.Contains(stdoutBuf.String(), "****xxxx") {
t.Errorf("expected key_preview: %s", stdoutBuf.String())
}
}
func TestOpenAPIKeyGetExecute_MissingKeyID(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "key-id": "string"},
map[string]string{"app-id": "app_x"})
if err := AppsOpenAPIKeyGet.Validate(context.Background(), rctx); err == nil {
t.Errorf("missing --key-id must fail validation")
}
}

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsOpenAPIKeyList lists an app's open API keys (redacted; raw secret never shown).
var AppsOpenAPIKeyList = common.Shortcut{
Service: appsService,
Command: "+openapi-key-list",
Description: "List an app's open API keys (secrets redacted)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +openapi-key-list --app-id <app_id>",
"Example: lark-cli apps +openapi-key-list --app-id <app_id> --limit 10",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "limit", Type: "int", Desc: "page size (server default if omitted)"},
{Name: "offset", Type: "int", Desc: "page offset"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return oapiKeyValidateAppID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))).
Desc("List open API keys").
Params(buildOpenAPIKeyListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, buildOpenAPIKeyListParams(rctx), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
infos := common.GetSlice(data, "infos")
redacted := make([]interface{}, 0, len(infos))
for _, it := range infos {
if m, ok := it.(map[string]interface{}); ok {
redacted = append(redacted, redactKeyInfo(m))
} else {
redacted = append(redacted, it)
}
}
out := map[string]interface{}{"infos": redacted}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d key(s)\n", len(redacted))
for _, it := range redacted {
if m, ok := it.(map[string]interface{}); ok {
fmt.Fprintf(w, "- %v %v %v\n", m["api_key_id"], m["name"], m["key_preview"])
}
}
})
return nil
},
}
// buildOpenAPIKeyListParams builds the optional limit/offset query params.
func buildOpenAPIKeyListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if rctx.Changed("limit") {
params["limit"] = rctx.Int("limit")
}
if rctx.Changed("offset") {
params["offset"] = rctx.Int("offset")
}
return params
}
// oapiKeyValidateAppID validates --app-id presence. Shared by all openapi-key commands.
func oapiKeyValidateAppID(rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required").
WithHint("list your apps with `lark-cli apps +list`")
}
return nil
}
// oapiKeyValidateKeyID validates --app-id and --key-id presence.
func oapiKeyValidateKeyID(rctx *common.RuntimeContext) error {
if err := oapiKeyValidateAppID(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("key-id")) == "" {
return appsValidationParamError("--key-id", "--key-id is required").
WithHint("find key ids with `lark-cli apps +openapi-key-list --app-id <app_id>`")
}
return nil
}

View File

@@ -1,91 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newOpenAPIKeyRCtx 构造带指定 flag 的 RuntimeContext。flags 是 name->value
// bool flag 传 "true"/"false"。被本组所有命令测试复用。
func newOpenAPIKeyRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-openapi-key"}
cmd.SetContext(context.Background())
for name, typ := range flagDefs {
switch typ {
case "bool":
cmd.Flags().Bool(name, false, "")
case "int":
cmd.Flags().Int(name, 0, "")
case "string_array":
cmd.Flags().StringArray(name, nil, "")
default:
cmd.Flags().String(name, "", "")
}
}
for name, val := range flags {
_ = cmd.Flags().Set(name, val)
}
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func TestOpenAPIKeyListMeta(t *testing.T) {
if AppsOpenAPIKeyList.Command != "+openapi-key-list" || AppsOpenAPIKeyList.Risk != "read" {
t.Errorf("meta mismatch: %+v", AppsOpenAPIKeyList)
}
if len(AppsOpenAPIKeyList.Scopes) != 1 || AppsOpenAPIKeyList.Scopes[0] != "spark:app:read" {
t.Errorf("scopes = %v", AppsOpenAPIKeyList.Scopes)
}
}
func TestOpenAPIKeyListExecute_Redacts(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "limit": "int", "offset": "int"},
map[string]string{"app-id": "app_x"})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{
"infos": []interface{}{
map[string]interface{}{
"api_key_id": "k1", "name": "partner-test",
"api_key": "xxxxxxxxxxxx", "status": float64(1),
},
},
},
},
})
if err := AppsOpenAPIKeyList.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if strings.Contains(out, "xxxxxxxxxxxx") {
t.Fatalf("list output leaked raw api key: %s", out)
}
if !strings.Contains(out, "****xxxx") {
t.Errorf("expected masked key_preview in output: %s", out)
}
_ = json.Valid
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsOpenAPIKeyReset rotates (refreshes) an open API key, returning a new raw secret ONCE.
var AppsOpenAPIKeyReset = common.Shortcut{
Service: appsService,
Command: "+openapi-key-reset",
Description: "Reset (rotate) an open API key; returns a new raw secret once",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +openapi-key-reset --app-id <app_id> --key-id <key_id> --yes",
"Preview: add --dry-run to see the request without rotating",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "key-id", Desc: "API key ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) },
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().POST(oapiKeyRefreshURL(rctx)).Desc("Reset (rotate) open API key")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", oapiKeyRefreshURL(rctx), nil, nil)
if err != nil {
return withAppsHint(err, oapiKeyNotFoundHint(rctx))
}
return outputIssuedKey(rctx, data)
},
}
// oapiKeyRefreshURL builds the refresh path from --app-id / --key-id.
func oapiKeyRefreshURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(oapiKeyRefreshPath,
validate.EncodePathSegment(strings.TrimSpace(rctx.Str("app-id"))),
validate.EncodePathSegment(strings.TrimSpace(rctx.Str("key-id"))))
}

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestOpenAPIKeyResetMeta_HighRisk(t *testing.T) {
if AppsOpenAPIKeyReset.Risk != "high-risk-write" {
t.Errorf("reset must be high-risk-write, got %q", AppsOpenAPIKeyReset.Risk)
}
}
func TestOpenAPIKeyResetExecute_ReturnsNewRaw(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "key-id": "string", "yes": "bool"},
map[string]string{"app-id": "app_x", "key-id": "1", "yes": "true"})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1/refresh",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{
"api_key": "xxxxxxxxxxxx",
"info": map[string]interface{}{"api_key_id": "k1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)},
},
},
})
if err := AppsOpenAPIKeyReset.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if !strings.Contains(out, "xxxxxxxxxxxx") {
t.Fatalf("reset must surface the new raw secret once: %s", out)
}
if strings.Count(out, "xxxxxxxxxxxx") != 1 {
t.Errorf("raw key must appear exactly once (top-level only, info must be redacted): %s", out)
}
if !strings.Contains(out, "****xxxx") {
t.Errorf("redacted info must carry key_preview: %s", out)
}
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestOpenAPIKeyEnableExecute_StatusOne(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "key-id": "string"},
map[string]string{"app-id": "app_x", "key-id": "1"})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{
"info": map[string]interface{}{"api_key_id": "k1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)},
},
},
})
if err := AppsOpenAPIKeyEnable.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") {
t.Fatalf("enable leaked raw api_key")
}
}
func TestOpenAPIKeyStatusBody(t *testing.T) {
if b := openAPIKeyStatusBody(1); b["status"] != 1 {
t.Errorf("enable body = %v", b)
}
if b := openAPIKeyStatusBody(0); b["status"] != 0 {
t.Errorf("disable body = %v", b)
}
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsOpenAPIKeyUpdate updates an open API key's name and/or config (not status).
var AppsOpenAPIKeyUpdate = common.Shortcut{
Service: appsService,
Command: "+openapi-key-update",
Description: "Update an open API key's name and/or scope",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +openapi-key-update --app-id <app_id> --key-id <key_id> --name partner-prod",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "key-id", Desc: "API key ID", Required: true},
{Name: "name", Desc: "new name"},
{Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"},
{Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"},
{Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"},
{Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := oapiKeyValidateKeyID(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" &&
!rctx.Changed("scope-all") &&
len(rctx.StrArray("scope-api")) == 0 &&
strings.TrimSpace(rctx.Str("scope")) == "" &&
!rctx.Changed("allow-preview") {
return appsValidationParamError("--name", "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required").
WithHint("pass at least one of --name / --scope-all / --scope-api / --scope / --allow-preview")
}
return oapiKeyValidateScopeFlags(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _ := buildOpenAPIKeyUpdateBody(rctx)
return common.NewDryRunAPI().
PATCH(oapiKeyItemURL(rctx)).
Desc("Update open API key").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, err := buildOpenAPIKeyUpdateBody(rctx)
if err != nil {
return appsValidationParamError("--scope", "invalid scope: %v", err)
}
data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, body)
if err != nil {
return withAppsHint(err, oapiKeyNotFoundHint(rctx))
}
return outputRedactedInfo(rctx, data)
},
}
// buildOpenAPIKeyUpdateBody builds {name?, config?} with only provided fields.
func buildOpenAPIKeyUpdateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{}
if name := strings.TrimSpace(rctx.Str("name")); name != "" {
body["name"] = name
}
cfg, err := buildKeyConfig(rctx.Bool("scope-all"), rctx.StrArray("scope-api"), rctx.Str("scope"), rctx.Changed("allow-preview"), rctx.Bool("allow-preview"))
if err != nil {
return nil, err
}
if cfg != nil {
body["config"] = cfg
}
return body, nil
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// updateFlagDefs returns the flag type map for +openapi-key-update tests.
func updateFlagDefs() map[string]string {
return map[string]string{
"app-id": "string",
"key-id": "string",
"name": "string",
"scope-all": "bool",
"scope-api": "string_array",
"scope": "string",
"allow-preview": "bool",
}
}
func TestOpenAPIKeyUpdate_RequiresOneField(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
updateFlagDefs(),
map[string]string{"app-id": "app_x", "key-id": "1"})
err := AppsOpenAPIKeyUpdate.Validate(context.Background(), rctx)
if err == nil {
t.Errorf("update with no changeable field must fail validation")
}
if err != nil && !strings.Contains(err.Error(), "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestOpenAPIKeyUpdateExecute_Redacts(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
updateFlagDefs(),
map[string]string{"app-id": "app_x", "key-id": "1", "name": "partner-prod"})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1",
Body: map[string]interface{}{
"code": 0, "msg": "",
"data": map[string]interface{}{
"info": map[string]interface{}{
"api_key_id": "k1", "name": "partner-prod",
"api_key": "xxxxxxxxxxxx", "status": float64(1),
},
},
},
})
if err := AppsOpenAPIKeyUpdate.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") {
t.Fatalf("update leaked raw api key: %s", stdoutBuf.String())
}
}

View File

@@ -1,351 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
"unicode/utf8"
)
type appsCellFormatter func(interface{}) string
type appsOutputColumn struct {
Key string
Label string
Value func(map[string]interface{}) interface{}
Format appsCellFormatter
}
type appsOutputSchema struct {
Columns []appsOutputColumn
Strict bool
}
func appsProjectRows(rows []map[string]interface{}, schema appsOutputSchema) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
out = append(out, appsProjectRow(row, schema))
}
return out
}
func appsProjectRow(row map[string]interface{}, schema appsOutputSchema) map[string]interface{} {
out := make(map[string]interface{}, len(schema.Columns))
declared := make(map[string]struct{}, len(schema.Columns))
for _, col := range schema.Columns {
if col.Key == "" {
continue
}
declared[col.Key] = struct{}{}
value := row[col.Key]
if col.Value != nil {
value = col.Value(row)
}
if value != nil {
out[col.Key] = value
}
}
if !schema.Strict {
for key, value := range row {
if _, ok := declared[key]; !ok {
out[key] = value
}
}
}
return out
}
func appsPrintSchemaTable(w io.Writer, rows []map[string]interface{}, schema appsOutputSchema) {
if len(rows) == 0 {
fmt.Fprintln(w, "(no data)")
return
}
headers := make([]string, 0, len(schema.Columns))
for _, col := range schema.Columns {
if col.Key == "" {
continue
}
headers = append(headers, appsColumnLabel(col))
}
if len(headers) == 0 {
fmt.Fprintln(w, "(no data)")
return
}
matrix := make([][]string, 0, len(rows)+1)
matrix = append(matrix, headers)
for _, row := range rows {
line := make([]string, 0, len(schema.Columns))
for _, col := range schema.Columns {
if col.Key == "" {
continue
}
value := row[col.Key]
if col.Value != nil {
value = col.Value(row)
}
line = append(line, appsFormatCell(value, col.Format))
}
matrix = append(matrix, line)
}
widths := appsColumnWidths(matrix)
for i, row := range matrix {
cells := make([]string, len(row))
for j, cell := range row {
cells[j] = appsPad(cell, widths[j])
}
fmt.Fprintln(w, strings.TrimRight(strings.Join(cells, " "), " "))
if i == 0 {
sep := make([]string, len(widths))
for j, width := range widths {
sep[j] = strings.Repeat("─", width)
}
fmt.Fprintln(w, strings.Join(sep, " "))
}
}
}
func appsColumnLabel(col appsOutputColumn) string {
if col.Label != "" {
return col.Label
}
return col.Key
}
func appsFormatCell(value interface{}, formatter appsCellFormatter) string {
if formatter != nil {
return formatter(value)
}
return appsDefaultCell(value)
}
func appsDefaultCell(value interface{}) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
return v
case json.Number:
return v.String()
case bool:
return strconv.FormatBool(v)
case int:
return strconv.Itoa(v)
case int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
case float32:
return appsFormatFloat(float64(v))
case float64:
return appsFormatFloat(v)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
return string(b)
}
}
func appsFormatFloat(value float64) string {
if math.Trunc(value) == value {
return strconv.FormatInt(int64(value), 10)
}
return strconv.FormatFloat(value, 'f', -1, 64)
}
func appsColumnWidths(matrix [][]string) []int {
if len(matrix) == 0 {
return nil
}
widths := make([]int, len(matrix[0]))
for _, row := range matrix {
for i, cell := range row {
if width := utf8.RuneCountInString(cell); width > widths[i] {
widths[i] = width
}
}
}
return widths
}
func appsPad(s string, width int) string {
delta := width - utf8.RuneCountInString(s)
if delta <= 0 {
return s
}
return s + strings.Repeat(" ", delta)
}
func appsFormatNS(layout string) appsCellFormatter {
return func(value interface{}) string {
ns, ok := appsInt64Value(value)
if !ok || ns <= 0 {
return appsDefaultCell(value)
}
return time.Unix(0, ns).Local().Format(layout)
}
}
func appsFormatSec(layout string) appsCellFormatter {
return func(value interface{}) string {
sec, ok := appsInt64Value(value)
if !ok || sec <= 0 {
return appsDefaultCell(value)
}
return time.Unix(sec, 0).Local().Format(layout)
}
}
func appsFormatDurationMS(value interface{}) string {
ms, ok := appsFloat64Value(value)
if !ok || ms < 0 {
return appsDefaultCell(value)
}
switch {
case ms < 1:
return fmt.Sprintf("%.2fms", ms)
case ms < 1000:
return fmt.Sprintf("%.0fms", ms)
case ms < 60000:
return fmt.Sprintf("%.2fs", ms/1000)
case ms < 3600000:
return fmt.Sprintf("%.1fm", ms/60000)
default:
return fmt.Sprintf("%.1fh", ms/3600000)
}
}
func appsInt64Value(value interface{}) (int64, bool) {
switch v := value.(type) {
case int:
return int64(v), true
case int8:
return int64(v), true
case int16:
return int64(v), true
case int32:
return int64(v), true
case int64:
return v, true
case uint:
return appsUint64ToInt64(uint64(v))
case uint8:
return int64(v), true
case uint16:
return int64(v), true
case uint32:
return int64(v), true
case uint64:
return appsUint64ToInt64(v)
case float32:
f := float64(v)
if math.Trunc(f) == f && f <= float64(math.MaxInt64) && f >= float64(math.MinInt64) {
return int64(f), true
}
case float64:
if math.Trunc(v) == v && v <= float64(math.MaxInt64) && v >= float64(math.MinInt64) {
return int64(v), true
}
case json.Number:
if n, err := v.Int64(); err == nil {
return n, true
}
if f, err := v.Float64(); err == nil && math.Trunc(f) == f {
return int64(f), true
}
case string:
raw := strings.TrimSpace(v)
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
return n, true
}
if f, err := strconv.ParseFloat(raw, 64); err == nil && math.Trunc(f) == f {
return int64(f), true
}
}
return 0, false
}
func appsFloat64Value(value interface{}) (float64, bool) {
switch v := value.(type) {
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint8:
return float64(v), true
case uint16:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case float32:
return float64(v), true
case float64:
return v, true
case json.Number:
f, err := v.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
return f, err == nil
default:
return 0, false
}
}
func appsUint64ToInt64(value uint64) (int64, bool) {
if value > uint64(math.MaxInt64) {
return 0, false
}
return int64(value), true
}
func appsAttrValue(key string) func(map[string]interface{}) interface{} {
return func(row map[string]interface{}) interface{} {
return appsAttributeValue(row["attributes"], key)
}
}
func appsAttributeValue(raw interface{}, key string) interface{} {
switch attrs := raw.(type) {
case map[string]interface{}:
return attrs[key]
case []interface{}:
for _, rawItem := range attrs {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
itemKey := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name")))
if itemKey == key {
return firstObservabilityValue(item, "value")
}
}
case []map[string]interface{}:
for _, item := range attrs {
itemKey := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name")))
if itemKey == key {
return firstObservabilityValue(item, "value")
}
}
}
return nil
}

View File

@@ -1,56 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"time"
)
func TestAppsOutputSchemaProjectsAndFormats(t *testing.T) {
row := map[string]interface{}{
"timestamp_ns": "1782209472123456789",
"level": "ERROR",
"extra": "ignored",
"attributes": map[string]interface{}{
"module": "frontend",
"duration_ms": "1234.5",
},
}
schema := appsOutputSchema{
Columns: []appsOutputColumn{
{Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05.000")},
{Key: "module", Value: appsAttrValue("module")},
{Key: "duration_ms", Value: appsAttrValue("duration_ms"), Format: appsFormatDurationMS},
{Key: "level"},
},
Strict: true,
}
projected := appsProjectRow(row, schema)
if len(projected) != 4 {
t.Fatalf("projected field count = %d, want 4: %#v", len(projected), projected)
}
if projected["module"] != "frontend" || projected["duration_ms"] != "1234.5" {
t.Fatalf("projected derived fields = %#v", projected)
}
if _, ok := projected["extra"]; ok {
t.Fatalf("strict projection should drop extra field: %#v", projected)
}
var b strings.Builder
appsPrintSchemaTable(&b, []map[string]interface{}{projected}, schema)
out := b.String()
wantTime := time.Unix(0, 1782209472123456789).Local().Format("2006-01-02 15:04:05.000")
if !strings.HasPrefix(out, "time") {
t.Fatalf("pretty output should start with schema label time, got:\n%s", out)
}
if !strings.Contains(out, wantTime) {
t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, out)
}
if strings.Contains(out, "1782209472123456789") {
t.Fatalf("pretty output should not contain raw timestamp:\n%s", out)
}
}

View File

@@ -1,664 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultAppsTraceEnv = "online"
traceSearchEndpoint = "search_traces"
traceGetEndpoint = "trace"
)
// AppsTraceList searches online app traces with observability filters.
var AppsTraceList = common.Shortcut{
Service: appsService,
Command: "+trace-list",
Description: "Search online app traces with observability filters",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +trace-list --app-id <app_id> --trace-id <trace_id>",
"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 traces should be searched", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"},
{Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"},
{Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"},
{Name: "user-id", Desc: "end user ID filter"},
{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: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"},
{Name: "page-token", Desc: "pagination cursor from a previous trace search response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, err := buildTraceSearchBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _ := buildTraceSearchBody(rctx)
return common.NewDryRunAPI().
POST(traceSearchPath(rctx.Str("app-id"))).
Desc("Search online app traces").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
body, err := buildTraceSearchBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", traceSearchPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := normalizeTraceSearchResponse(data)
rctx.OutFormat(out, nil, func(w io.Writer) {
appsPrintSchemaTable(w, appsProjectRows(traceListRows(out.Items), traceSummarySchema), traceSummarySchema)
})
return nil
},
}
// AppsTraceGet fetches one online app trace by trace ID.
var AppsTraceGet = common.Shortcut{
Service: appsService,
Command: "+trace-get",
Description: "Get one online app trace by trace ID",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +trace-get --app-id <app_id> --trace-id <trace_id>",
"Tip: use +trace-list first if the trace ID is unknown.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"},
{Name: "trace-id", Desc: "trace ID to fetch", Required: true},
},
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("trace-id")) == "" {
return appsValidationParamError("--trace-id", "--trace-id is required")
}
return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag))
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(traceGetPath(rctx.Str("app-id"))).
Desc("Get online app trace by trace ID").
Body(buildTraceGetBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
data, err := rctx.CallAPITyped("POST", traceGetPath(appID), nil, buildTraceGetBody(rctx))
if err != nil {
return withAppsHint(err, appIDListHint)
}
trace := normalizeTraceDetail(data)
rctx.OutFormat(trace, nil, func(w io.Writer) {
appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{traceDetailSummary(trace)}, traceSummarySchema), traceSummarySchema)
})
return nil
},
}
type traceSearchOutput struct {
Items []map[string]interface{} `json:"items"`
PageToken string `json:"page_token,omitempty"`
HasMore bool `json:"has_more"`
}
func traceSearchPath(appID string) string {
return appScopedPath(appID, traceSearchEndpoint)
}
func traceGetPath(appID string) string {
return appScopedPath(appID, traceGetEndpoint)
}
func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
if env == "" {
env = defaultAppsTraceEnv
}
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 := addTraceSearchTimeRange(body, rctx); err != nil {
return nil, err
}
if filter := buildTraceSearchFilter(rctx); len(filter) > 0 {
body["filter"] = filter
}
return body, nil
}
func buildTraceGetBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"app_env": appsObservabilityBackendEnv,
"trace_id": strings.TrimSpace(rctx.Str("trace-id")),
}
}
func addTraceSearchTimeRange(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 buildTraceSearchFilter(rctx *common.RuntimeContext) map[string]interface{} {
filter := make(map[string]interface{})
if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 {
filter["trace_ids"] = traceIDs
}
addTrimmedTraceFilterString(filter, "keyword", rctx.Str("root-span"))
addTrimmedTraceFilterStrings(filter, "user_ids", rctx.Str("user-id"))
return filter
}
func addTrimmedTraceFilterString(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = value
}
}
func addTrimmedTraceFilterStrings(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = []string{value}
}
}
func normalizeTraceSearchResponse(data map[string]interface{}) traceSearchOutput {
items, sourceKey := firstTraceMapSliceWithKey(data, "items", "trace_items", "traceItems", "spans", "span_items", "spanItems")
normalized := normalizeTraceSummaries(items)
if isTraceSpanItemsKey(sourceKey) {
normalized = aggregateTraceSpanSummaries(items)
}
return traceSearchOutput{
Items: normalized,
PageToken: firstLogString(data, "page_token", "next_page_token", "pageToken", "nextPageToken"),
HasMore: firstLogBool(data, "has_more", "hasMore"),
}
}
func firstTraceMapSliceWithKey(data map[string]interface{}, keys ...string) ([]map[string]interface{}, string) {
for _, key := range keys {
raw, ok := data[key]
if !ok {
continue
}
return traceMapSlice(raw), key
}
return nil, ""
}
func traceMapSlice(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 isTraceSpanItemsKey(key string) bool {
switch key {
case "spans", "span_items", "spanItems":
return true
default:
return false
}
}
func normalizeTraceSummaries(items []map[string]interface{}) []map[string]interface{} {
if len(items) == 0 {
return nil
}
if hasRepeatedTraceID(items) {
return aggregateTraceSpanSummaries(items)
}
out := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
out = append(out, normalizeTraceSummary(item))
}
return out
}
func hasRepeatedTraceID(items []map[string]interface{}) bool {
seen := make(map[string]struct{}, len(items))
for _, item := range items {
traceID := firstTraceString(item, "trace_id", "traceID", "traceId")
if traceID == "" {
continue
}
if _, ok := seen[traceID]; ok {
return true
}
seen[traceID] = struct{}{}
}
return false
}
func normalizeTraceSummary(item map[string]interface{}) map[string]interface{} {
out := cloneMap(item)
copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId")
copyFirstAlias(out, item, "start_time_ns", "start_time_ns", "startTimeNs")
copyFirstAlias(out, item, "root_span", "root_span", "rootSpan")
copyFirstAlias(out, item, "user_id", "user_id", "userID", "userId")
copyFirstAlias(out, item, "duration_ms", "duration_ms", "durationMs")
copyFirstAlias(out, item, "status", "status")
copyFirstAlias(out, item, "span_count", "span_count", "spanCount")
return out
}
func aggregateTraceSpanSummaries(spans []map[string]interface{}) []map[string]interface{} {
groups := make([]traceSpanGroup, 0, len(spans))
indexByTraceID := make(map[string]int, len(spans))
ungrouped := make([]map[string]interface{}, 0)
for _, span := range spans {
span = normalizeTraceSpan(span)
traceID := firstTraceString(span, "trace_id", "traceID", "traceId")
if traceID == "" {
ungrouped = append(ungrouped, normalizeTraceSummary(span))
continue
}
idx, ok := indexByTraceID[traceID]
if !ok {
indexByTraceID[traceID] = len(groups)
groups = append(groups, traceSpanGroup{traceID: traceID, spans: []map[string]interface{}{span}})
continue
}
groups[idx].spans = append(groups[idx].spans, span)
}
out := make([]map[string]interface{}, 0, len(groups)+len(ungrouped))
for _, group := range groups {
out = append(out, buildTraceSpanSummary(group.traceID, group.spans))
}
out = append(out, ungrouped...)
return out
}
type traceSpanGroup struct {
traceID string
spans []map[string]interface{}
}
func buildTraceSpanSummary(traceID string, spans []map[string]interface{}) map[string]interface{} {
root := selectTraceRootCandidate(spans)
summary := normalizeTraceSummary(root)
summary["trace_id"] = traceID
summary["span_count"] = len(spans)
if firstItemString(summary, "root_span") == "" {
if rootName := firstItemString(root, "name", "span_name", "spanName"); rootName != "" {
summary["root_span"] = rootName
} else if fallbackName := firstTraceSpanName(spans); fallbackName != "" {
summary["root_span"] = fallbackName
}
}
if firstItemString(summary, "user_id") == "" {
if userID := firstStringInTraceSpans(spans, "user_id", "userID", "userId"); userID != "" {
summary["user_id"] = userID
}
}
if startValue, ok := earliestTraceSpanValue(spans, "start_time_ns", "startTimeNs"); ok {
summary["start_time_ns"] = startValue
}
if durationValue, ok := maxTraceSpanValue(spans, "duration_ms", "durationMs"); ok {
summary["duration_ms"] = durationValue
}
if status := aggregateTraceSpanStatus(spans); status != "" {
summary["status"] = status
}
return summary
}
func selectTraceRootCandidate(spans []map[string]interface{}) map[string]interface{} {
for _, span := range spans {
if firstItemString(span, "root_span", "rootSpan") != "" {
return span
}
}
for _, span := range spans {
if isTraceRootParentCandidate(span) {
return span
}
}
for _, span := range spans {
if firstItemString(span, "name", "span_name", "spanName") != "" {
return span
}
}
if len(spans) == 0 {
return map[string]interface{}{}
}
return spans[0]
}
func isTraceRootParentCandidate(span map[string]interface{}) bool {
parent, ok := firstTraceValue(span, "parent_span_id", "parentSpanID", "parentSpanId")
if !ok || parent == nil {
return true
}
parentID, ok := parent.(string)
return ok && strings.TrimSpace(parentID) == ""
}
func firstTraceSpanName(spans []map[string]interface{}) string {
return firstStringInTraceSpans(spans, "name", "span_name", "spanName")
}
func firstStringInTraceSpans(spans []map[string]interface{}, keys ...string) string {
for _, span := range spans {
if value := firstItemString(span, keys...); value != "" {
return value
}
}
return ""
}
func earliestTraceSpanValue(spans []map[string]interface{}, keys ...string) (interface{}, bool) {
var bestValue interface{}
var bestNumber traceNumber
var found bool
for _, span := range spans {
value, number, ok := firstTraceNumericValue(span, keys...)
if !ok {
continue
}
if !found || number.less(bestNumber) {
bestValue = value
bestNumber = number
found = true
}
}
return bestValue, found
}
func maxTraceSpanValue(spans []map[string]interface{}, keys ...string) (interface{}, bool) {
var bestValue interface{}
var bestNumber traceNumber
var found bool
for _, span := range spans {
value, number, ok := firstTraceNumericValue(span, keys...)
if !ok {
continue
}
if !found || number.greater(bestNumber) {
bestValue = value
bestNumber = number
found = true
}
}
return bestValue, found
}
func firstTraceNumericValue(span map[string]interface{}, keys ...string) (interface{}, traceNumber, bool) {
value, ok := firstTraceValue(span, keys...)
if !ok {
return nil, traceNumber{}, false
}
number, ok := parseTraceNumber(value)
return value, number, ok
}
type traceNumber struct {
floatValue float64
intValue int64
exactInt bool
}
func (n traceNumber) less(other traceNumber) bool {
if n.exactInt && other.exactInt {
return n.intValue < other.intValue
}
return n.floatValue < other.floatValue
}
func (n traceNumber) greater(other traceNumber) bool {
if n.exactInt && other.exactInt {
return n.intValue > other.intValue
}
return n.floatValue > other.floatValue
}
func parseTraceNumber(value interface{}) (traceNumber, bool) {
switch v := value.(type) {
case int:
return exactTraceInt(int64(v)), true
case int8:
return exactTraceInt(int64(v)), true
case int16:
return exactTraceInt(int64(v)), true
case int32:
return exactTraceInt(int64(v)), true
case int64:
return exactTraceInt(v), true
case uint:
return traceUintNumber(uint64(v))
case uint8:
return traceUintNumber(uint64(v))
case uint16:
return traceUintNumber(uint64(v))
case uint32:
return traceUintNumber(uint64(v))
case uint64:
return traceUintNumber(v)
case float32:
return traceFloatNumber(float64(v)), true
case float64:
return traceFloatNumber(v), true
case string:
raw := strings.TrimSpace(v)
if number, err := strconv.ParseInt(raw, 10, 64); err == nil {
return exactTraceInt(number), true
}
number, err := strconv.ParseFloat(raw, 64)
return traceFloatNumber(number), err == nil
default:
return traceNumber{}, false
}
}
func exactTraceInt(value int64) traceNumber {
return traceNumber{floatValue: float64(value), intValue: value, exactInt: true}
}
func traceFloatNumber(value float64) traceNumber {
return traceNumber{floatValue: value}
}
func traceUintNumber(value uint64) (traceNumber, bool) {
const maxInt64AsUint = uint64(1<<63 - 1)
if value <= maxInt64AsUint {
return exactTraceInt(int64(value)), true
}
return traceFloatNumber(float64(value)), true
}
func aggregateTraceSpanStatus(spans []map[string]interface{}) string {
firstStatus := ""
for _, span := range spans {
status := firstItemString(span, "status")
if status == "" {
continue
}
if strings.EqualFold(status, "ERROR") {
return "ERROR"
}
if firstStatus == "" {
firstStatus = status
}
}
return firstStatus
}
func normalizeTraceDetail(data map[string]interface{}) map[string]interface{} {
trace := firstTraceMap(data, "trace", "trace_detail", "traceDetail")
if trace == nil {
trace = data
}
out := normalizeTraceObject(trace)
if spans := firstMapSlice(trace, "spans", "span_items", "spanItems"); len(spans) > 0 {
normalized := make([]map[string]interface{}, 0, len(spans))
for _, span := range spans {
normalized = append(normalized, normalizeTraceSpan(span))
}
out["spans"] = normalized
if firstTraceString(out, "trace_id") == "" {
if traceID := firstTraceString(normalized[0], "trace_id"); traceID != "" {
out["trace_id"] = traceID
}
}
}
return out
}
func normalizeTraceObject(trace map[string]interface{}) map[string]interface{} {
out := cloneMap(trace)
normalizeObservabilityAttributes(out)
copyFirstAlias(out, trace, "trace_id", "trace_id", "traceID", "traceId")
copyFirstAlias(out, trace, "is_break", "is_break", "isBreak")
return out
}
func normalizeTraceSpan(span map[string]interface{}) map[string]interface{} {
out := cloneMap(span)
normalizeObservabilityAttributes(out)
copyFirstAlias(out, span, "trace_id", "trace_id", "traceID", "traceId")
copyFirstAlias(out, span, "span_id", "span_id", "spanID", "spanId")
copyFirstAlias(out, span, "parent_span_id", "parent_span_id", "parentSpanID", "parentSpanId")
copyFirstAlias(out, span, "start_time_ns", "start_time_ns", "startTimeNs", "start_time_unix_nano", "startTimeUnixNano")
copyFirstAlias(out, span, "end_time_ns", "end_time_ns", "endTimeNs", "end_time_unix_nano", "endTimeUnixNano")
copyFirstAlias(out, span, "duration_ms", "duration_ms", "durationMs")
copyFirstAlias(out, span, "is_break", "is_break", "isBreak")
for _, key := range []string{"duration_ms", "user_id", "status", "module"} {
if _, ok := out[key]; !ok {
if value := appsAttributeValue(span["attributes"], key); value != nil {
out[key] = value
}
}
}
return out
}
func traceListRows(items []map[string]interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
rows = append(rows, traceSummaryRow(item))
}
return rows
}
var traceSummarySchema = appsOutputSchema{
Columns: []appsOutputColumn{
{Key: "start_time_ns", Label: "start-time", Format: appsFormatNS("2006-01-02 15:04:05.000")},
{Key: "root_span", Label: "root-span"},
{Key: "user_id", Label: "user-id"},
{Key: "duration_ms", Label: "duration", Format: appsFormatDurationMS},
{Key: "trace_id", Label: "trace-id"},
},
Strict: true,
}
func traceDetailSummary(trace map[string]interface{}) map[string]interface{} {
if spans := traceMapSlice(trace["spans"]); len(spans) > 0 {
summaries := aggregateTraceSpanSummaries(spans)
if len(summaries) > 0 {
summary := summaries[0]
for _, key := range []string{"trace_id", "is_break"} {
if value, ok := trace[key]; ok {
summary[key] = value
}
}
return summary
}
}
return traceSummaryRow(trace)
}
func traceSummaryRow(item map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"trace_id": item["trace_id"],
"start_time_ns": item["start_time_ns"],
"root_span": firstItemString(item, "root_span", "name", "span_name"),
"user_id": item["user_id"],
"duration_ms": item["duration_ms"],
"status": item["status"],
"span_count": item["span_count"],
}
}
func firstTraceMap(data map[string]interface{}, keys ...string) map[string]interface{} {
for _, key := range keys {
if value, ok := data[key].(map[string]interface{}); ok {
return value
}
}
return nil
}
func firstTraceString(data map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value, ok := firstTraceValue(data, key); ok {
if s, ok := value.(string); ok && strings.TrimSpace(s) != "" {
return s
}
}
}
return ""
}
func firstTraceValue(data map[string]interface{}, keys ...string) (interface{}, bool) {
for _, key := range keys {
if value, ok := data[key]; ok {
return value, true
}
}
return nil, false
}

View File

@@ -1,453 +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 TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsTraceList, []string{
"+trace-list", "--app-id", "app_x", "--trace-id", "trace-1",
"--root-span", "gateway", "--user-id", "ou_1",
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
"--page-size", "10", "--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_traces" {
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(10) {
t.Fatalf("body = %#v", env.API[0].Body)
}
filter := env.API[0].Body["filter"].(map[string]interface{})
traceIDs := filter["trace_ids"].([]interface{})
if len(traceIDs) != 1 || traceIDs[0] != "trace-1" {
t.Fatalf("filter.trace_ids = %#v", traceIDs)
}
if got := filter["keyword"]; got != "gateway" {
t.Fatalf("filter.keyword = %v", got)
}
userIDs := filter["user_ids"].([]interface{})
if len(userIDs) != 1 || userIDs[0] != "ou_1" {
t.Fatalf("filter.user_ids = %#v", userIDs)
}
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 TestAppsTraceList_RejectsDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout)
requireAppsValidationParam(t, err, "--environment")
}
func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsTraceGet, []string{
"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--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/trace" {
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["trace_id"] != "trace-1" {
t.Fatalf("body = %#v", env.API[0].Body)
}
}
func TestNormalizeTraceSummaries_DeduplicatesSpanList(t *testing.T) {
got := normalizeTraceSummaries([]map[string]interface{}{
{"trace_id": "trace-1", "name": "gateway"},
{"traceId": "trace-1", "name": "handler"},
})
if len(got) != 1 {
t.Fatalf("summaries len = %d, want 1: %#v", len(got), got)
}
if got[0]["trace_id"] != "trace-1" || got[0]["span_count"] != 2 {
t.Fatalf("summary = %#v", got[0])
}
}
func TestNormalizeTraceSummaries_PrefersRootCandidateOverChildOrder(t *testing.T) {
got := normalizeTraceSummaries([]map[string]interface{}{
{
"trace_id": "trace-1",
"parent_span_id": "span-root",
"name": "child",
"status": "ERROR",
"start_time_ns": "200",
"duration_ms": 10,
},
{
"traceID": "trace-1",
"parentSpanID": "",
"spanName": "root",
"status": "OK",
"startTimeNs": "100",
"durationMs": 200,
"userID": "ou_root",
"parent_span_id": "",
},
})
if len(got) != 1 {
t.Fatalf("summaries len = %d, want 1: %#v", len(got), got)
}
summary := got[0]
if summary["trace_id"] != "trace-1" || summary["span_count"] != 2 {
t.Fatalf("summary identity/count = %#v", summary)
}
if summary["root_span"] != "root" {
t.Fatalf("root_span = %#v, want root: %#v", summary["root_span"], summary)
}
if summary["status"] != "ERROR" {
t.Fatalf("status = %#v, want ERROR: %#v", summary["status"], summary)
}
if summary["start_time_ns"] != "100" {
t.Fatalf("start_time_ns = %#v, want earliest 100: %#v", summary["start_time_ns"], summary)
}
if summary["duration_ms"] != 200 {
t.Fatalf("duration_ms = %#v, want max 200: %#v", summary["duration_ms"], summary)
}
if summary["user_id"] != "ou_root" {
t.Fatalf("user_id = %#v, want root candidate user: %#v", summary["user_id"], summary)
}
}
func TestAppsTraceList_NormalizesTraceItemsPaginationVariants(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_traces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"traceItems": []interface{}{
map[string]interface{}{
"traceID": "trace-1",
"startTimeNs": "1782209472123456789",
"rootSpan": "gateway",
"userID": "ou_1",
"durationMs": float64(123),
"spanCount": float64(7),
},
},
"nextPageToken": "tok-next",
"hasMore": true,
},
},
})
if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-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["trace_id"] != "trace-1" || item["root_span"] != "gateway" || item["user_id"] != "ou_1" {
t.Fatalf("item aliases = %#v", item)
}
if item["span_count"] != float64(7) {
t.Fatalf("span_count = %#v", item["span_count"])
}
}
func TestAppsTraceList_AggregatesSpansSourceWithSingleSpanPerTrace(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_traces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"spans": []interface{}{
map[string]interface{}{
"traceID": "trace-1",
"name": "gateway",
},
map[string]interface{}{
"trace_id": "trace-2",
"span_name": "worker",
},
},
},
},
})
if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-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())
}
if len(env.Data.Items) != 2 {
t.Fatalf("items len = %d, want 2: %#v", len(env.Data.Items), env.Data.Items)
}
wantRootSpan := map[string]string{
"trace-1": "gateway",
"trace-2": "worker",
}
for _, item := range env.Data.Items {
traceID, ok := item["trace_id"].(string)
if !ok || traceID == "" {
t.Fatalf("missing canonical trace_id: %#v", item)
}
if item["span_count"] != float64(1) {
t.Fatalf("span_count for %s = %#v, want 1: %#v", traceID, item["span_count"], item)
}
if item["root_span"] != wantRootSpan[traceID] {
t.Fatalf("root_span for %s = %#v, want %q: %#v", traceID, item["root_span"], wantRootSpan[traceID], item)
}
}
}
func TestAppsTraceList_PrettyUsesTraceSummaryColumns(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/search_traces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"traceItems": []interface{}{
map[string]interface{}{
"traceID": "trace-1",
"startTimeNs": "1782232472381701316",
"rootSpan": "GET /app/app_x/api/note-records",
"userID": "1846640196867498",
"durationMs": float64(414),
"status": "OK",
"spanCount": float64(4),
},
},
},
},
})
if err := runAppsShortcut(t, AppsTraceList, []string{
"+trace-list", "--app-id", "app_x", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.HasPrefix(got, "start-time") {
t.Fatalf("pretty output should start with start-time column, got:\n%s", got)
}
for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "GET /app/app_x/api/note-records", "414ms"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q:\n%s", want, got)
}
}
for _, banned := range []string{"span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} {
if strings.Contains(got, banned) {
t.Fatalf("pretty output should not include %q:\n%s", banned, got)
}
}
}
func TestAppsTraceGet_PrettySummarizesSpans(t *testing.T) {
const rawNS = int64(1782232472381701316)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"is_break": false,
"spans": []interface{}{
map[string]interface{}{
"trace_id": "trace-1",
"name": "GET /app/app_x",
"span_id": "root",
"parent_span_id": "",
"start_time_unix_nano": "1782232472381701316",
"end_time_unix_nano": "1782232480645457992",
"attributes": []interface{}{
map[string]interface{}{"key": "duration_ms", "value": "8263.76"},
map[string]interface{}{"key": "user_id", "value": "1826968659245100"},
},
},
map[string]interface{}{
"trace_id": "trace-1",
"name": "child",
"span_id": "child",
"parent_span_id": "root",
"start_time_unix_nano": "1782232480448000000",
"attributes": []interface{}{
map[string]interface{}{"key": "duration_ms", "value": "184.89"},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsTraceGet, []string{
"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--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, "start-time") {
t.Fatalf("pretty output should start with start-time columns, got:\n%s", got)
}
for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "trace-1", "GET /app/app_x", "1826968659245100", wantTime} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q:\n%s", want, got)
}
}
for _, banned := range []string{"start_time_ns", "1782232472381701316", "span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} {
if strings.Contains(got, banned) {
t.Fatalf("pretty output should not include %q:\n%s", banned, got)
}
}
}
func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"traceDetail": map[string]interface{}{
"traceID": "trace-1",
"isBreak": true,
"spans": []interface{}{
map[string]interface{}{
"spanID": "span-1",
"parentSpanID": "root",
"traceID": "trace-1",
"startTimeNs": "1782209472123456789",
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if _, wrapped := env.Data["trace"]; wrapped {
t.Fatalf("trace-get should output the trace object directly: %#v", env.Data)
}
if env.Data["trace_id"] != "trace-1" || env.Data["is_break"] != true {
t.Fatalf("trace aliases = %#v", env.Data)
}
spans := env.Data["spans"].([]interface{})
span := spans[0].(map[string]interface{})
if span["span_id"] != "span-1" || span["parent_span_id"] != "root" || span["trace_id"] != "trace-1" {
t.Fatalf("span aliases = %#v", span)
}
}
func TestAppsTraceGet_NormalizesKVAttributesToObject(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"spans": []interface{}{
map[string]interface{}{
"trace_id": "trace-1",
"span_id": "span-1",
"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, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Spans []map[string]interface{} `json:"spans"`
} `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.Spans[0]["attributes"].(map[string]interface{})
if !ok {
t.Fatalf("attributes = %#v, want object", env.Data.Spans[0]["attributes"])
}
if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" {
t.Fatalf("attributes = %#v", attrs)
}
}

View File

@@ -4,79 +4,12 @@
package apps
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ── db 环境 flag--environment 是唯一受理名;旧名 --env 已移除 ──
//
// 硬改名:标准名 --environment带默认/枚举)正常注册并受理;旧名 --env 仅注册为隐藏 flag
// 目的是「传了能被识别并给出清晰报错」而非继续受理——一旦显式传 --env在 Validate 阶段直接
// 返回 validation 错、指向 --environment。所有 DryRun/Execute 经 dbEnv() 只读 --environment。
// dbEnvFlags 返回环境 flag 对,供各 db 命令 append 进自己的 Flags。
func dbEnvFlags(def string, enum []string, desc string) []common.Flag {
return []common.Flag{
{Name: "environment", Default: def, Enum: enum, Desc: desc},
{Name: "env", Hidden: true, Desc: "removed: use --environment"},
}
}
// dbEnv 取环境值:只认标准 --environment含其默认值旧名 --env 不再受理(见 rejectLegacyEnvFlag
func dbEnv(rctx *common.RuntimeContext) string {
return rctx.Str("environment")
}
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env显式传了就报清晰的 validation 错,指向 --environment。
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
if rctx.Changed("env") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--env is no longer supported; use --environment instead").WithParam("--env")
}
return nil
}
// pollUntil 轮询异步任务直到 check 判定终态。async migrate/recovery 用dataloom 立即返
// task_id/preview_request_idCLI 自己 poll避免单连接长挂被网关/SDK 30s 中断)。
// 首次立即 fetch不睡check 返 done→返回返 err→透传失败终态否则按 interval 间隔重试至 maxWait。
func pollUntil(ctx context.Context, interval, maxWait time.Duration,
fetch func() (map[string]interface{}, error),
check func(map[string]interface{}) (done bool, err error)) (map[string]interface{}, error) {
maxAttempts := int(maxWait / interval)
if maxAttempts < 1 {
maxAttempts = 1
}
for i := 0; ; i++ {
data, err := fetch()
if err != nil {
return nil, err
}
done, cerr := check(data)
if cerr != nil {
return nil, cerr
}
if done {
return data, nil
}
if i+1 >= maxAttempts {
// async 任务多半还在服务端推进poll 超时是可重试的——标 retryable 让 agent 重新轮询而非放弃。
return nil, errs.NewNetworkError(errs.SubtypeNetworkTimeout, "timed out waiting for completion after %s", maxWait).WithRetryable()
}
select {
case <-ctx.Done():
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "cancelled while waiting").WithCause(ctx.Err())
case <-time.After(interval):
}
}
}
// URL helpers for the db CLI commands.
// appTablesPath 返回 app db 表列表 URL复用存量「获取数据表列表」接口
@@ -99,167 +32,11 @@ func appDbEnvCreatePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID))
}
// ── 多环境发布env diff/migrate/ 数据恢复recovery/ 配额 路由 ──
// appEnvMigratePath 返回 dev→online 发布(预览/落地共用URLdb/env_migrate。
func appEnvMigratePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID))
}
// appEnvMigrateStatusPath 返回发布异步任务状态查询 URLdb/env_migrate_status。
func appEnvMigrateStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appRecoveryPath 返回 PITR 数据恢复(预览/落地共用URLdb/env_recovery。
func appRecoveryPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID))
}
// appRecoveryDiffStatusPath 返回恢复预览diff异步状态查询 URLdb/env_recovery_diff_status。
func appRecoveryDiffStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_recovery_diff_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appRecoveryApplyStatusPath 返回恢复落地异步状态查询 URLdb/env_recovery_apply_status。
func appRecoveryApplyStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/env_recovery_apply_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appDbQuotaPath 返回 db 配额查询 URLdb/quota。
func appDbQuotaPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID))
}
// ── 变更追溯changelog / audit路由 ──
// appChangelogListPath 返回 DDL 变更记录列表 URLdb/changelog_list。
func appChangelogListPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID))
}
// appAuditStatusPath 返回表审计开关状态查询 URLdb/audit_status。
func appAuditStatusPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID))
}
// appAuditSetPath 返回表审计开关设置 URLdb/audit_set。
func appAuditSetPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID))
}
// appAuditListPath 返回行级审计事件列表 URLdb/audit_list。
func appAuditListPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/audit_list", apiBasePath, validate.EncodePathSegment(appID))
}
// operatorRef 是 operator 的 {id,name}。后端用 JSON 字符串内嵌透传CLI parse
// json 输出还原成对象下游能区分同名用户pretty 只取 name。
type operatorRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
// parseOperator 解析 operator 字符串空→nil非 JSON→{raw,raw}JSON→{id,name}name 空兜底 id
func parseOperator(raw string) *operatorRef {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
if !strings.HasPrefix(s, "{") {
return &operatorRef{ID: s, Name: s}
}
var o operatorRef
if json.Unmarshal([]byte(s), &o) != nil {
return &operatorRef{ID: s, Name: s}
}
if o.Name == "" {
o.Name = o.ID
}
return &o
}
// operatorName 取 operator 的展示名pretty空用 "—"。
func operatorName(op *operatorRef) string {
if op == nil || op.Name == "" {
return "—"
}
return op.Name
}
// safeParseJSON 把 before/after 的 JSON 字符串还原成结构化对象供下游消费;失败时透传原始串。
func safeParseJSON(s string) interface{} {
var v interface{}
if json.Unmarshal([]byte(s), &v) == nil {
return v
}
return s
}
// appDataImportPath 返回 db 数据导入 URL新增 db/ 域段路由)。
func appDataImportPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/data_import", apiBasePath, validate.EncodePathSegment(appID))
}
// appDataExportPath 返回 db 数据导出 URL返原始字节
func appDataExportPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/db/data_export", apiBasePath, validate.EncodePathSegment(appID))
}
// appTableRecordsPath 返回数据表记录列表 URL复用 GetAppTableRecordList其 total 即符合条件的记录总数)。
func appTableRecordsPath(appID, table string) string {
return appTablePath(appID, table) + "/records"
}
// resolveDataFormat 由文件扩展名推断数据格式。lark-cli 的 --format 已被框架占用(输出渲染),
// 故数据格式从文件名推断import 接受 csv/jsonexport 还接受 sql。
func resolveDataFormat(ext string, allowSQL bool) (string, error) {
raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
switch raw {
case "csv", "json":
return raw, nil
case "sql":
if allowSQL {
return "sql", nil
}
}
if allowSQL {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv, .json or .sql)", raw)
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv or .json)", raw)
}
// countDataRows 粗估数据行数(用于导入上限校验、导出兜底计数)。
// csv非空行数 - 1表头json顶层数组长度非数组算 1解析失败算 0。
func countDataRows(body []byte, format string) int {
if format == "csv" {
lines := 0
for _, ln := range strings.Split(string(body), "\n") {
if strings.TrimRight(ln, "\r") != "" {
lines++
}
}
if lines > 0 {
return lines - 1
}
return 0
}
var arr []json.RawMessage
if err := json.Unmarshal(body, &arr); err == nil {
return len(arr)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err == nil {
return 1
}
return 0
}
// requireAppID trims --app-id and rejects blank, returning a uniform validation error.
func requireAppID(raw string) (string, error) {
id := strings.TrimSpace(raw)
if id == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
return "", appsValidationParamError("--app-id", "--app-id is required")
}
return id, nil
}

View File

@@ -1,228 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
reTsRelative = regexp.MustCompile(`^([0-9]+)([smhdw])$`)
reTsDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`)
reTsLocalDateTime = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$`)
)
// normalizeTimestamp 实现设计原则三的 <timestamp> 多格式输入,统一归一化为 RFC3339 UTC
// - 相对30s / 5m / 2h / 3d / 1w从现在往前推
// - date2026-04-15本地时区 00:00:00
// - local datetime2026-04-15T10:00:00本地时区T 分隔)
// - ISO 8601 带 TZ...ZUTC/ ...+08:00显式偏移
//
// 归一化到 UTC 是必须的:服务端对无 TZ 的串按 UTC 裸解析,故 date / local datetime 的「本地」
// 语义只能在 CLI 端换算;相对时间服务端也不认。空串原样返回(调用方据此跳过该过滤)。
func normalizeTimestamp(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
if m := reTsRelative.FindStringSubmatch(s); m != nil {
n, _ := strconv.Atoi(m[1])
var unit time.Duration
switch m[2] {
case "s":
unit = time.Second
case "m":
unit = time.Minute
case "h":
unit = time.Hour
case "d":
unit = 24 * time.Hour
case "w":
unit = 7 * 24 * time.Hour
}
return time.Now().Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil
}
if reTsDate.MatchString(s) {
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid date %q", s)
}
return t.UTC().Format(time.RFC3339), nil
}
if reTsLocalDateTime.MatchString(s) {
t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid local datetime %q", s)
}
return t.UTC().Format(time.RFC3339), nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC().Format(time.RFC3339), nil
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
}
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件
//
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
func newFileTransferClient() *http.Client {
return &http.Client{Transport: http.DefaultTransport}
}
// URL helpers for the file (storage) CLI commands.
//
// 全部走 spark OpenAPIpath 形如 /open-apis/spark/v1/apps/{app_id}/storage/<name>。
// 路由段不含 HTTP 方法名file_get→file、file_delete→file_batch_remove、file_quota_get→file_quota
// appFileListPath 返回文件列表 URLstorage/file_list。
func appFileListPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_list", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileGetPath 返回单文件元数据 URLstorage/filefile_get→file路由不含方法名
func appFileGetPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileSignPath 返回临时签名下载 URL 生成接口storage/file_sign。
func appFileSignPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_sign", apiBasePath, validate.EncodePathSegment(appID))
}
// appFilePreUploadPath 返回上传预处理(取 presigned 直传地址URLstorage/file_pre_upload。
func appFilePreUploadPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_pre_upload", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileUploadCallbackPath 返回直传完成回调登记文件URLstorage/file_upload_callback。
func appFileUploadCallbackPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_upload_callback", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileBatchRemovePath 返回批量删除文件 URLstorage/file_batch_removefile_delete→file_batch_remove
func appFileBatchRemovePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_batch_remove", apiBasePath, validate.EncodePathSegment(appID))
}
// appFileQuotaPath 返回存储配额查询 URLstorage/file_quotafile_quota_get→file_quota
func appFileQuotaPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/storage/file_quota", apiBasePath, validate.EncodePathSegment(appID))
}
// requireFilePath trims --path and rejects blank, returning a uniform validation error.
func requireFilePath(raw string) (string, error) {
p := strings.TrimSpace(raw)
if p == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required").WithParam("--path")
}
return p, nil
}
// fileUser 是 uploaded_by 的 {id,name}。OpenAPI 以 created_by 的 JSON 字符串透传CLI parse。
type fileUser struct {
ID string `json:"id"`
Name string `json:"name"`
}
// fileInfo 是 file 命令对外输出的白名单字段。
// OpenAPI 字段 created_at / created_by → CLI 产品语义 uploaded_at / uploaded_by。
type fileInfo struct {
FileName string `json:"file_name"`
Path string `json:"path"`
SizeBytes interface{} `json:"size_bytes,omitempty"`
Type string `json:"type,omitempty"`
UploadedBy *fileUser `json:"uploaded_by,omitempty"`
UploadedAt string `json:"uploaded_at,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
}
// projectFileInfo 把 server 原始 file map 投影为 CLI fileInfocreated_*→uploaded_*)。
func projectFileInfo(m map[string]interface{}) fileInfo {
return fileInfo{
FileName: common.GetString(m, "file_name"),
Path: common.GetString(m, "path"),
SizeBytes: m["size_bytes"],
Type: common.GetString(m, "type"),
UploadedBy: parseFileUser(common.GetString(m, "created_by")),
UploadedAt: common.GetString(m, "created_at"),
DownloadURL: common.GetString(m, "download_url"),
}
}
// parseFileUser 解析 created_by 的 JSON 字符串 {id,name};空 / 非法 / 全空 → nil。
func parseFileUser(raw string) *fileUser {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
var u fileUser
if err := json.Unmarshal([]byte(s), &u); err != nil {
return nil
}
if u.ID == "" && u.Name == "" {
return nil
}
return &u
}
// normalizeTimeFlags 把若干时间 flag如 --since/--until/--uploaded-since就地归一化为 RFC3339 UTC
// 并回写,供 build*Params 透传。空 flag 跳过;非法格式 → validation 错误。复用 normalizeTimestamp。
func normalizeTimeFlags(rctx *common.RuntimeContext, flags ...string) error {
for _, f := range flags {
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
}
// dashIfEmpty 空白串用 "—" 占位pretty 列对齐)。
func dashIfEmpty(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
return s
}
// fileSizeDetail 把 size_bytes 渲染成 "24 KB (24580 bytes)"pretty 单文件详情用)。
func fileSizeDetail(raw interface{}) string {
n, ok := numericAsFloat(raw)
if !ok {
return "—"
}
return fmt.Sprintf("%s (%d bytes)", humanBytes(raw), int64(n))
}
// renderKeyValuePairs 输出对齐的 key: valuekey 列按最长 key 右填充)。
func renderKeyValuePairs(w io.Writer, pairs [][2]string) {
width := 0
for _, p := range pairs {
if dw := displayWidth(p[0]); dw > width {
width = dw
}
}
for _, p := range pairs {
io.WriteString(w, p[0]+":")
if pad := width - displayWidth(p[0]); pad > 0 {
io.WriteString(w, strings.Repeat(" ", pad))
}
io.WriteString(w, " "+p[1]+"\n")
}
}

View File

@@ -1,392 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)
// pluginResolveProjectPath resolves --project-path to an absolute path,
// defaulting to cwd when empty.
func pluginResolveProjectPath(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
if err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
}
return cwd, nil
}
if err := validate.RejectControlChars(raw, "--project-path"); err != nil {
return "", err
}
return filepath.Clean(raw), nil
}
// pluginCheckProjectDir validates that projectPath contains a package.json.
func pluginCheckProjectDir(projectPath string) error {
info, err := os.Stat(filepath.Join(projectPath, "package.json")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for project dir check.
if err != nil {
if os.IsNotExist(err) {
return appsFailedPreconditionError("package.json not found in %s", projectPath).
WithHint("run 'lark-cli apps +init' to initialize the project first")
}
return appsFileIOError(err, "cannot access package.json in %s", projectPath)
}
if !info.Mode().IsRegular() {
return appsFailedPreconditionError("package.json in %s is not a regular file", projectPath)
}
return nil
}
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
// 1. MIAODA_CAPABILITIES_DIR env var
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
// 2.5 Read .env.local for MIAODA_APP_TYPE
// 3. Detect by checking which directories exist under projectPath
func pluginResolveCapDir(projectPath string) (string, error) {
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
if filepath.IsAbs(dir) {
return dir, nil
}
return filepath.Join(projectPath, dir), nil
}
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
if appType == "" {
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
}
if appType == "6" {
return filepath.Join(projectPath, "shared", "capabilities"), nil
}
if appType != "" {
return filepath.Join(projectPath, "server", "capabilities"), nil
}
// 3. Directory detection
serverDir := filepath.Join(projectPath, "server", "capabilities")
sharedDir := filepath.Join(projectPath, "shared", "capabilities")
serverOK := pluginDirExists(serverDir)
sharedOK := pluginDirExists(sharedDir)
switch {
case serverOK && sharedOK:
return "", appsFailedPreconditionError(
"ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist",
).WithHint("set MIAODA_APP_TYPE or MIAODA_CAPABILITIES_DIR in .env.local to resolve ambiguity")
case serverOK:
return serverDir, nil
case sharedOK:
return sharedDir, nil
default:
return filepath.Join(projectPath, "server", "capabilities"), nil
}
}
// pluginReadEnvLocalValue reads a value from .env.local by key name.
func pluginReadEnvLocalValue(projectPath, key string) string {
data, err := os.ReadFile(filepath.Join(projectPath, ".env.local")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local env file read.
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok || strings.TrimSpace(k) != key {
continue
}
v = strings.TrimSpace(v)
v = strings.Trim(v, "\"'")
return v
}
return ""
}
func pluginDirExists(path string) bool {
info, err := os.Stat(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir existence check.
return err == nil && info.IsDir()
}
// pluginListCapabilities reads all *.json files from capDir.
// Returns nil (not error) if the directory does not exist.
func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
entries, err := os.ReadDir(capDir) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir listing.
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, appsFileIOError(err, "cannot read capabilities directory %s", capDir)
}
var caps []map[string]interface{}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(capDir, entry.Name())) //nolint:forbidigo
if err != nil {
continue
}
var cap map[string]interface{}
if err := json.Unmarshal(data, &cap); err != nil {
continue
}
caps = append(caps, cap)
}
return caps, nil
}
// pluginCheckDependentInstances scans the capabilities directory for instances
// that reference the given pluginKey. Returns nil if none found, an error with
// the list of dependent instance ids if any exist, or the underlying I/O error.
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
capDir, err := pluginResolveCapDir(projectPath)
if err != nil {
// No capabilities directory → no instances can exist → no conflict.
return nil
}
caps, err := pluginListCapabilities(capDir)
if err != nil {
// Cannot scan → best-effort, don't block.
return nil
}
var deps []string
for _, cap := range caps {
if pk, _ := cap["pluginKey"].(string); pk == pluginKey {
if id, _ := cap["id"].(string); id != "" {
deps = append(deps, id)
}
}
}
if len(deps) == 0 {
return nil
}
return appsFailedPreconditionError(
"plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "),
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
}
// pluginCheckInstalled verifies that the plugin package is installed in node_modules
// with a valid manifest.json.
func pluginCheckInstalled(projectPath, pluginKey string) error {
pluginDir := filepath.Join(projectPath, "node_modules", pluginKey)
manifestPath := filepath.Join(pluginDir, "manifest.json")
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check.
if os.IsNotExist(err) {
if pluginDirExists(pluginDir) {
return appsFailedPreconditionError(
"plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey,
).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey)
}
return appsFailedPreconditionError("plugin %q is not installed", pluginKey).
WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey)
}
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
}
return nil
}
// ── package.json helpers ──
// pluginReadPackageJSON reads and parses the project's package.json.
func pluginReadPackageJSON(projectPath string) (map[string]interface{}, error) {
path := filepath.Join(projectPath, "package.json")
data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package.json read.
if err != nil {
return nil, appsFileIOError(err, "cannot read package.json")
}
var pkg map[string]interface{}
if err := json.Unmarshal(data, &pkg); err != nil {
return nil, appsValidationError("invalid package.json: %v", err).WithCause(err)
}
return pkg, nil
}
// pluginWritePackageJSON writes package.json atomically, preserving formatting.
func pluginWritePackageJSON(projectPath string, pkg map[string]interface{}) error {
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return appsFileIOError(err, "cannot marshal package.json")
}
data = append(data, '\n')
return validate.AtomicWrite(filepath.Join(projectPath, "package.json"), data, 0o644)
}
// pluginGetActionPlugins extracts actionPlugins from package.json as key→version.
func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string {
raw, ok := pkg["actionPlugins"]
if !ok {
return nil
}
m, ok := raw.(map[string]interface{})
if !ok {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out
}
// pluginSetActionPlugin adds or updates a plugin entry in actionPlugins.
func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) {
m, ok := pkg["actionPlugins"].(map[string]interface{})
if !ok {
m = make(map[string]interface{})
pkg["actionPlugins"] = m
}
m[key] = version
}
// pluginRemoveActionPlugin removes a plugin entry from actionPlugins.
func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) {
m, ok := pkg["actionPlugins"].(map[string]interface{})
if !ok {
return
}
delete(m, key)
}
// pluginSyncActionPlugins ensures the actionPlugins record in package.json
// matches the actually installed version, even when install is skipped.
func pluginSyncActionPlugins(projectPath, key, version string) {
pkg, err := pluginReadPackageJSON(projectPath)
if err != nil {
return
}
ap := pluginGetActionPlugins(pkg)
if ap[key] == version {
return
}
pluginSetActionPlugin(pkg, key, version)
_ = pluginWritePackageJSON(projectPath, pkg)
}
// pluginCheckPeerDeps reads peerDependencies from the installed plugin's
// package.json and returns the names of any that are missing from node_modules.
func pluginCheckPeerDeps(projectPath, pluginKey string) []string {
pkgPath := filepath.Join(projectPath, "node_modules", pluginKey, "package.json")
data, err := os.ReadFile(pkgPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read.
if err != nil {
return nil
}
var pkg map[string]interface{}
if err := json.Unmarshal(data, &pkg); err != nil {
return nil
}
peerDeps, ok := pkg["peerDependencies"].(map[string]interface{})
if !ok || len(peerDeps) == 0 {
return nil
}
var missing []string
for dep := range peerDeps {
depDir := filepath.Join(projectPath, "node_modules", dep)
if !pluginDirExists(depDir) {
missing = append(missing, dep)
}
}
return missing
}
// pluginInstalledVersion reads the version of an installed plugin from its
// package.json in node_modules. Returns "" if not found or unreadable.
func pluginInstalledVersion(projectPath, pluginKey string) string {
path := filepath.Join(projectPath, "node_modules", pluginKey, "package.json")
data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read.
if err != nil {
return ""
}
var pkg map[string]interface{}
if err := json.Unmarshal(data, &pkg); err != nil {
return ""
}
v, _ := pkg["version"].(string)
return v
}
// ── tgz extraction ──
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
// first path component (npm convention: tarballs contain a "package/" prefix).
// Path traversal entries are silently skipped.
func pluginExtractTGZ(r io.Reader, destDir string) error {
gz, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
cleanDest := filepath.Clean(destDir) + string(filepath.Separator)
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("tar: %w", err)
}
name := pluginStripFirstComponent(hdr.Name)
if name == "" {
continue
}
if strings.Contains(name, "..") {
continue
}
target := filepath.Join(destDir, name)
if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanDest) &&
filepath.Clean(target) != filepath.Clean(destDir) {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:forbidigo
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) //nolint:forbidigo
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
f.Close()
return err
}
f.Close()
}
}
return nil
}
// pluginStripFirstComponent removes the first path component ("package/foo" → "foo").
func pluginStripFirstComponent(name string) string {
name = filepath.ToSlash(name)
if i := strings.Index(name, "/"); i >= 0 {
return name[i+1:]
}
return ""
}

View File

@@ -1,253 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
// --- pluginResolveProjectPath ---
func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) {
got, err := pluginResolveProjectPath("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cwd, _ := os.Getwd() //nolint:forbidigo
if got != cwd {
t.Errorf("got %q, want cwd %q", got, cwd)
}
}
func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
got, err := pluginResolveProjectPath("/tmp/myapp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/tmp/myapp" {
t.Errorf("got %q, want /tmp/myapp", got)
}
}
// --- pluginCheckProjectDir ---
func TestPluginCheckProjectDir_OK(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
if err := pluginCheckProjectDir(dir); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPluginCheckProjectDir_Missing(t *testing.T) {
dir := t.TempDir()
err := pluginCheckProjectDir(dir)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", p.Subtype)
}
}
// --- pluginResolveCapDir ---
func TestPluginResolveCapDir_EnvVar(t *testing.T) {
t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps")
got, err := pluginResolveCapDir("/proj")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join("/proj", "envdir/caps"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) {
t.Setenv("MIAODA_APP_TYPE", "2")
got, err := pluginResolveCapDir("/proj")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join("/proj", "server", "capabilities"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
t.Setenv("MIAODA_APP_TYPE", "6")
got, err := pluginResolveCapDir("/proj")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join("/proj", "shared", "capabilities"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
got, err := pluginResolveCapDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join(dir, "server", "capabilities"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
t.Fatal(err)
}
got, err := pluginResolveCapDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join(dir, "server", "capabilities"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
t.Fatal(err)
}
got, err := pluginResolveCapDir(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join(dir, "shared", "capabilities"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
t.Fatal(err)
}
_, err := pluginResolveCapDir(dir)
if err == nil {
t.Fatal("expected ambiguous error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", p.Subtype)
}
}
func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) {
dir := t.TempDir()
got, err := pluginResolveCapDir(dir)
if err != nil {
t.Fatalf("should default to server/capabilities, got error: %v", err)
}
if want := filepath.Join(dir, "server", "capabilities"); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) {
t.Setenv("MIAODA_APP_TYPE", "3")
got, err := pluginResolveCapDir("/proj")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := filepath.Join("/proj", "server", "capabilities"); got != want {
t.Errorf("got %q, want %q (appType=3 should use server)", got, want)
}
}
// --- pluginListCapabilities ---
func TestPluginListCapabilities_Empty(t *testing.T) {
dir := t.TempDir()
caps, err := pluginListCapabilities(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caps) != 0 {
t.Errorf("got %d caps, want 0", len(caps))
}
}
func TestPluginListCapabilities_DirNotExist(t *testing.T) {
caps, err := pluginListCapabilities("/nonexistent/path")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if caps != nil {
t.Errorf("got %v, want nil", caps)
}
}
func TestPluginListCapabilities_WithFiles(t *testing.T) {
dir := t.TempDir()
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
// non-JSON file should be skipped
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
caps, err := pluginListCapabilities(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caps) != 2 {
t.Fatalf("got %d caps, want 2", len(caps))
}
}
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
dir := t.TempDir()
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
caps, err := pluginListCapabilities(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caps) != 1 {
t.Fatalf("got %d caps, want 1", len(caps))
}
}
// --- helpers ---
func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) {
t.Helper()
b, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
}

View File

@@ -1,393 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsPluginInstall downloads a plugin package from the registry, extracts it
// to node_modules, and updates package.json actionPlugins.
//
// Without --name it batch-installs all plugins declared in actionPlugins that
// are not yet present in node_modules.
var AppsPluginInstall = common.Shortcut{
Service: appsService,
Command: "+plugin-install",
Description: "Install a plugin package (download, extract, update package.json)",
Risk: "write",
ConditionalScopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
Tips: []string{
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0",
"Example: lark-cli apps +plugin-install (install all declared plugins in package.json)",
},
Flags: []common.Flag{
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
{Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"},
{Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true},
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
key := strings.TrimSpace(rctx.Str("name"))
if key == "" {
return common.NewDryRunAPI().
POST(apiBasePath+"/plugin/versions/batch_query").
Desc("Batch-install all declared plugins from package.json actionPlugins").
Set("request_body", `{"plugin_keys": [<from actionPlugins>], "latest_only": false}`)
}
version := strings.TrimSpace(rctx.Str("version"))
isLatest := version == "" || version == "latest"
desc := fmt.Sprintf("Query version for %s, then download .tgz", key)
if isLatest {
desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key)
}
return common.NewDryRunAPI().
POST(apiBasePath+"/plugin/versions/batch_query").
Desc(desc).
Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)).
Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version))
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" {
return pluginInstallLocal(rctx, projectPath, localTgz)
}
key := strings.TrimSpace(rctx.Str("name"))
if key == "" {
return pluginInstallAll(ctx, rctx, projectPath)
}
version := strings.TrimSpace(rctx.Str("version"))
return pluginInstallOne(ctx, rctx, projectPath, key, version)
},
}
// pluginInstallOne installs a single plugin by key and optional version.
func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, key, version string) error {
if key == "" {
return appsValidationParamError("--name", "--name is required")
}
// Check if already installed with same version (pre-API fast path)
if version != "" && version != "latest" {
if installed := pluginInstalledVersion(projectPath, key); installed == version {
pluginSyncActionPlugins(projectPath, key, version)
result := map[string]interface{}{
"key": key, "version": version, "status": "already_installed",
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ %s@%s is already installed\n", key, version)
})
return nil
}
}
// Resolve version via API
resolvedVersion, err := pluginResolveVersion(ctx, rctx, key, version)
if err != nil {
return err
}
// Post-API check: latest may resolve to the already-installed version
if installed := pluginInstalledVersion(projectPath, key); installed == resolvedVersion {
pluginSyncActionPlugins(projectPath, key, resolvedVersion)
result := map[string]interface{}{
"key": key, "version": resolvedVersion, "status": "already_installed",
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ %s@%s is already up to date\n", key, resolvedVersion)
})
return nil
}
// Download tgz
tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion)
if err != nil {
return err
}
// Extract to node_modules
destDir := filepath.Join(projectPath, "node_modules", key)
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
return appsFileIOError(err, "cannot clean %s", destDir)
}
if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint:forbidigo
return appsFileIOError(err, "cannot create %s", destDir)
}
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
return appsFileIOError(err, "cannot extract plugin package for %s", key)
}
// Check peer dependencies
missingPeers := pluginCheckPeerDeps(projectPath, key)
// Update package.json
pkg, err := pluginReadPackageJSON(projectPath)
if err != nil {
return err
}
pluginSetActionPlugin(pkg, key, resolvedVersion)
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
return appsFileIOError(err, "cannot update package.json")
}
result := map[string]interface{}{
"key": key, "version": resolvedVersion, "status": "installed",
}
if len(missingPeers) > 0 {
result["missing_peer_dependencies"] = missingPeers
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion)
if len(missingPeers) > 0 {
fmt.Fprintf(w, "⚠ Missing peer dependencies: %s\n", strings.Join(missingPeers, ", "))
fmt.Fprintln(w, " Run 'npm install' in the project directory to install them.")
}
})
return nil
}
// pluginInstallAll installs all plugins declared in actionPlugins that are
// missing from node_modules.
func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectPath string) error {
pkg, err := pluginReadPackageJSON(projectPath)
if err != nil {
return err
}
declared := pluginGetActionPlugins(pkg)
if len(declared) == 0 {
rctx.OutFormat(map[string]interface{}{"installed": 0}, nil, func(w io.Writer) {
fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.")
})
return nil
}
var installed int
for key, version := range declared {
existing := pluginInstalledVersion(projectPath, key)
if existing != "" && existing == version {
continue
}
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
return fmt.Errorf("install %s: %w", key, err)
}
installed++
}
if installed == 0 {
rctx.OutFormat(map[string]interface{}{"installed": 0, "status": "all_up_to_date"}, nil, func(w io.Writer) {
fmt.Fprintln(w, "All declared plugins are already installed.")
})
}
return nil
}
// pluginInstallLocal installs a plugin from a local .tgz file, skipping API calls.
// Reads plugin key and version from the extracted package.json inside the tgz.
func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error {
tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read.
if err != nil {
return appsValidationParamError("--file", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err)
}
// Extract to a temp dir first to read package.json
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
if err != nil {
return appsFileIOError(err, "cannot create temp dir")
}
defer os.RemoveAll(tmpDir) //nolint:forbidigo
if err := pluginExtractTGZ(bytes.NewReader(tgzData), tmpDir); err != nil {
return appsFileIOError(err, "cannot extract tgz")
}
// Read key and version from extracted package.json
pkgData, err := os.ReadFile(filepath.Join(tmpDir, "package.json")) //nolint:forbidigo
if err != nil {
return appsFileIOError(err, "tgz does not contain package.json")
}
var pkgMeta map[string]interface{}
if err := json.Unmarshal(pkgData, &pkgMeta); err != nil {
return appsFileIOError(err, "invalid package.json in tgz")
}
key, _ := pkgMeta["name"].(string)
version, _ := pkgMeta["version"].(string)
if key == "" {
return appsValidationParamError("--file", "package.json in tgz missing 'name' field")
}
if version == "" {
version = "0.0.0"
}
// Move to node_modules
destDir := filepath.Join(projectPath, "node_modules", key)
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
return appsFileIOError(err, "cannot clean %s", destDir)
}
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { //nolint:forbidigo
return appsFileIOError(err, "cannot create parent dir for %s", destDir)
}
if err := os.Rename(tmpDir, destDir); err != nil { //nolint:forbidigo
// rename may fail across filesystems; fall back to re-extract
if err2 := os.MkdirAll(destDir, 0o755); err2 != nil { //nolint:forbidigo
return appsFileIOError(err2, "cannot create %s", destDir)
}
if err2 := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err2 != nil {
return appsFileIOError(err2, "cannot extract plugin to %s", destDir)
}
}
// Update package.json actionPlugins
pkg, err := pluginReadPackageJSON(projectPath)
if err != nil {
return err
}
pluginSetActionPlugin(pkg, key, version)
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
return appsFileIOError(err, "cannot update package.json")
}
result := map[string]interface{}{
"key": key, "version": version, "status": "installed", "source": "local",
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Installed %s@%s (from local %s)\n", key, version, tgzPath)
})
return nil
}
// pluginResolveVersion calls the batch_query API to resolve version info.
func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion string, err error) {
isLatest := version == "" || version == "latest"
body := map[string]interface{}{
"plugin_keys": []interface{}{key},
"latest_only": isLatest,
}
data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_query", nil, body)
if err != nil {
p, ok := errs.ProblemOf(err)
if ok && p.Subtype == errs.SubtypeInvalidResponse {
p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key)
p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team"
return "", err
}
return "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key))
}
// Response: data.items is a flat list of plugin_version objects
match := pluginFindVersionInItems(data, key, version)
if match == nil {
hint := "check plugin key spelling"
if !isLatest {
hint = fmt.Sprintf("version %q not found for %s; omit --version to install latest", version, key)
}
return "", appsValidationError("no version found for plugin %q", key).
WithHint(hint)
}
// API returns "version" (not "plugin_version")
rv, _ := match["version"].(string)
if rv == "" {
return "", appsValidationError("incomplete version info for plugin %q", key).
WithHint("API returned version info without version field; contact plugin maintainer")
}
return rv, nil
}
// pluginFindVersionInItems extracts data.items and finds a matching version.
func pluginFindVersionInItems(data map[string]interface{}, key, version string) map[string]interface{} {
raw, ok := data["items"]
if !ok {
return nil
}
arr, ok := raw.([]interface{})
if !ok {
return nil
}
isLatest := version == "" || version == "latest"
for _, v := range arr {
item, ok := v.(map[string]interface{})
if !ok {
continue
}
// API returns "key" (not "plugin_key")
pk, _ := item["key"].(string)
if pk != key {
continue
}
if isLatest {
return item
}
pv, _ := item["version"].(string)
if pv == version {
return item
}
}
return nil
}
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
apiPath := apiBasePath + "/plugin/versions/download_package"
body, _ := json.Marshal(map[string]string{
"plugin_key": key,
"plugin_version": version,
})
resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: apiPath,
Body: bytes.NewReader(body),
})
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
WithHint("check network connectivity and retry").
WithRetryable().
WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed for %s@%s: HTTP %d", key, version, resp.StatusCode).
WithHint("plugin registry returned a server error; retry after a short wait").
WithRetryable()
}
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
hint := "check plugin key and version spelling"
if resp.StatusCode == 403 {
hint = "download token may have expired; retry the install to get a fresh token"
} else if resp.StatusCode == 404 {
hint = fmt.Sprintf("package %s@%s not found in registry; check plugin key and version", key, version)
}
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
WithHint(hint)
}
return io.ReadAll(resp.Body)
}

View File

@@ -1,181 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestPluginInstall_SinglePlugin(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{})
chdirTest(t, dir)
factory, stdout, reg := newAppsExecuteFactory(t)
// Mock batch_query API (new protocol: plugin_keys array, response data.items flat list)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/plugin/versions/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"key": "@test/my-plugin",
"version": "1.0.0",
"download_approach": "inner",
"status": "active",
},
},
},
},
})
// Mock download API (POST with JSON body, returns binary tgz)
tgzData := buildTestTGZ(t, map[string]string{
"manifest.json": `{"actions":[]}`,
"package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/plugin/versions/download_package",
RawBody: tgzData,
ContentType: "application/octet-stream",
})
err := runAppsShortcut(t, AppsPluginInstall, []string{
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify file extracted
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
t.Fatalf("manifest.json not extracted: %v", err)
}
// Verify package.json updated
pkg, _ := pluginReadPackageJSON(dir)
ap := pluginGetActionPlugins(pkg)
if v := ap["@test/my-plugin"]; v != "1.0.0" {
t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v)
}
// Verify output
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
if data["status"] != "installed" {
t.Errorf("status = %v, want installed", data["status"])
}
}
func TestPluginInstall_AlreadyInstalled(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/my-plugin": "1.0.0",
},
})
// Create an existing installed plugin with package.json containing version
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginInstall, []string{
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
if data["status"] != "already_installed" {
t.Errorf("status = %v, want already_installed", data["status"])
}
}
// --- tgz helpers ---
func TestPluginExtractTGZ(t *testing.T) {
tgzData := buildTestTGZ(t, map[string]string{
"manifest.json": `{"actions":[]}`,
"README.md": "# Hello",
})
destDir := t.TempDir()
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
t.Fatalf("extract error: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
if err != nil {
t.Fatalf("manifest.json not extracted: %v", err)
}
if string(data) != `{"actions":[]}` {
t.Errorf("manifest.json content = %q", string(data))
}
}
func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
tw.WriteHeader(&tar.Header{
Name: "package/../../../etc/passwd",
Size: 5,
Mode: 0o644,
Typeflag: tar.TypeReg,
})
tw.Write([]byte("evil!"))
tw.Close()
gz.Close()
destDir := t.TempDir()
if err := pluginExtractTGZ(&buf, destDir); err != nil {
t.Fatalf("extract should not error, but skip bad entries: %v", err)
}
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
t.Error("path traversal should have been blocked")
}
}
// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix.
func buildTestTGZ(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, content := range files {
tw.WriteHeader(&tar.Header{
Name: "package/" + name,
Size: int64(len(content)),
Mode: 0o644,
Typeflag: tar.TypeReg,
})
tw.Write([]byte(content))
}
tw.Close()
gz.Close()
return buf.Bytes()
}

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsPluginList lists plugin packages declared in package.json actionPlugins,
// cross-referencing with node_modules to report installation status.
var AppsPluginList = common.Shortcut{
Service: appsService,
Command: "+plugin-list",
Description: "List declared plugin packages and their installation status",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +plugin-list",
"Example: lark-cli apps +plugin-list --format pretty",
},
Flags: []common.Flag{},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List declared plugin packages and installation status").
Set("action", "list").
Set("source", "package.json actionPlugins + node_modules")
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
pkg, err := pluginReadPackageJSON(projectPath)
if err != nil {
return err
}
declared := pluginGetActionPlugins(pkg)
plugins := make([]interface{}, 0, len(declared))
for key, version := range declared {
installed := pluginInstalledVersion(projectPath, key)
status := "declared_not_installed"
if installed != "" {
status = "installed"
}
plugins = append(plugins, map[string]interface{}{
"key": key,
"version": version,
"status": status,
})
}
data := map[string]interface{}{"plugins": plugins}
rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) {
if len(plugins) == 0 {
fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.")
return
}
rows := make([]map[string]interface{}, 0, len(plugins))
for _, p := range plugins {
rows = append(rows, p.(map[string]interface{}))
}
output.PrintTable(w, rows)
})
return nil
},
}

View File

@@ -1,121 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestPluginList_Empty(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginList, []string{
"+plugin-list", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
plugins, _ := data["plugins"].([]interface{})
if len(plugins) != 0 {
t.Errorf("expected 0 plugins, got %d", len(plugins))
}
}
func TestPluginList_Installed(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/my-plugin": "1.0.0",
},
})
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginList, []string{
"+plugin-list", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
plugins, _ := data["plugins"].([]interface{})
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
p := plugins[0].(map[string]interface{})
if p["status"] != "installed" {
t.Errorf("status = %v, want installed", p["status"])
}
}
func TestPluginList_DeclaredNotInstalled(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/missing": "1.0.0",
},
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginList, []string{
"+plugin-list", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
plugins, _ := data["plugins"].([]interface{})
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
p := plugins[0].(map[string]interface{})
if p["status"] != "declared_not_installed" {
t.Errorf("status = %v, want declared_not_installed", p["status"])
}
}
// --- helpers ---
func chdirTest(t *testing.T, dir string) {
t.Helper()
prev, err := os.Getwd() //nolint:forbidigo
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
}
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
t.Helper()
data, err := json.Marshal(pkg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsPluginUninstall removes a plugin package from node_modules and its
// entry from package.json actionPlugins.
var AppsPluginUninstall = common.Shortcut{
Service: appsService,
Command: "+plugin-uninstall",
Description: "Uninstall a plugin package (remove from node_modules and package.json)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
},
Flags: []common.Flag{
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true},
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
key := strings.TrimSpace(rctx.Str("name"))
return common.NewDryRunAPI().
Desc("Uninstall plugin package (remove from node_modules and package.json)").
Set("action", "uninstall").
Set("plugin_key", key).
Set("remove_dir", fmt.Sprintf("node_modules/%s", key)).
Set("update_file", "package.json actionPlugins")
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required")
}
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
key := strings.TrimSpace(rctx.Str("name"))
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
// Block uninstall if any instances still reference this plugin package.
if err := pluginCheckDependentInstances(projectPath, key); err != nil {
return err
}
pkgDir := filepath.Join(projectPath, "node_modules", key)
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
return appsFileIOError(err, "cannot remove %s", pkgDir)
}
pkg, err := pluginReadPackageJSON(projectPath)
if err != nil {
return err
}
pluginRemoveActionPlugin(pkg, key)
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
return appsFileIOError(err, "cannot update package.json")
}
result := map[string]interface{}{
"key": key,
"removed": true,
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key)
})
return nil
},
}

View File

@@ -1,187 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
func TestPluginUninstall_Basic(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/my-plugin": "1.0.0",
},
})
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/my-plugin",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify node_modules removed
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
t.Error("node_modules plugin dir should be removed")
}
// Verify package.json updated
pkg, _ := pluginReadPackageJSON(dir)
ap := pluginGetActionPlugins(pkg)
if _, ok := ap["@test/my-plugin"]; ok {
t.Error("actionPlugins should no longer contain @test/my-plugin")
}
}
func TestPluginUninstall_NotInstalled(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/not-here",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("uninstalling non-existent plugin should succeed: %v", err)
}
var env map[string]interface{}
json.Unmarshal(stdout.Bytes(), &env)
data, _ := env["data"].(map[string]interface{})
if data["removed"] != true {
t.Errorf("removed = %v, want true", data["removed"])
}
}
func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/my-plugin": "1.0.0",
},
})
// Install plugin
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
// Create a capability that references this plugin
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{
"id": "my-instance",
"pluginKey": "@test/my-plugin",
"name": "My Instance",
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/my-plugin",
"--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil")
}
// Verify plugin directory still exists (blocked)
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
t.Errorf("plugin directory should still exist after blocked uninstall: %v", err)
}
// Verify error mentions the dependent instance
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed error, got %v", err)
}
if prob.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", prob.Subtype, errs.SubtypeFailedPrecondition)
}
if prob.Hint == "" {
t.Error("hint should be non-empty")
}
}
func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"actionPlugins": map[string]interface{}{
"@test/my-plugin": "1.0.0",
},
})
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
// Create a capability that references a DIFFERENT plugin — should not block
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{
"id": "other-instance",
"pluginKey": "@test/other-plugin",
"name": "Other Instance",
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/my-plugin",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err)
}
// Verify plugin was removed
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
t.Error("plugin directory should be removed")
}
}
func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) {
dir := t.TempDir()
writeTestPkgJSON(t, dir, map[string]interface{}{
"name": "my-app",
"actionPlugins": map[string]interface{}{
"@test/remove-me": "1.0.0",
"@test/keep-me": "2.0.0",
},
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/remove-me",
"--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
pkg, _ := pluginReadPackageJSON(dir)
ap := pluginGetActionPlugins(pkg)
if _, ok := ap["@test/remove-me"]; ok {
t.Error("@test/remove-me should be removed from actionPlugins")
}
if v, ok := ap["@test/keep-me"]; !ok || v != "2.0.0" {
t.Errorf("@test/keep-me should be preserved, got %q", v)
}
if name, _ := pkg["name"].(string); name != "my-app" {
t.Errorf("other fields should be preserved, name = %q", name)
}
}

View File

@@ -7,9 +7,6 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all apps domain shortcuts.
func Shortcuts() []common.Shortcut {
envSet := withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +env-set --app-id <app_id> --environment online --key FOO --value <value> --yes")
envDelete := withExtraTips(AppsEnvVarDelete, "Tip: +env-delete is high-risk-write; only pass --yes after explicit confirmation.")
return []common.Shortcut{
AppsCreate,
AppsUpdate,
@@ -22,38 +19,10 @@ func Shortcuts() []common.Shortcut {
AppsReleaseList,
AppsReleaseGet,
AppsEnvPull,
withExtraTips(AppsLogList, "Tip: logs are online-only; keep --environment omitted or set --environment online."),
withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."),
withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."),
withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."),
withExtraTips(AppsMetricList, "Tip: metrics are online-only; keep --environment omitted or set --environment online."),
withExtraTips(AppsAnalyticsList, "Tip: analytics are online-only; keep --environment omitted or set --environment online."),
AppsEnvVarList,
envSet,
envDelete,
AppsDBTableList,
AppsDBTableGet,
AppsDBExecute,
AppsDBEnvCreate,
AppsDBDataImport,
AppsDBDataExport,
AppsDBChangelogList,
AppsDBAuditStatus,
AppsDBAuditEnable,
AppsDBAuditDisable,
AppsDBAuditList,
AppsDBEnvDiff,
AppsDBEnvMigrate,
AppsDBRecoveryDiff,
AppsDBRecoveryApply,
AppsDBQuotaGet,
AppsFileList,
AppsFileGet,
AppsFileSign,
AppsFileDownload,
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,
@@ -63,22 +32,5 @@ func Shortcuts() []common.Shortcut {
AppsSessionStop,
AppsSessionMessagesList,
AppsChat,
AppsPluginInstall,
AppsPluginUninstall,
AppsPluginList,
// open API key management
AppsOpenAPIKeyList,
AppsOpenAPIKeyGet,
AppsOpenAPIKeyCreate,
AppsOpenAPIKeyUpdate,
AppsOpenAPIKeyEnable,
AppsOpenAPIKeyDisable,
AppsOpenAPIKeyDelete,
AppsOpenAPIKeyReset,
}
}
func withExtraTips(sc common.Shortcut, tips ...string) common.Shortcut {
sc.Tips = append(append([]string{}, sc.Tips...), tips...)
return sc
}

View File

@@ -10,60 +10,12 @@ import (
)
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
// 6 基础 + 1 init + 3 publish + 1 env-pull
// - 6 observabilitylog-list/log-get/trace-list/trace-get/metric-list/analytics-list
// - 3 envlist/set/delete
// - 16 dbtable-list/table-schema/sql/dev-init/data-import/data-export/changelog-list/
// audit-status/audit-enable/audit-disable/audit-list/
// env-diff/env-migrate/recovery-diff/recovery-apply/quota-get
// - 7 filelist/get/sign/download/upload/delete/quota-get
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/uninstall/list= 63。
func TestAppsShortcuts_Returns63(t *testing.T) {
// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 dbtable-list/table-schema/sql/dev-init
// + 3 git-credential + 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list = 24。
func TestAppsShortcuts_Returns24(t *testing.T) {
got := Shortcuts()
if len(got) != 63 {
t.Fatalf("Shortcuts() returned %d entries, want 63", len(got))
}
}
func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) {
for _, sc := range Shortcuts() {
switch sc.Command {
case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete":
t.Fatalf("Shortcuts() must not register %s", sc.Command)
}
}
}
func TestAppsShortcuts_DoesNotIncludeMetricQueryAliases(t *testing.T) {
for _, sc := range Shortcuts() {
switch sc.Command {
case "+metric-query", "+analytics-query":
t.Fatalf("Shortcuts() must not register %s", sc.Command)
}
}
}
func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) {
want := map[string]bool{
"+env-list": false,
"+env-set": false,
"+env-delete": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing canonical %s", cmd)
}
if len(got) != 24 {
t.Fatalf("Shortcuts() returned %d entries, want 24", len(got))
}
}
@@ -88,7 +40,6 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
}
}
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.Command == "git-credential-helper" {
@@ -97,21 +48,18 @@ func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
}
}
// TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes 确认 git credential remove 是本地清理、不带任何 scope。
func TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes(t *testing.T) {
if len(AppsGitCredentialRemove.Scopes) != 0 {
t.Fatalf("git credential remove scopes = %#v, want none for local cleanup", AppsGitCredentialRemove.Scopes)
}
}
// TestAppsGitCredentialList_IsLocalReadWithoutScopes 确认 git credential list 是本地读取、不带任何 scope。
func TestAppsGitCredentialList_IsLocalReadWithoutScopes(t *testing.T) {
if len(AppsGitCredentialList.Scopes) != 0 {
t.Fatalf("git credential list scopes = %#v, want none for local read", AppsGitCredentialList.Scopes)
}
}
// TestInstallOnApps_AddsHiddenGitCredentialHelper 验证 InstallOnApps 挂载一个隐藏、带 RunE 且独立于 shortcut 管线的 git-credential-helper 命令。
func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) {
parent := &cobra.Command{Use: "apps"}
InstallOnApps(parent, nil)

View File

@@ -1,215 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
package calendar
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const meetingLogPrefix = "[calendar +meeting]"
// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
type mgetInstanceRelationRequestBody struct {
InstanceIDs []string `json:"instance_ids"`
NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
NeedMeetingNotes bool `json:"need_meeting_notes"`
NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
}
// meetingInfoItem represents a single event's meeting info in the output.
type meetingInfoItem struct {
EventID string `json:"event_id"`
MeetingID string `json:"meeting_id,omitempty"`
MeetingNote string `json:"meeting_note,omitempty"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
}
// translateFailMsg converts API fail_msg to a user-friendly error message.
func translateFailMsg(failMsg string) string {
switch failMsg {
case "No Permission":
return "no read permission for this calendar event (not a participant of the event)"
case "Not Found":
return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
default:
return failMsg
}
}
// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
body := &mgetInstanceRelationRequestBody{
InstanceIDs: []string{instanceID},
NeedMeetingInstanceIDs: true,
NeedMeetingNotes: true,
NeedAIMeetingNotes: false,
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
nil, body)
if err != nil {
msg := unwrapCalendarAPIError(err)
if msg == "" {
msg = err.Error()
}
return &meetingInfoItem{EventID: instanceID, Error: msg}
}
// Check for failed instance IDs first
if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
for _, raw := range failedIDs {
if failInfo, ok := raw.(map[string]any); ok {
if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
failMsg, _ := failInfo["fail_msg"].(string)
return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
}
}
}
}
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
}
info, _ := infos[0].(map[string]any)
result := &meetingInfoItem{EventID: instanceID}
// Extract meeting_id (return first if multiple) — API returns string
if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
if id, ok := rawIDs[0].(string); ok && id != "" {
result.MeetingID = id
}
}
// Extract meeting_note (return first if multiple)
if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
if note, ok := notes[0].(string); ok && note != "" {
result.MeetingNote = note
}
}
// Add hints for empty resources (independent checks)
var emptyFields []string
if result.MeetingID == "" {
emptyFields = append(emptyFields, "meeting_id")
}
if result.MeetingNote == "" {
emptyFields = append(emptyFields, "meeting_note")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
}
return result
}
// CalendarMeeting gets meeting info for calendar events.
var CalendarMeeting = common.Shortcut{
Service: "calendar",
Command: "+meeting",
Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
ids := common.SplitCSV(runtime.Str("event-ids"))
const maxBatchSize = 50
if len(ids) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID := runtime.Str("calendar-id")
if calendarID == "" {
calendarID = "<primary>"
}
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
Set("calendar_id", calendarID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
results := make([]*meetingInfoItem, 0, len(instanceIDs))
fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
for _, id := range instanceIDs {
if err := ctx.Err(); err != nil {
return err
}
fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"meetings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No events.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"event_id": r.EventID}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
if r.MeetingID != "" {
row["meeting_id"] = r.MeetingID
}
if r.MeetingNote != "" {
row["meeting_note"] = r.MeetingNote
}
if r.Hint != "" {
row["hint"] = r.Hint
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -1,484 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
var calWarmOnce sync.Once
func calWarmTokenCache(t *testing.T) {
t.Helper()
calWarmOnce.Do(func() {
f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/warm",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
s := common.Shortcut{
Service: "test",
Command: "+warm",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
return err
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+warm"})
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.Execute()
})
}
func calDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_testuser",
}
}
func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
calWarmTokenCache(t)
parent := &cobra.Command{Use: "calendar"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// ---------------------------------------------------------------------------
// calendar +meeting tests
// ---------------------------------------------------------------------------
func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
infos := map[string]interface{}{
"instance_id": instanceID,
}
mIDs := make([]interface{}, len(meetingIDs))
for i, id := range meetingIDs {
mIDs[i] = id
}
infos["meeting_instance_ids"] = mIDs
if len(meetingNotes) > 0 {
notes := make([]interface{}, len(meetingNotes))
for i, n := range meetingNotes {
notes[i] = n
}
infos["meeting_notes"] = notes
}
if len(aiMeetingNotes) > 0 {
notes := make([]interface{}, len(aiMeetingNotes))
for i, n := range aiMeetingNotes {
notes[i] = n
}
infos["ai_meeting_notes"] = notes
}
return &httpmock.Stub{
Method: "POST",
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{infos},
},
},
}
}
func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{},
"failed_instance_ids": []interface{}{
map[string]interface{}{
"instance_id": instanceID,
"fail_msg": failMsg,
},
},
},
},
}
}
func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --event-ids")
}
}
func TestMeeting_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("evt%d", i)
}
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
}
func TestMeeting_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
}
}
func TestMeeting_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if m["meeting_id"] != "123456" {
t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
}
if m["meeting_note"] != "doc_note1" {
t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
}
if _, hasAI := m["ai_meeting_note"]; hasAI {
t.Error("ai_meeting_note should not be present in output")
}
}
func TestMeeting_Execute_FailedInstance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
// Verify translated fail_msg appears in output
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) > 0 {
m, _ := meetings[0].(map[string]any)
if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
t.Errorf("expected translated fail_msg, got: %v", errMsg)
}
}
}
}
func TestMeeting_Execute_NoMeeting(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
t.Errorf("expected hint about meeting_id, got: %v", hint)
}
}
// ---------------------------------------------------------------------------
// calendar +search-event tests
// ---------------------------------------------------------------------------
func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid --start")
}
if !strings.Contains(err.Error(), "--start") {
t.Errorf("unexpected error: %v", err)
}
}
func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for start after end")
}
}
func TestSearchEvent_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "search_event") {
t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
}
}
func TestSearchEvent_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"display_info": "Q2 周会\n2026-04-23 15:00-16:00",
"meta_data": map[string]interface{}{
"event_id": "evt_search1",
"summary": "Q2 周会",
"start": map[string]interface{}{
"date_time": "2026-04-23T15:00:00+08:00",
"timezone": "Asia/Shanghai",
},
"end": map[string]interface{}{
"date_time": "2026-04-23T16:00:00+08:00",
"timezone": "Asia/Shanghai",
},
"is_all_day": false,
"app_link": "https://applink.feishu.cn/...",
},
},
},
"has_more": false,
"page_token": "",
},
},
})
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
if data["calendar_id"] != "primary" {
t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
}
items, _ := data["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
item, _ := items[0].(map[string]any)
if item["event_id"] != "evt_search1" {
t.Errorf("event_id = %v, want evt_search1", item["event_id"])
}
if item["summary"] != "Q2 周会" {
t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
}
}
func TestSearchEvent_Execute_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
"has_more": false,
},
},
})
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestParseSearchEventTimeRange(t *testing.T) {
tests := []struct {
name string
start string
end string
wantErr bool
}{
{"empty", "", "", false},
{"valid", "2026-04-20", "2026-04-27", false},
{"start only defaults end", "2026-04-20", "", false},
{"end only defaults start", "", "2026-04-27", false},
{"invalid start format", "not-a-date", "2026-04-27", true},
{"start after end", "2026-04-27", "2026-04-20", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
_, _, err := parseSearchEventTimeRange(runtime)
if (err != nil) != tt.wantErr {
t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
t.Run("start only fills end with end-of-day", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("start", "2026-04-20")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
}
if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
}
})
t.Run("end only fills start with start-of-day", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("end", "2026-04-27")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
}
if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
}
})
}
func TestBuildSearchEventFilter(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
_ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "", "")
if filter == nil {
t.Fatal("expected filter to be non-nil")
}
if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
}
if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
}
if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
}
}
func TestBuildSearchEventFilter_Empty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "", "")
if filter != nil {
t.Errorf("expected nil for empty filter, got %v", filter)
}
}
func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
if filter == nil {
t.Fatal("expected filter to be non-nil")
}
if filter.TimeRange == nil {
t.Fatal("expected time_range in filter")
}
if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
}
}

View File

@@ -66,8 +66,7 @@ type roomFindSlot struct {
type roomFindTimeSlot struct {
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
Hint string `json:"hint,omitempty"`
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
}
type roomFindOutput struct {
@@ -104,18 +103,11 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
}
return
}
if suggestions == nil {
suggestions = []*roomFindSuggestion{}
}
ts := &roomFindTimeSlot{
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
Start: slot.Start,
End: slot.End,
MeetingRooms: suggestions,
}
if len(suggestions) == 0 {
ts.Hint = "no meeting room matches the current filters for this slot"
}
out.TimeSlots = append(out.TimeSlots, ts)
})
}(slot)
}
wg.Wait()
@@ -382,10 +374,6 @@ var CalendarRoomFind = common.Shortcut{
}
for _, slot := range out.TimeSlots {
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
if len(slot.MeetingRooms) == 0 {
fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
continue
}
var rows []map[string]interface{}
for _, room := range slot.MeetingRooms {
rows = append(rows, map[string]interface{}{
@@ -396,7 +384,6 @@ var CalendarRoomFind = common.Shortcut{
})
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
fmt.Fprintln(w)
}
})

View File

@@ -4,8 +4,6 @@
package calendar
import (
"encoding/json"
"strings"
"testing"
"time"
)
@@ -84,60 +82,3 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
}
}
func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
slots := []roomFindSlot{
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
}
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
if strings.HasPrefix(slot.Start, "2026-03-27T14") {
return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
}
return nil, nil
})
if err != nil {
t.Fatalf("collectRoomFindResults returned error: %v", err)
}
if len(out.TimeSlots) != 2 {
t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
}
for _, ts := range out.TimeSlots {
if ts.MeetingRooms == nil {
t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
}
switch {
case strings.HasPrefix(ts.Start, "2026-03-27T14"):
if len(ts.MeetingRooms) != 1 {
t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
}
if ts.Hint != "" {
t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
}
case strings.HasPrefix(ts.Start, "2026-03-27T15"):
if len(ts.MeetingRooms) != 0 {
t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
}
if ts.Hint == "" {
t.Fatal("empty slot should carry a hint explaining the filters")
}
}
}
emptySlot := out.TimeSlots[0]
if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
emptySlot = out.TimeSlots[1]
}
raw, err := json.Marshal(emptySlot)
if err != nil {
t.Fatalf("marshal empty slot: %v", err)
}
if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
}
if !strings.Contains(string(raw), `"hint"`) {
t.Fatalf("expected hint field in JSON, got %s", raw)
}
}

View File

@@ -1,331 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +search-event — search calendar events by keyword, time range, and attendees
package calendar
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultSearchEventPageSize = 20
maxSearchEventPageSize = 30
)
// searchEventTimeRange represents the time range filter for search_event API.
type searchEventTimeRange struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
}
// searchEventFilter represents the filter object for the search_event API request.
type searchEventFilter struct {
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
}
// searchEventRequestBody is the request body for the search_event API.
type searchEventRequestBody struct {
Query string `json:"query"`
Filter *searchEventFilter `json:"filter,omitempty"`
}
// searchEventTimeInfo represents start/end time info in the search result.
type searchEventTimeInfo struct {
Date string `json:"date,omitempty"`
DateTime string `json:"date_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// searchEventItem represents a single event in the search result output.
type searchEventItem struct {
EventID string `json:"event_id"`
Summary string `json:"summary"`
Start *searchEventTimeInfo `json:"start,omitempty"`
End *searchEventTimeInfo `json:"end,omitempty"`
IsAllDay bool `json:"is_all_day,omitempty"`
AppLink string `json:"app_link,omitempty"`
}
// searchEventOutput is the structured output for +search-event.
type searchEventOutput struct {
CalendarID string `json:"calendar_id"`
Items []searchEventItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
}
// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
// When only one side is provided, the other defaults to the same day's
// boundary (start → end-of-day, end → start-of-day).
func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
startInput := strings.TrimSpace(runtime.Str("start"))
endInput := strings.TrimSpace(runtime.Str("end"))
if startInput == "" && endInput == "" {
return "", "", nil
}
var startSec, endSec int64
if startInput != "" {
ts, err := common.ParseTime(startInput)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startSec, _ = strconv.ParseInt(ts, 10, 64)
}
if endInput != "" {
ts, err := common.ParseTime(endInput, "end")
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endSec, _ = strconv.ParseInt(ts, 10, 64)
}
if startInput == "" {
t := time.Unix(endSec, 0).In(time.Local)
startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
}
if endInput == "" {
t := time.Unix(startSec, 0).In(time.Local)
endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
}
if startSec > endSec {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
}
return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
}
// buildSearchEventFilter builds the filter object for the search_event API.
func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
var userIDs, chatIDs, roomIDs []string
for _, id := range attendeeIDs {
switch {
case strings.HasPrefix(id, "ou_"):
userIDs = append(userIDs, id)
case strings.HasPrefix(id, "oc_"):
chatIDs = append(chatIDs, id)
case strings.HasPrefix(id, "omm_"):
roomIDs = append(roomIDs, id)
default:
userIDs = append(userIDs, id)
}
}
var tr *searchEventTimeRange
if startTime != "" || endTime != "" {
tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
}
if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
return nil
}
return &searchEventFilter{
AttendeeUserIDs: userIDs,
AttendeeChatIDs: chatIDs,
MeetingRoomIDs: roomIDs,
TimeRange: tr,
}
}
// extractTimeInfo extracts time info from a meta_data start/end map.
func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
if m == nil {
return nil
}
info := &searchEventTimeInfo{}
if v, ok := m["date"].(string); ok && v != "" {
info.Date = v
}
if v, ok := m["date_time"].(string); ok && v != "" {
info.DateTime = v
}
if v, ok := m["timezone"].(string); ok && v != "" {
info.Timezone = v
}
if info.Date == "" && info.DateTime == "" {
return nil
}
return info
}
// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
var CalendarSearchEvent = common.Shortcut{
Service: "calendar",
Command: "+search-event",
Description: "Search calendar events by keyword, time range, and attendees",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "query", Desc: "search keyword"},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
{Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
{Name: "page-token", Desc: "page token for next page"},
{Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
return err
}
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID := runtime.Str("calendar-id")
if calendarID == "" {
calendarID = "<primary>"
}
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
Set("calendar_id", calendarID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
startTime, endTime, err := parseSearchEventTimeRange(runtime)
if err != nil {
return err
}
// Build request body — always send query (even if empty)
body := &searchEventRequestBody{
Query: strings.TrimSpace(runtime.Str("query")),
}
if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
body.Filter = filter
}
// Build query params
params := map[string]any{}
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
if pageSize <= 0 {
pageSize = defaultSearchEventPageSize
}
params["page_size"] = strconv.Itoa(pageSize)
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
params, body)
if err != nil {
return err
}
if data == nil {
data = map[string]any{}
}
items := common.GetSlice(data, "items")
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
// Transform items to structured output
outItems := make([]searchEventItem, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]any)
if item == nil {
continue
}
meta, _ := item["meta_data"].(map[string]any)
out := searchEventItem{}
if meta != nil {
if v, ok := meta["event_id"].(string); ok {
out.EventID = v
}
if v, ok := meta["summary"].(string); ok {
out.Summary = v
}
if v, ok := meta["is_all_day"].(bool); ok {
out.IsAllDay = v
}
if v, ok := meta["app_link"].(string); ok {
out.AppLink = v
}
if start, ok := meta["start"].(map[string]any); ok {
out.Start = extractTimeInfo(start)
}
if end, ok := meta["end"].(map[string]any); ok {
out.End = extractTimeInfo(end)
}
}
outItems = append(outItems, out)
}
outData := searchEventOutput{
CalendarID: calendarID,
Items: outItems,
HasMore: hasMore,
PageToken: pageToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
if len(outItems) == 0 {
fmt.Fprintln(w, "No events found.")
return
}
var rows []map[string]interface{}
for _, item := range outItems {
row := map[string]interface{}{
"event_id": item.EventID,
"summary": common.TruncateStr(item.Summary, 40),
}
if item.Start != nil {
if item.Start.DateTime != "" {
row["start"] = item.Start.DateTime
} else if item.Start.Date != "" {
row["start"] = item.Start.Date
}
}
if item.End != nil {
if item.End.DateTime != "" {
row["end"] = item.End.DateTime
} else if item.End.Date != "" {
row["end"] = item.End.Date
}
}
if item.IsAllDay {
row["is_all_day"] = true
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},
}

View File

@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
func TestShortcuts_Returns9(t *testing.T) {
func TestShortcuts_Returns7(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 9 {
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
if len(shortcuts) != 7 {
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}

View File

@@ -42,30 +42,3 @@ func withParam(err error, flag string) error {
}
return err
}
// unwrapCalendarAPIError returns a user-facing message extracted from a
// calendar business-domain *errs.APIError, or "" when the error is not an
// APIError or its Code is not specialized here. Callers should fall back to
// err.Error() on "".
//
// Today it handles:
// - 190014 (invalid_parameters): returns Problem.Hint, which carries the
// server-supplied field-level detail (e.g. "end_time should be later
// than start_time") lifted by errclass.BuildAPIError.
//
// Add additional 19xxxx codes here as they become worth surfacing — keep this
// the single switch site so call sites stay readable.
func unwrapCalendarAPIError(err error) string {
if err == nil {
return ""
}
var ae *errs.APIError
if !errors.As(err, &ae) {
return ""
}
switch ae.Code {
case 190014:
return ae.Hint
}
return ""
}

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