mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
462358a746 | ||
|
|
ad4d3cb874 | ||
|
|
171778951d | ||
|
|
a6797ac2e4 | ||
|
|
d852ab311b | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 | ||
|
|
75926f9744 | ||
|
|
5c4ad52741 | ||
|
|
3fcb695698 | ||
|
|
fb042758db | ||
|
|
22108c3300 | ||
|
|
31744f8cf9 |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,40 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add meeting message send shortcut (#1643)
|
||||
- **doc**: Add document word statistics helper (#1697)
|
||||
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
|
||||
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
|
||||
- **base**: Support JSON array input for field create (#1661)
|
||||
- **task**: Expose completion state in `my tasks` output (#1641)
|
||||
- **cli**: Reduce public content credential false positives (#1700)
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||
- **docs**: Add reference map flags (#1547)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||
- **cli**: Harden git credential error handling (#1676)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide document copy skill usage (#1673)
|
||||
- **doc**: Fix lark-doc media token examples (#1662)
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
@@ -1299,6 +1333,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -194,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -212,6 +214,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
|
||||
@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
if got := findCheck(t, checks, name); got.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
return checkResult{}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Under an external credential provider with no usable identity, the
|
||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||
// per-identity checks already carry the source-appropriate escalation.
|
||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Provider serves neither identity: bot unsupported, user supported but not
|
||||
// signed in → both unavailable → identity_ready fails.
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
ready := findCheck(t, got.Checks, "identity_ready")
|
||||
if ready.Status != "fail" {
|
||||
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
||||
}
|
||||
// The summary defers to the per-identity checks; it carries no hint of its
|
||||
// own (a command here would be wrong under an external provider).
|
||||
if ready.Hint != "" {
|
||||
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
||||
}
|
||||
user := findCheck(t, got.Checks, "user_identity")
|
||||
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
||||
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
90
cmd/root_upgrade.go
Normal file
90
cmd/root_upgrade.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
|
||||
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
|
||||
// (install-method detection, output, error handling). Package-level var so
|
||||
// tests can stub it and avoid real network / self-update.
|
||||
var runRootUpgrade = func(cmd *cobra.Command) {
|
||||
for _, c := range cmd.Root().Commands() {
|
||||
if c.Name() == "update" && c.RunE != nil {
|
||||
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||
// AND no flag tokens in the raw invocation.
|
||||
func isBareRootInvocation(args []string) bool {
|
||||
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
|
||||
}
|
||||
|
||||
// readYes reads one line and reports whether it is an affirmative y/yes.
|
||||
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
|
||||
func readYes(r io.Reader) bool {
|
||||
line, _ := bufio.NewReader(r).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||
// failure is swallowed — it must never affect help output or the exit code.
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
ios := f.IOStreams
|
||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
||||
return
|
||||
}
|
||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||
info := update.CheckCached(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
runRootUpgrade(cmd)
|
||||
}
|
||||
|
||||
// installRootUpgradePrompt wraps the root command's RunE (set to
|
||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||
// invocations are passed straight through, unchanged.
|
||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
}
|
||||
191
cmd/root_upgrade_test.go
Normal file
191
cmd/root_upgrade_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func writeUpdateState(t *testing.T, dir, latest string) {
|
||||
t.Helper()
|
||||
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
||||
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYes(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
|
||||
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := readYes(strings.NewReader(in)); got != want {
|
||||
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBareRootInvocation(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
|
||||
rawInvocationArgs = nil
|
||||
if !isBareRootInvocation([]string{}) {
|
||||
t.Error("empty args + no raw flag tokens should be bare")
|
||||
}
|
||||
rawInvocationArgs = []string{"--profile", "x"}
|
||||
if isBareRootInvocation([]string{}) {
|
||||
t.Error("flag token present → not bare")
|
||||
}
|
||||
rawInvocationArgs = nil
|
||||
if isBareRootInvocation([]string{"im"}) {
|
||||
t.Error("positional arg → not bare")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgrade(t *testing.T) {
|
||||
origV := build.Version
|
||||
build.Version = "1.0.0" // release version so shouldSkip()==false
|
||||
t.Cleanup(func() { build.Version = origV })
|
||||
|
||||
origRun := runRootUpgrade
|
||||
t.Cleanup(func() { runRootUpgrade = origRun })
|
||||
|
||||
// This test builds a Factory literal (no NewDefault), so it never runs
|
||||
// workspace detection; pin the process-global workspace to Local so
|
||||
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
|
||||
// subdir inherited from a prior test in the package.
|
||||
origWS := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in, out, err bool
|
||||
input string
|
||||
latest string // "" → no state file (CheckCached nil)
|
||||
optOut bool
|
||||
wantPrompt, wantRun bool
|
||||
}{
|
||||
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
|
||||
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
|
||||
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
|
||||
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
|
||||
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
|
||||
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
|
||||
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
|
||||
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
|
||||
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
|
||||
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Clear env that update.shouldSkip treats as "suppress" so the
|
||||
// test is deterministic regardless of host (GitHub Actions sets
|
||||
// CI=true, which would otherwise suppress the prompt).
|
||||
t.Setenv("CI", "")
|
||||
t.Setenv("BUILD_NUMBER", "")
|
||||
t.Setenv("RUN_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||
if tc.latest != "" {
|
||||
writeUpdateState(t, dir, tc.latest)
|
||||
}
|
||||
if tc.optOut {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
called := false
|
||||
runRootUpgrade = func(*cobra.Command) { called = true }
|
||||
|
||||
var errBuf bytes.Buffer
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(tc.input),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &errBuf,
|
||||
IsTerminal: tc.in,
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
rawInvocationArgs = nil
|
||||
|
||||
innerCalls := 0
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
|
||||
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
}
|
||||
if err := root.RunE(root, []string{"im"}); err != nil {
|
||||
t.Fatalf("non-bare RunE err = %v", err)
|
||||
}
|
||||
if innerCalls != 2 {
|
||||
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
|
||||
// path (not the stub used elsewhere): from any command it must locate the
|
||||
// registered "update" subcommand via cmd.Root() and invoke its RunE.
|
||||
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
ran := 0
|
||||
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
|
||||
child := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(child)
|
||||
|
||||
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
|
||||
|
||||
if ran != 1 {
|
||||
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
|
||||
// when root has no RunE, installRootUpgradePrompt must not wrap it.
|
||||
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
}
|
||||
163
cmd/whoami/whoami.go
Normal file
163
cmd/whoami/whoami.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
//
|
||||
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
|
||||
// the app acting as itself; a user identity is the app acting *on behalf of* a
|
||||
// person (calls are attributed to that user, who is not necessarily present).
|
||||
// onBehalfOf only *names* that person and so appears only once a user is
|
||||
// resolved — a user identity that is not signed in still has identity "user"
|
||||
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Identity string `json:"identity"`
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
type delegatedUser struct {
|
||||
UserName string `json:"userName,omitempty"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. Output is always
|
||||
// JSON — whoami is consumed by agents. With the built-in credential path it is
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Validate as a real API call does (strict mode, then identity) so whoami
|
||||
// can't preview an identity the next call would refuse.
|
||||
if err := f.CheckStrictMode(ctx, as); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
source := resolveSource(
|
||||
cmd.Flags().Changed("as"),
|
||||
flagAs,
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSource derives how the effective identity became effective.
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto_detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict_mode"
|
||||
}
|
||||
return "default_as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = diag.Bot.Hint
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
// Use Status (not the raw TokenStatus) so the vocab matches the bot
|
||||
// branch: "ready" means usable for both. available stays the canonical
|
||||
// usable signal; tokenStatus is the readable state behind it.
|
||||
res.TokenStatus = diag.User.Status
|
||||
// Set onBehalfOf only when a user is actually resolved; an unresolved
|
||||
// user identity (not signed in) has no one to act on behalf of yet.
|
||||
if diag.User.UserName != "" || diag.User.OpenID != "" {
|
||||
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = diag.User.Hint
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
320
cmd/whoami/whoami_test.go
Normal file
320
cmd/whoami/whoami_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changedAs bool
|
||||
flagAs core.Identity
|
||||
autoDetected bool
|
||||
strictForced core.Identity
|
||||
want string
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
|
||||
{"auto detected", false, "", true, "", "auto_detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
|
||||
{"default_as", false, "", false, "", "default_as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
|
||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||
t.Fatalf("app context = %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
// whoami renders the diagnosed hint verbatim (single source of truth) so it
|
||||
// stays correct for the external-provider path without whoami knowing about it.
|
||||
if r.Hint != diag.User.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OnBehalfOf != nil {
|
||||
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint != diag.Bot.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_BotJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||
}
|
||||
if got.Profile != "test-profile" {
|
||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||
}
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OnBehalfOf != nil {
|
||||
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||
t.Run("as="+bad, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", bad})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||
}
|
||||
// Lock in the typed validation contract: an unsupported identity must
|
||||
// surface as a *errs.ValidationError on --as, not just any error.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||
}
|
||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||
// command-execution error.
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
|
||||
// Bot-only account → strict mode bot. A real `--as user` call would be
|
||||
// rejected by CheckStrictMode; whoami must reject it identically rather than
|
||||
// previewing a user identity the next call would refuse.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: 2, // bot only
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil // no UAT served locally; whoami runs with verify=false
|
||||
}
|
||||
|
||||
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// Regression for the external-provider blind spot: with credentials managed by
|
||||
// an extension provider, a signed-in user must read as available, and an
|
||||
// unavailable identity must not be told to "auth login" (which is blocked).
|
||||
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
|
||||
}
|
||||
f, out := externalWhoamiFactory(cfg)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("got %#v, want user/available/ready", got)
|
||||
}
|
||||
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
|
||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
|
||||
}
|
||||
if got.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty when available", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
|
||||
}
|
||||
f, out := externalWhoamiFactory(cfg)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Identity != "user" || got.Available {
|
||||
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
|
||||
}
|
||||
if strings.Contains(got.Hint, "auth login") {
|
||||
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
|
||||
}
|
||||
if !strings.Contains(got.Hint, "external") {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ type IOStreams struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
|
||||
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
|
||||
OutIsTerminal bool
|
||||
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
||||
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
||||
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
||||
@@ -27,19 +30,24 @@ type IOStreams struct {
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
|
||||
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
|
||||
// false.
|
||||
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
|
||||
// underlying *os.File of in / out / errOut respectively; non-file
|
||||
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
fileIsTerminal := func(v any) bool {
|
||||
if f, ok := v.(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
}
|
||||
stderrIsTerminal := false
|
||||
if f, ok := errOut.(*os.File); ok {
|
||||
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||
return &IOStreams{
|
||||
In: in,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
IsTerminal: fileIsTerminal(in),
|
||||
OutIsTerminal: fileIsTerminal(out),
|
||||
StderrIsTerminal: fileIsTerminal(errOut),
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
|
||||
31
internal/cmdutil/iostreams_test.go
Normal file
31
internal/cmdutil/iostreams_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
|
||||
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
|
||||
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
|
||||
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
s := NewIOStreams(r, w, w)
|
||||
if s.OutIsTerminal || s.StderrIsTerminal {
|
||||
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,15 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -102,6 +102,35 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{1061001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// An external provider mints tokens on demand and blocks interactive auth,
|
||||
// so the built-in keychain heuristics and "auth login" hints don't apply.
|
||||
if provider := activeExternalProvider(ctx, f); provider != "" {
|
||||
return diagnoseExternal(ctx, f, cfg, provider, verify)
|
||||
}
|
||||
return Result{
|
||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||
User: diagnoseUser(ctx, f, cfg, verify),
|
||||
}
|
||||
}
|
||||
|
||||
// activeExternalProvider returns the active extension provider name, or "".
|
||||
// An error degrades to the built-in path: an unreachable provider would already
|
||||
// have failed the f.Config() that produced cfg.
|
||||
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
|
||||
if f == nil || f.Credential == nil {
|
||||
return ""
|
||||
}
|
||||
name, err := f.Credential.ActiveExtensionProviderName(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
notConfigured := Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "not configured (missing app config)",
|
||||
Hint: externalCredentialHint(provider),
|
||||
}
|
||||
return Result{Bot: notConfigured, User: notConfigured}
|
||||
}
|
||||
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
|
||||
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
|
||||
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
|
||||
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
|
||||
return Result{
|
||||
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
|
||||
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
|
||||
}
|
||||
}
|
||||
|
||||
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
||||
if !supported {
|
||||
return notProvidedExternally("Bot", provider)
|
||||
}
|
||||
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
token, err := resolveBotToken(ctx, f, cfg)
|
||||
if err != nil {
|
||||
return externalVerifyFailed(id, "Bot", provider, err)
|
||||
}
|
||||
info, err := fetchBotInfo(ctx, f, cfg, token)
|
||||
if err != nil {
|
||||
return externalVerifyFailed(id, "Bot", provider, err)
|
||||
}
|
||||
id.Verified = boolPtr(true)
|
||||
id.OpenID = info.OpenID
|
||||
id.AppName = info.AppName
|
||||
return id
|
||||
}
|
||||
|
||||
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
|
||||
if !supported {
|
||||
return notProvidedExternally("User", provider)
|
||||
}
|
||||
// enrichUserInfo populates UserOpenId only after the provider returns and
|
||||
// verifies a UAT (and clears it on failure), so a resolved open id is the
|
||||
// external analogue of a keychain token being present.
|
||||
if cfg.UserOpenId == "" {
|
||||
return Identity{
|
||||
Status: StatusMissing,
|
||||
Message: "User identity: not signed in via credential source " + provider,
|
||||
Hint: externalCredentialHint(provider),
|
||||
}
|
||||
}
|
||||
id := Identity{
|
||||
Status: StatusReady,
|
||||
Available: true,
|
||||
TokenStatus: StatusReady,
|
||||
UserName: cfg.UserName,
|
||||
OpenID: cfg.UserOpenId,
|
||||
Message: "User identity: ready (provided by " + provider + ")",
|
||||
}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
|
||||
return externalVerifyFailed(id, "User", provider, err)
|
||||
}
|
||||
id.Verified = boolPtr(true)
|
||||
return id
|
||||
}
|
||||
|
||||
func notProvidedExternally(label, provider string) Identity {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: label + " identity: not provided by credential source " + provider,
|
||||
Hint: externalCredentialHint(provider),
|
||||
}
|
||||
}
|
||||
|
||||
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
|
||||
// (open id, user name) already resolved before the probe.
|
||||
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
|
||||
id.Available = false
|
||||
id.Verified = boolPtr(false)
|
||||
id.Status = StatusVerifyFailed
|
||||
id.TokenStatus = ""
|
||||
id.Message = label + " identity: verify failed: " + err.Error()
|
||||
id.Hint = externalCredentialHint(provider)
|
||||
return id
|
||||
}
|
||||
|
||||
// externalCredentialHint reports the constraint, not a remediation: the
|
||||
// identity is the provider's to manage, not lark-cli's to fix. What to do about
|
||||
// it is the caller's call — there may be no user to ask.
|
||||
func externalCredentialHint(provider string) string {
|
||||
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
|
||||
}
|
||||
|
||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
|
||||
// external-credential diagnosis path. account makes the provider "active";
|
||||
// token (when set) satisfies ResolveToken during verify.
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
token *extcred.Token
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{prov}, nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{},
|
||||
}
|
||||
}
|
||||
|
||||
// assertExternalHint locks the contract that an external-provider hint never
|
||||
// points at interactive commands blocked under an external provider.
|
||||
func assertExternalHint(t *testing.T, hint string) {
|
||||
t.Helper()
|
||||
if hint == "" {
|
||||
t.Fatalf("hint empty, want external guidance")
|
||||
}
|
||||
for _, blocked := range []string{"auth login", "config --help"} {
|
||||
if strings.Contains(hint, blocked) {
|
||||
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(hint, "external") {
|
||||
t.Fatalf("hint %q should explain credentials are external", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_External_UserReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
// The bug this guards: the built-in path read the keychain (empty under an
|
||||
// external provider) and reported the user as missing. Now availability
|
||||
// follows the resolved account, so a signed-in user reads as ready.
|
||||
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
|
||||
t.Fatalf("user = %#v, want ready/available", got.User)
|
||||
}
|
||||
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
|
||||
t.Fatalf("user identity = %#v", got.User)
|
||||
}
|
||||
if got.User.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
|
||||
}
|
||||
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
||||
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Available || got.User.Status != StatusMissing {
|
||||
t.Fatalf("user = %#v, want missing/unavailable", got.User)
|
||||
}
|
||||
assertExternalHint(t, got.User.Hint)
|
||||
}
|
||||
|
||||
func TestDiagnose_External_BotOnly(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if !got.Bot.Available || got.Bot.Status != StatusReady {
|
||||
t.Fatalf("bot = %#v, want ready/available", got.Bot)
|
||||
}
|
||||
// Provider declares bot-only: user is unavailable even though an open id is
|
||||
// present, and the hint is external (not "auth login").
|
||||
if got.User.Available || got.User.Status != StatusNotConfigured {
|
||||
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
|
||||
}
|
||||
assertExternalHint(t, got.User.Hint)
|
||||
}
|
||||
|
||||
func TestDiagnose_External_UserOnly(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if !got.User.Available || got.User.Status != StatusReady {
|
||||
t.Fatalf("user = %#v, want ready/available", got.User)
|
||||
}
|
||||
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
|
||||
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
|
||||
}
|
||||
assertExternalHint(t, got.Bot.Hint)
|
||||
}
|
||||
|
||||
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
|
||||
t.Fatalf("user = %#v, want available and verified", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
|
||||
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Available || got.User.Status != StatusVerifyFailed {
|
||||
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || *got.User.Verified {
|
||||
t.Fatalf("verified = %v, want false", got.User.Verified)
|
||||
}
|
||||
assertExternalHint(t, got.User.Hint)
|
||||
}
|
||||
|
||||
80
internal/output/spinner.go
Normal file
80
internal/output/spinner.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
const (
|
||||
spinnerInterval = 80 * time.Millisecond
|
||||
spinnerHideCursor = "\x1b[?25l"
|
||||
spinnerShowCursor = "\x1b[?25h"
|
||||
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
||||
)
|
||||
|
||||
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
||||
// until the returned stop() is called, e.g.:
|
||||
//
|
||||
// ⠹ Publishing dev → main... 3s
|
||||
//
|
||||
// It is meant for slow operations (long polls, first-time provisioning) so the
|
||||
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
||||
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
||||
//
|
||||
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
||||
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
||||
// the stderr-TTY check (IOStreams.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
|
||||
})
|
||||
}
|
||||
}
|
||||
54
internal/output/spinner_test.go
Normal file
54
internal/output/spinner_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
|
||||
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, false, "working")
|
||||
stop()
|
||||
stop() // idempotent
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
|
||||
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
|
||||
stop := StartSpinner(nil, true, "working")
|
||||
stop() // must not panic
|
||||
}
|
||||
|
||||
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
|
||||
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, true, "Publishing")
|
||||
// The goroutine renders the first frame synchronously before selecting on
|
||||
// the stop channel, so even an immediate stop() yields one full cycle.
|
||||
stop()
|
||||
stop() // idempotent, must not panic or double-write after finished
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, spinnerHideCursor) {
|
||||
t.Errorf("missing hide-cursor escape:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerFrames[0]) {
|
||||
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
|
||||
}
|
||||
if !strings.Contains(out, "Publishing...") {
|
||||
t.Errorf("missing label:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerClearLine) {
|
||||
t.Errorf("missing clear-line escape:\n%q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, spinnerShowCursor) {
|
||||
t.Errorf("must end by restoring the cursor:\n%q", out)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ func isPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if normalized == "" ||
|
||||
normalized == "=" ||
|
||||
printfPlaceholderValue(normalized) ||
|
||||
htmlEntityAnglePlaceholder(normalized) ||
|
||||
starMaskedPlaceholder(normalized) ||
|
||||
percentWrappedPlaceholder(normalized) ||
|
||||
angleWrappedPlaceholder(normalized) ||
|
||||
urlWithAnglePlaceholder(normalized) ||
|
||||
@@ -61,9 +64,28 @@ func isPlaceholderValue(value string) bool {
|
||||
return namedPlaceholderValue(normalized)
|
||||
}
|
||||
|
||||
func htmlEntityAnglePlaceholder(value string) bool {
|
||||
if !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||
return false
|
||||
}
|
||||
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">"))
|
||||
}
|
||||
|
||||
func starMaskedPlaceholder(value string) bool {
|
||||
var stars int
|
||||
for _, r := range value {
|
||||
if r == '*' {
|
||||
stars++
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return stars >= 3
|
||||
}
|
||||
|
||||
func namedPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") ||
|
||||
@@ -71,6 +93,15 @@ func namedPlaceholderValue(value string) bool {
|
||||
conventionalNamedPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func printfPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
if len(value) < 4 {
|
||||
return false
|
||||
|
||||
@@ -54,8 +54,9 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
continue
|
||||
}
|
||||
@@ -266,7 +267,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||
return true
|
||||
case tokenLikePlaceholderKey(key):
|
||||
return tokenLikePlaceholderValue(value)
|
||||
return tokenLikePlaceholderValue(key, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -278,12 +279,13 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderValue(value string) bool {
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||
isPlaceholderValue(value) ||
|
||||
normalized == "token" ||
|
||||
strings.Contains(normalized, "...") ||
|
||||
@@ -293,6 +295,51 @@ func tokenLikePlaceholderValue(value string) bool {
|
||||
strings.HasPrefix(normalized, ".")
|
||||
}
|
||||
|
||||
func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
if authCredentialTokenKey(key) {
|
||||
return false
|
||||
}
|
||||
var stars, alnum int
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r == '*':
|
||||
stars++
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
alnum++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func authCredentialTokenKey(key string) bool {
|
||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||
case "access_token",
|
||||
"refresh_token",
|
||||
"session_token",
|
||||
"bearer_token",
|
||||
"auth_token",
|
||||
"authorization_token",
|
||||
"id_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPermissionScopeIdentifierAssignment(key, value string) bool {
|
||||
if !strings.HasSuffix(key, "_token") {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.Trim(value, `"',;`)) {
|
||||
case "read", "write", "modify", "readonly", "get_as_user":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||
}
|
||||
@@ -333,20 +380,87 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
}
|
||||
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(match, "+") {
|
||||
return true
|
||||
}
|
||||
if rawValueQuoted {
|
||||
return false
|
||||
}
|
||||
if quotedLiteral(value) {
|
||||
return sourceCodeLiteralLooksNonSecret(value, false)
|
||||
}
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
assignmentIdx := strings.Index(typeAndRHS, "=")
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
value = strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
|
||||
return true
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
|
||||
return true
|
||||
}
|
||||
if quotedLiteral(value) {
|
||||
return false
|
||||
}
|
||||
return codeReferenceExpression(value)
|
||||
}
|
||||
|
||||
func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
|
||||
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
|
||||
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
|
||||
}
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".py":
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -360,7 +474,147 @@ func quotedLiteral(value string) bool {
|
||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||
}
|
||||
|
||||
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
literal := strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if strings.HasPrefix(literal, "/") {
|
||||
return true
|
||||
}
|
||||
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
|
||||
sourceCodeEnvVarNameLiteral(literal) ||
|
||||
sourceCodeAttributeNameLiteral(literal) ||
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
}
|
||||
|
||||
func sourceCodeFormatArgumentContext(line, match string) bool {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
prefix := line[:idx]
|
||||
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
|
||||
prefix = prefix[semicolon+1:]
|
||||
}
|
||||
return strings.Contains(prefix, "fmt.") ||
|
||||
strings.Contains(prefix, "log.") ||
|
||||
strings.Contains(prefix, "printf(") ||
|
||||
strings.Contains(prefix, "Printf(") ||
|
||||
strings.Contains(prefix, "Errorf(") ||
|
||||
strings.Contains(prefix, "Fprintf(")
|
||||
}
|
||||
|
||||
func sourceCodeFormatStringLiteral(value string) bool {
|
||||
for i := 0; i < len(value)-1; i++ {
|
||||
if value[i] != '%' {
|
||||
continue
|
||||
}
|
||||
if value[i+1] == '%' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
|
||||
j++
|
||||
}
|
||||
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceCodeEnvVarNameLiteral(value string) bool {
|
||||
if value == "" || !strings.Contains(value, "_") {
|
||||
return false
|
||||
}
|
||||
var hasCredentialMarker bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
|
||||
if strings.Contains(value, marker) {
|
||||
hasCredentialMarker = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return hasCredentialMarker
|
||||
}
|
||||
|
||||
func sourceCodeAttributeNameLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return strings.HasPrefix(normalized, "fake_") ||
|
||||
strings.HasPrefix(normalized, "fake-") ||
|
||||
strings.Contains(normalized, "placeholder") ||
|
||||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
|
||||
}
|
||||
|
||||
func sourceCodeCredentialTermLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
||||
return conventionalCredentialPlaceholderName(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "appsecret:":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeSchemaTypeLiteral(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
return normalized == "string" || strings.HasPrefix(normalized, "string(")
|
||||
}
|
||||
|
||||
func benignCredentialStatusLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
|
||||
if !delimitedPlaceholderIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"bad_fmt",
|
||||
"expired",
|
||||
"format",
|
||||
"invalid",
|
||||
"missing",
|
||||
"permission",
|
||||
"status",
|
||||
"type",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codeReferenceExpression(value string) bool {
|
||||
value = strings.TrimRight(strings.TrimSpace(value), ";")
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
@@ -369,7 +623,10 @@ func codeReferenceExpression(value string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||
if !codeIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
return codeIdentifier(value)
|
||||
}
|
||||
|
||||
func codeIdentifier(value string) bool {
|
||||
@@ -386,16 +643,6 @@ func codeIdentifier(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialNameFragment(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
|
||||
@@ -770,6 +770,172 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
|
||||
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
|
||||
"class Counter:",
|
||||
" def __init__(self) -> None:",
|
||||
" self._token_kind: TokenKind | None = None",
|
||||
" self.access_token: AccessToken | None = None",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
|
||||
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
|
||||
`return fmt.Errorf("failed to remove token: %v", err)`,
|
||||
`const LarkErrTokenMissing = "token_missing"`,
|
||||
`const LarkErrTokenExpired = 99991677`,
|
||||
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
|
||||
`const LargeAttachmentTokenAttr = "data-mail-token"`,
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
|
||||
`tokenTypeHint := "access_token"`,
|
||||
`const TokenTenant Token = "tenant"`,
|
||||
`const secretKeyPrefix = "appsecret:"`,
|
||||
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
|
||||
`return &credential.TokenResult{Token: "test-token"}, nil`,
|
||||
`fmt.Fprintf(w, "password=%s\n", pat)`,
|
||||
`text += "(img_token:" + imgToken + ")"`,
|
||||
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
|
||||
`this.token = token;`,
|
||||
`// AppSecret: "appsecret:<appId>"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
`{"token":"<wiki_token>"}`,
|
||||
`{"token":"Pgrrwvr***********UnRb"}`,
|
||||
`"scope_name": "auth:user_access_token:read"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
|
||||
"LARKSUITE_CLI_APP_SECRET=dry-run",
|
||||
"client_secret: dry_run",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
text string
|
||||
}{
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "typescript numeric password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ScanFile(tc.file, []byte(tc.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("typed credential assignment should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
"client_secret=%s",
|
||||
"access_token=%v",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
`<img token="..." url="https://..." width="..." height="..."/>`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.63",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
207
shortcuts/apps/apps_analytics.go
Normal file
207
shortcuts/apps/apps_analytics.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// 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}
|
||||
}
|
||||
459
shortcuts/apps/apps_analytics_test.go
Normal file
459
shortcuts/apps/apps_analytics_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
302
shortcuts/apps/apps_db_audit_list.go
Normal file
302
shortcuts/apps/apps_db_audit_list.go
Normal file
@@ -0,0 +1,302 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
|
||||
// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON
|
||||
// (INSERT 无 before、DELETE 无 after),json 还原成对象。
|
||||
//
|
||||
// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 /
|
||||
// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。
|
||||
var AppsDBAuditList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-list",
|
||||
Description: "List row-change audit events for one or more tables (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders",
|
||||
"Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(auditListTables(rctx)) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table")
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditListPath(appID)).
|
||||
Desc("List Miaoda app table audit events").
|
||||
Params(buildAuditListParams(rctx, auditListTables(rctx)))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requested := auditListTables(rctx)
|
||||
env := dbEnv(rctx)
|
||||
|
||||
// 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤,
|
||||
// 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。
|
||||
// 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。
|
||||
queryTables := requested
|
||||
var skipped []auditSkippedEntry
|
||||
if len(requested) > 1 {
|
||||
queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
// 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。
|
||||
if len(queryTables) == 0 {
|
||||
out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, len(requested))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectAuditLogItems(data["items"])
|
||||
data["items"] = items
|
||||
// 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。
|
||||
if len(skipped) > 0 {
|
||||
data["skipped"] = skipped
|
||||
} else {
|
||||
delete(data, "skipped")
|
||||
}
|
||||
multi := len(requested) > 1
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderAuditListPretty(w, items, skipped, len(requested), multi)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。
|
||||
type auditSkippedEntry struct {
|
||||
Table string `json:"table"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。
|
||||
func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) {
|
||||
existing, err := fetchExistingTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
enabled, err := fetchAuditEnabledTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
valid := make([]string, 0, len(requested))
|
||||
var skipped []auditSkippedEntry
|
||||
for _, t := range requested {
|
||||
switch {
|
||||
case !existing[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"})
|
||||
case !enabled[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"})
|
||||
default:
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
return valid, skipped, nil
|
||||
}
|
||||
|
||||
// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。
|
||||
func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if name := common.GetString(it, "name"); name != "" {
|
||||
existing[name] = true
|
||||
}
|
||||
}
|
||||
token = common.GetString(data, "page_token")
|
||||
if data["has_more"] != true || token == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := map[string]bool{}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if it["enabled"] == true {
|
||||
if name := common.GetString(it, "table"); name != "" {
|
||||
enabled[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。
|
||||
func asMapSlice(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditListTables 取 --table 切片,trim 去空。
|
||||
func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
out := make([]string, 0)
|
||||
for _, t := range rctx.StrSlice("table") {
|
||||
if v := strings.TrimSpace(t); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type auditLogItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventTime string `json:"event_time"`
|
||||
TargetTable string `json:"target_table"`
|
||||
Type string `json:"type"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Before interface{} `json:"before,omitempty"`
|
||||
After interface{} `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItem(operator 解析、before/after 还原成对象)。
|
||||
func projectAuditLogItems(raw interface{}) []auditLogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]auditLogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := auditLogItem{
|
||||
EventID: common.GetString(m, "event_id"),
|
||||
EventTime: common.GetString(m, "event_time"),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
Type: common.GetString(m, "type"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
}
|
||||
// before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。
|
||||
if b := common.GetString(m, "before"); b != "" {
|
||||
row.Before = safeParseJSON(b)
|
||||
}
|
||||
if a := common.GetString(m, "after"); a != "" {
|
||||
row.After = safeParseJSON(a)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。
|
||||
func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
return
|
||||
}
|
||||
var headers []string
|
||||
if multi {
|
||||
headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"}
|
||||
} else {
|
||||
headers = []string{"event_time", "type", "event_id", "operator", "summary"}
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)}
|
||||
if multi {
|
||||
cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
}
|
||||
|
||||
// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。
|
||||
func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) {
|
||||
if len(skipped) == 0 {
|
||||
return
|
||||
}
|
||||
parts := make([]string, 0, len(skipped))
|
||||
for _, s := range skipped {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason))
|
||||
}
|
||||
fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", "))
|
||||
}
|
||||
144
shortcuts/apps/apps_db_audit_set.go
Normal file
144
shortcuts/apps/apps_db_audit_set.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 审计保留期合法取值。
|
||||
var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"}
|
||||
|
||||
const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id <app_id>`"
|
||||
|
||||
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。
|
||||
var AppsDBAuditEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-enable",
|
||||
Description: "Enable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: 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_set,body {table, enabled:false}。
|
||||
var AppsDBAuditDisable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-disable",
|
||||
Description: "Disable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: 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}
|
||||
}
|
||||
140
shortcuts/apps/apps_db_audit_status.go
Normal file
140
shortcuts/apps/apps_db_audit_status.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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)
|
||||
}
|
||||
316
shortcuts/apps/apps_db_audit_test.go
Normal file
316
shortcuts/apps/apps_db_audit_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
|
||||
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
|
||||
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
|
||||
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
|
||||
)
|
||||
|
||||
// ── audit-status ──
|
||||
|
||||
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
|
||||
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// 单表无记录 → 占位对象 enabled:false(不是数组)。
|
||||
var env struct {
|
||||
Data struct {
|
||||
Table string `json:"table"`
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Table != "orders" || env.Data.Enabled {
|
||||
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
|
||||
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
|
||||
map[string]interface{}{"table": "users", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
|
||||
t.Fatalf("pretty table malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-enable / disable ──
|
||||
|
||||
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
|
||||
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required, exit 1
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
// 非法 retention → enum 校验 (validation)
|
||||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--retention" {
|
||||
t.Fatalf("Param = %q, want --retention", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
// dry-run body {table, enabled:true, retention}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
|
||||
// success
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-list ──
|
||||
|
||||
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
|
||||
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。
|
||||
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
|
||||
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
|
||||
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
|
||||
"before": `{"amount":100}`, "after": `{"amount":999}`,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// operator → 对象;before/after → 还原成对象(非字符串)。
|
||||
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"skipped"`) {
|
||||
t.Errorf("single-table query must not emit skipped:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `"before": "{`) {
|
||||
t.Errorf("before should be an object, not a JSON string:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
|
||||
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
|
||||
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list,
|
||||
// 不存在 / 未开启审计的表进 skipped。
|
||||
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
|
||||
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// schema:orders/users/carts 存在,ghost 不存在。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
|
||||
}}},
|
||||
})
|
||||
// status:orders/users 开启审计,carts 未开启。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
|
||||
map[string]interface{}{"table": "carts", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
// audit_list 只应被传入有效表 orders,users。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("tables"); got != "orders,users" {
|
||||
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
|
||||
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。
|
||||
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
|
||||
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"},
|
||||
}}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
// 不注册 audit_list:若被调用会命中未注册请求而报错。
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
|
||||
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
|
||||
}
|
||||
}
|
||||
152
shortcuts/apps/apps_db_changelog_list.go
Normal file
152
shortcuts/apps/apps_db_changelog_list.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// 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_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。
|
||||
// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name},
|
||||
// json 还原成对象、pretty 只展示 name。
|
||||
var AppsDBChangelogList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-changelog-list",
|
||||
Description: "List a Miaoda app database's DDL change history (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-changelog-list --app-id <app_id>",
|
||||
"Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "filter by target table"},
|
||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appChangelogListPath(appID)).
|
||||
Desc("List Miaoda app DDL changelog").
|
||||
Params(buildChangelogParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectChangelogItems(data["items"])
|
||||
data["items"] = items
|
||||
changeID := strings.TrimSpace(rctx.Str("change-id"))
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderChangelogPretty(w, items, changeID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("table", "table")
|
||||
addStr("change-id", "change_id")
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type changelogItem struct {
|
||||
ChangeID string `json:"change_id"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
TargetTable string `json:"target_table"`
|
||||
ChangeType string `json:"change_type"`
|
||||
Summary string `json:"summary"`
|
||||
Statement string `json:"statement,omitempty"`
|
||||
}
|
||||
|
||||
// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItem(operator 解析成对象)。
|
||||
func projectChangelogItems(raw interface{}) []changelogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]changelogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, changelogItem{
|
||||
ChangeID: common.GetString(m, "change_id"),
|
||||
ChangedAt: common.GetString(m, "changed_at"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
ChangeType: common.GetString(m, "change_type"),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。
|
||||
func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) {
|
||||
if len(items) == 0 {
|
||||
if changeID != "" {
|
||||
fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID)
|
||||
} else {
|
||||
io.WriteString(w, "No DDL changes found.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{
|
||||
it.ChangeID,
|
||||
dashIfEmpty(it.ChangedAt),
|
||||
operatorName(it.Operator),
|
||||
dashIfEmpty(it.TargetTable),
|
||||
it.ChangeType,
|
||||
dashIfEmpty(it.Summary),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
143
shortcuts/apps/apps_db_changelog_list_test.go
Normal file
143
shortcuts/apps/apps_db_changelog_list_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list"
|
||||
|
||||
// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。
|
||||
func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--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)
|
||||
}
|
||||
}
|
||||
194
shortcuts/apps/apps_db_data_export.go
Normal file
194
shortcuts/apps/apps_db_data_export.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataExportMaxRows = 5000
|
||||
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
|
||||
|
||||
// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。
|
||||
// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。
|
||||
// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 <table>.csv);上限 5000 行 / 1 MB。
|
||||
var AppsDBDataExport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-export",
|
||||
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
|
||||
"Format follows the --output extension: .csv / .json / .sql (default csv).",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table")
|
||||
}
|
||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||
}
|
||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
format, _, _ := exportFormatAndOutput(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
format, out, err := exportFormatAndOutput(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{dbEnv(rctx)},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
// 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。
|
||||
if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' {
|
||||
if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil {
|
||||
return withAppsHint(cerr, dbDataExportHint)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
body := resp.RawBody
|
||||
if len(body) > dbDataExportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body))
|
||||
}
|
||||
|
||||
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(body)),
|
||||
}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output")
|
||||
}
|
||||
// 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。
|
||||
rows := 0
|
||||
if totalErr == nil {
|
||||
rows = total
|
||||
if lim := rctx.Int("limit"); rows > lim {
|
||||
rows = lim
|
||||
}
|
||||
} else {
|
||||
rows = countDataRows(body, format)
|
||||
}
|
||||
resolved, perr := rctx.FileIO().ResolvePath(out)
|
||||
if perr != nil || resolved == "" {
|
||||
resolved = out
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"table": table, "output": resolved, "format": format,
|
||||
"rows": rows, "size_bytes": saved.Size(),
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalAsInt(raw["total"]), nil
|
||||
}
|
||||
|
||||
// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。
|
||||
func totalAsInt(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exportFormatAndOutput 由 --output 推断数据格式与落盘路径:
|
||||
// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出 <table>.csv。
|
||||
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
return "csv", table + ".csv", nil
|
||||
}
|
||||
f, ferr := resolveDataFormat(filepath.Ext(out), true)
|
||||
if ferr != nil {
|
||||
return "", "", ferr
|
||||
}
|
||||
return f, out, nil
|
||||
}
|
||||
193
shortcuts/apps/apps_db_data_export_test.go
Normal file
193
shortcuts/apps/apps_db_data_export_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
|
||||
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
|
||||
|
||||
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required-flag, exit 1
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected required-flag error for missing --table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。
|
||||
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
|
||||
for _, lim := range []string{"0", "-1", "5001"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
|
||||
}
|
||||
if ve.Param != "--limit" {
|
||||
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。
|
||||
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:format 跟随 --output 扩展名;缺省 csv。
|
||||
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。
|
||||
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
cases := []struct{ output, wantFmt string }{
|
||||
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
|
||||
if c.output != "" {
|
||||
args = append(args, "--output", c.output)
|
||||
}
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
|
||||
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
|
||||
}
|
||||
if _, ok := a.Params["limit"]; !ok {
|
||||
t.Errorf("dry-run missing limit param")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
|
||||
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
|
||||
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// 第 1 步:records 列表 total=2(行数来源)。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
|
||||
})
|
||||
// 第 2 步:导出原始字节。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n"),
|
||||
Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
b, err := os.ReadFile(dir + "/orders.csv")
|
||||
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
|
||||
t.Fatalf("output file wrong: %q err=%v", string(b), err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
|
||||
t.Fatalf("output json missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 行数取自 records total,且按 --limit 截顶(min(total, limit))。
|
||||
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。
|
||||
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 100`) {
|
||||
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
|
||||
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
|
||||
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("export should still succeed via fallback, got %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(dir + "/orders.csv")
|
||||
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
|
||||
t.Fatalf("file not written on fallback path: %q", string(b))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 3`) {
|
||||
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。
|
||||
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
|
||||
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
|
||||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
if _, statErr := os.Stat("nope.csv"); statErr == nil {
|
||||
t.Fatalf("error path must not write the output file")
|
||||
}
|
||||
}
|
||||
144
shortcuts/apps/apps_db_data_import.go
Normal file
144
shortcuts/apps/apps_db_data_import.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches"
|
||||
|
||||
// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与
|
||||
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
|
||||
// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
|
||||
var AppsDBDataImport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-import",
|
||||
Description: "Import rows from a local csv/json file into a Miaoda app table",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes",
|
||||
"Table defaults to the file name; override with --table.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: 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)
|
||||
|
||||
// multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{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))
|
||||
}
|
||||
161
shortcuts/apps/apps_db_data_import_test.go
Normal file
161
shortcuts/apps/apps_db_data_import_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import"
|
||||
|
||||
// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。
|
||||
func chdirTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBDataImport_RequiresAppID(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt)报不支持格式的校验错误。
|
||||
func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("data.txt", []byte("x\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。
|
||||
func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation_required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。
|
||||
func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
// >1MB → size 校验
|
||||
big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...)
|
||||
_ = os.WriteFile("big.csv", big, 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected 1MB limit error, got %T %v", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。
|
||||
// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态:file_name+file 走 body、env+table 走 query 且不再发 format。
|
||||
func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--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)
|
||||
}
|
||||
}
|
||||
@@ -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> --env 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> --environment dev`"
|
||||
|
||||
// AppsDBEnvCreate creates a DB environment for an app(拆分单库为 dev/online 多环境)。
|
||||
//
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
|
||||
var AppsDBEnvCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
@@ -24,19 +24,20 @@ 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 --env dev --sync-data --app-id <app_id> --yes",
|
||||
"Example: lark-cli apps +db-env-create --environment dev --sync-data --app-id <app_id> --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]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 {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
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"))
|
||||
@@ -62,7 +63,7 @@ var AppsDBEnvCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
// buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。
|
||||
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
|
||||
// --environment 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
|
||||
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"sync_data": rctx.Bool("sync-data"),
|
||||
|
||||
@@ -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", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "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", "--env", "dev", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "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", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "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", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "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", "--env", "online", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "online", "--yes", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
|
||||
191
shortcuts/apps/apps_db_env_migrate.go
Normal file
191
shortcuts/apps/apps_db_env_migrate.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`"
|
||||
|
||||
// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。
|
||||
// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
|
||||
var AppsDBEnvDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-diff",
|
||||
Description: "Preview pending dev→online schema changes (no apply)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-diff --app-id <app_id>",
|
||||
"Apply the previewed changes with +db-env-migrate --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(data, "from"), common.GetString(data, "to")
|
||||
changes := projectMigrationChanges(data["changes"])
|
||||
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderMigrationDiff(w, from, to, changes)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status
|
||||
// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
|
||||
var AppsDBEnvMigrate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-migrate",
|
||||
Description: "Publish pending dev→online schema changes (irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
|
||||
"Preview first with +db-env-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
applied = len(projectMigrationChanges(submit["changes"]))
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "applied", "migrated":
|
||||
return true, nil
|
||||
case "failed":
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if n := intFromAny(final["changes_applied"]); n > 0 {
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type migrationChange struct {
|
||||
Type string `json:"type"`
|
||||
Table string `json:"table"`
|
||||
Statement string `json:"statement"`
|
||||
}
|
||||
|
||||
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。
|
||||
func projectMigrationChanges(raw interface{}) []migrationChange {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]migrationChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, migrationChange{
|
||||
Type: common.GetString(m, "type"),
|
||||
Table: common.GetString(m, "table"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
|
||||
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
|
||||
if len(changes) == 0 {
|
||||
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s\n", c.Statement)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。
|
||||
func migrateFailMsg(d map[string]interface{}, taskID string) string {
|
||||
if m := common.GetString(d, "error_message"); m != "" {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
|
||||
}
|
||||
|
||||
// intFromAny 把 JSON number / json.Number 转 int(计数用)。
|
||||
func intFromAny(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
369
shortcuts/apps/apps_db_env_recovery_quota_test.go
Normal file
369
shortcuts/apps/apps_db_env_recovery_quota_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate"
|
||||
dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status"
|
||||
dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery"
|
||||
dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status"
|
||||
dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status"
|
||||
dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota"
|
||||
)
|
||||
|
||||
// ── env-diff ──
|
||||
|
||||
// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体:POST env_migrate 且 dry_run=true。
|
||||
func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。
|
||||
func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"from": "dev", "to": "online",
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") {
|
||||
t.Fatalf("pretty diff malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。
|
||||
func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No pending changes from dev to online.") {
|
||||
t.Fatalf("expected empty message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── env-migrate ──
|
||||
|
||||
// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false(真实迁移)。
|
||||
func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") {
|
||||
t.Fatalf("pretty: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "lock timeout") {
|
||||
t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+db-env-diff") {
|
||||
t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。
|
||||
func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-diff ──
|
||||
|
||||
// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。
|
||||
func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --target error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。
|
||||
func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。
|
||||
func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"preview_status": "success", "tables_affected": 2, "estimated_seconds": 12,
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2},
|
||||
map[string]interface{}{"table": "carts", "action": "restore_table"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error,携带 message 与 PITR window hint。
|
||||
func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "snapshot expired") {
|
||||
t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "PITR window") {
|
||||
t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-apply ──
|
||||
|
||||
// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。
|
||||
func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No changes — database is already at this state.") {
|
||||
t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。
|
||||
func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryApplyURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。
|
||||
func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── quota-get ──
|
||||
|
||||
// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。
|
||||
func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0,
|
||||
"tables": 4, "views": 1,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") {
|
||||
t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") {
|
||||
t.Fatalf("expected used + tables retained:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views(及配额已对接时的
|
||||
// quota/usage_percent),后端额外字段不透传。
|
||||
func TestProjectDbQuota_WhitelistsFields(t *testing.T) {
|
||||
out := projectDbQuota(map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
|
||||
"tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1",
|
||||
})
|
||||
if _, ok := out["storage_quota_bytes"]; ok {
|
||||
t.Errorf("zero quota should be omitted: %v", out)
|
||||
}
|
||||
if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 {
|
||||
t.Errorf("whitelisted fields should be kept: %v", out)
|
||||
}
|
||||
for _, leaked := range []string{"tenant_key", "internal_shard"} {
|
||||
if _, ok := out[leaked]; ok {
|
||||
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
|
||||
}
|
||||
}
|
||||
|
||||
out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2})
|
||||
if _, ok := out2["storage_quota_bytes"]; !ok {
|
||||
t.Errorf("non-zero quota should be kept: %v", out2)
|
||||
}
|
||||
if _, ok := out2["usage_percent"]; !ok {
|
||||
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
|
||||
}
|
||||
}
|
||||
@@ -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 an app database.
|
||||
// AppsDBExecute executes SQL against a Miaoda app database.
|
||||
//
|
||||
// POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式
|
||||
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。
|
||||
@@ -31,12 +31,18 @@ import (
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
|
||||
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
|
||||
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
// 后升级成 typed errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息
|
||||
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无
|
||||
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
|
||||
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。
|
||||
//
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`)
|
||||
// - 单 DML → data = `{command, rows_affected}`
|
||||
// - 单 DDL → data = `{command}`
|
||||
// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
|
||||
//
|
||||
// 字段裁剪用框架原生 --jq/-q。
|
||||
//
|
||||
// Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
|
||||
//
|
||||
@@ -45,51 +51,45 @@ import (
|
||||
var AppsDBExecute = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-execute",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against an app database",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
|
||||
`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'",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
|
||||
},
|
||||
}, 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
|
||||
}
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return appsValidationError("--sql and --file are mutually exclusive").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "mutually exclusive with --file"),
|
||||
appsInvalidParam("--file", "mutually exclusive with --sql"),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sql and --file are mutually exclusive")
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
|
||||
}
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
// 仅本地校验非空;不把文件内容写回公开的 --sql flag(避免 SQL 内容进入
|
||||
// flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --sql or --file is required (use --sql - to read stdin)")
|
||||
}
|
||||
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 app database").
|
||||
Desc("Execute SQL on Miaoda app database").
|
||||
Params(buildDBSQLParams(rctx)).
|
||||
Body(buildDBSQLBody(rctx))
|
||||
},
|
||||
@@ -110,27 +110,30 @@ 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 `--env 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 `--environment dev`")
|
||||
}
|
||||
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results,
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态,
|
||||
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
|
||||
stmts := parseSQLResult(common.GetString(raw, "result"))
|
||||
// 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
|
||||
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
// JSON data 形态(不再透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 [{...}](空 → [])
|
||||
// - 单 DML → data = {command, rows_affected}
|
||||
// - 单 DDL → data = {command}
|
||||
// - 多语句 → data = [{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]
|
||||
// 字段裁剪走框架原生 --jq/-q(不引入 miaoda 的 --json <fields>)。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错
|
||||
// (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := shapeSQLData(stmts)
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
|
||||
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
@@ -140,6 +143,70 @@ var AppsDBExecute = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态:
|
||||
// - 无语句 → [](空数组)
|
||||
// - 单条语句 → singleStatementJSON(SELECT 是行数组、DML/DDL 是对象)
|
||||
// - 多条语句 → []multiStatementElement(每条统一成 {command,...} 对象,SELECT 行放 rows)
|
||||
//
|
||||
// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。
|
||||
func shapeSQLData(stmts []map[string]interface{}) interface{} {
|
||||
if len(stmts) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
if len(stmts) == 1 {
|
||||
return singleStatementJSON(stmts[0])
|
||||
}
|
||||
out := make([]interface{}, 0, len(stmts))
|
||||
for _, s := range stmts {
|
||||
out = append(out, multiStatementElement(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// singleStatementJSON 单条语句的 PRD JSON 形态:
|
||||
// - SELECT → 行数组(空 → [])
|
||||
// - DML → {command, rows_affected}
|
||||
// - DDL / OK / 其它 → {command}
|
||||
func singleStatementJSON(s map[string]interface{}) interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return selectRows(s)
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成
|
||||
// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。
|
||||
func multiStatementElement(s map[string]interface{}) map[string]interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)}
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组;
|
||||
// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null)。
|
||||
func selectRows(s map[string]interface{}) []map[string]interface{} {
|
||||
dataJSON := strings.TrimSpace(common.GetString(s, "data"))
|
||||
if dataJSON == "" || dataJSON == "null" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。
|
||||
// 返回失败语句下标(0-based)、该 ERROR statement、是否命中。
|
||||
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
|
||||
@@ -151,28 +218,48 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIError(CategoryAPI → exit 1)。
|
||||
//
|
||||
// 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{} {
|
||||
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
|
||||
// message + hint 的人类可读文案(errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
|
||||
// miaoda-cli(src/cli/handlers/db/sql.ts、src/api/db/api.ts):
|
||||
// - message 末尾 "(at statement N of M)" 给出失败位置;
|
||||
// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回):
|
||||
// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted.";
|
||||
// 否则前序语句已逐条 commit、未回滚(flat 信封无逐句 breakdown,故 hint 简述前序已落地 + 从失败处续跑)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
|
||||
stmtNo := errIdx + 1 // 1-based 给人看
|
||||
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)
|
||||
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case inferRolledBack(stmts[:errIdx]):
|
||||
hint = "Transaction rolled back; no changes persisted."
|
||||
case errIdx > 0:
|
||||
hint = fmt.Sprintf("Earlier statements were committed and not rolled back; fix statement %d and re-run the remaining statements.", stmtNo)
|
||||
default:
|
||||
hint = "No statements were applied; fix the SQL and re-run."
|
||||
}
|
||||
return 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 errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。
|
||||
// 遍历已完成语句的 sql_type:BEGIN/START TRANSACTION +1,COMMIT/ROLLBACK/END -1;
|
||||
// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。
|
||||
func inferRolledBack(completed []map[string]interface{}) bool {
|
||||
depth := 0
|
||||
for _, s := range completed {
|
||||
switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) {
|
||||
case "BEGIN", "START TRANSACTION", "START_TRANSACTION":
|
||||
depth++
|
||||
case "COMMIT", "ROLLBACK", "END":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
return depth > 0
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
@@ -205,15 +292,34 @@ 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": rctx.Str("env"),
|
||||
"env": dbEnv(rctx),
|
||||
"transactional": false,
|
||||
}
|
||||
}
|
||||
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
// 不被写回公开的 --sql flag(避免泄露进 flag dump / 结构化日志)。优先 --sql(内联或 stdin,
|
||||
// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。
|
||||
func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) {
|
||||
if strings.TrimSpace(rctx.Str("sql")) != "" {
|
||||
return rctx.Str("sql"), nil
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if file == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(由 resolveExecuteSQL 在用时解析,--file 不入 flag)。
|
||||
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
sql, _ := resolveExecuteSQL(rctx)
|
||||
return map[string]interface{}{
|
||||
"sql": rctx.Str("sql"),
|
||||
"sql": sql,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,10 +460,10 @@ func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if failedIdx >= 0 {
|
||||
// CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地,
|
||||
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
|
||||
// CLI 永远传 transactional=false,失败语句之前的语句已逐条 commit 落地、不会整批回滚——
|
||||
// 如实告诉用户,避免整批重跑导致重复写入。
|
||||
if successCount > 0 {
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n",
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n",
|
||||
failedIdx+1, successCount, plural(int64(successCount)))
|
||||
} else {
|
||||
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
|
||||
@@ -461,6 +567,7 @@ 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":
|
||||
@@ -475,6 +582,7 @@ func dmlVerb(sqlType string) string {
|
||||
return "affected"
|
||||
}
|
||||
|
||||
// plural 返回英文复数后缀:n==1 时空串,否则 "s"。
|
||||
func plural(n int64) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
|
||||
@@ -5,17 +5,18 @@ 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"
|
||||
)
|
||||
|
||||
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
|
||||
// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。
|
||||
func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -33,27 +34,134 @@ func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
|
||||
// PRD 单 SELECT:data 直接是行数组(不再是 data.results[].data 字符串)
|
||||
var env struct {
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
|
||||
if env.Data[0]["id"] != float64(101) || env.Data[0]["total_cents"] != float64(2500) {
|
||||
t.Fatalf("data[0] = %v, want {id:101,total_cents:2500}", env.Data[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。
|
||||
func TestAppsDBExecute_SingleDMLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"INSERT","data":"","affected_rows":3}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DML:data = {command, rows_affected}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
RowsAffected int `json:"rows_affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 {
|
||||
t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。
|
||||
func TestAppsDBExecute_SingleDDLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DDL:data = {command}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "CREATE_TABLE" {
|
||||
t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。
|
||||
func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) {
|
||||
t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0])
|
||||
}
|
||||
if env.Data[1]["command"] != "SELECT" {
|
||||
t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"])
|
||||
}
|
||||
rows, ok := env.Data[1]["rows"].([]interface{})
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。
|
||||
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--environment", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -85,6 +193,7 @@ 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,
|
||||
@@ -94,6 +203,23 @@ 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)
|
||||
@@ -124,7 +250,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
|
||||
[]string{"+db-execute", "--app-id", "app_x", "--environment", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -147,6 +273,7 @@ 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)
|
||||
@@ -178,8 +305,9 @@ func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
|
||||
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
|
||||
// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
|
||||
// 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态(data 直接是行)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -197,24 +325,20 @@ func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
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.Results) != 1 {
|
||||
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data)
|
||||
}
|
||||
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"])
|
||||
if env.Data[0]["x"] != float64(1) {
|
||||
t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelect 断言 legacy wire 多 SELECT 输出带 Statement N header 与末尾 "✓ N statements executed" 汇总。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -244,6 +368,7 @@ func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时(legacy DDL)pretty 输出 "(empty result)"。
|
||||
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
// BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows)
|
||||
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
|
||||
@@ -270,6 +395,7 @@ 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)
|
||||
@@ -328,6 +454,7 @@ 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{
|
||||
@@ -350,6 +477,7 @@ 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
|
||||
@@ -386,6 +514,7 @@ 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{
|
||||
@@ -455,6 +584,7 @@ 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{
|
||||
@@ -486,19 +616,20 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, 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)
|
||||
// 非事务(transactional=false)前序语句已逐条 commit 落地,须如实说明「committed and not rolled back」,
|
||||
// 绝不能误报整批回滚。
|
||||
if !strings.Contains(got, "committed and not rolled back") {
|
||||
t.Errorf("non-tx failure must state prior statements committed & not rolled back; got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "statements executed") {
|
||||
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
|
||||
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout,
|
||||
// 退出信号是 PartialFailureError(非零 exit)。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed errs.APIError」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 typed errs.* 错误(type=api / subtype=server_error、
|
||||
// exit=1)。失败位置在 message 的 "(at statement N of M)",前序是否落地/是否回滚写在 hint。
|
||||
// 本例无 BEGIN → 前序逐条 commit、未回滚(hint 含 "committed and not rolled back")。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -518,64 +649,36 @@ 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 partial-failure error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
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())
|
||||
if got := payload["statement_index"]; got != float64(1) {
|
||||
t.Errorf("statement_index = %v, want 1", got)
|
||||
if p.Code != 1300002 {
|
||||
t.Errorf("code = %d, want 1300002", p.Code)
|
||||
}
|
||||
if got := payload["error_code"]; got != float64(1300002) {
|
||||
t.Errorf("error_code = %v, want 1300002", got)
|
||||
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 2 of 2)") {
|
||||
t.Errorf("error_message missing statement locator: %q", msg)
|
||||
// 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "committed and not rolled back") {
|
||||
t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint)
|
||||
}
|
||||
if got := payload["rolled_back"]; got != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
}
|
||||
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 哨兵)
|
||||
// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -592,26 +695,92 @@ 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 partial-failure error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
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 p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if got := payload["statement_index"]; got != float64(0) {
|
||||
t.Errorf("statement_index = %v, want 0", got)
|
||||
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("message missing locator: %q", p.Message)
|
||||
}
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "no statements were applied") {
|
||||
t.Errorf("note should say nothing was applied, got %q", note)
|
||||
// 第一条就失败、无落地 的语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "No statements were applied") {
|
||||
t.Errorf("hint should state nothing applied: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
|
||||
// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。
|
||||
// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。
|
||||
func TestAppsDBExecute_TransactionFailureRolledBack(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
// BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR)
|
||||
"result": `[` +
|
||||
`{"sql_type":"BEGIN","data":"[]"},` +
|
||||
`{"sql_type":"CREATE_TABLE","data":"[]"},` +
|
||||
`{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 4 of 4)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
// 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。
|
||||
if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") {
|
||||
t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。
|
||||
func TestInferRolledBack_Cases(t *testing.T) {
|
||||
stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} }
|
||||
cases := []struct {
|
||||
name string
|
||||
completed []map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"autocommit single", []map[string]interface{}{stmt("INSERT")}, false},
|
||||
{"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true},
|
||||
{"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false},
|
||||
{"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true},
|
||||
{"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := inferRolledBack(c.completed); got != c.want {
|
||||
t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。
|
||||
func TestCellString_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -635,6 +804,7 @@ func TestCellString_AllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。
|
||||
func TestCodeString_Forms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -656,6 +826,7 @@ 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",
|
||||
@@ -671,6 +842,7 @@ 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)
|
||||
@@ -683,6 +855,7 @@ 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
|
||||
@@ -701,6 +874,7 @@ 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
|
||||
@@ -722,6 +896,7 @@ 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")
|
||||
@@ -734,6 +909,7 @@ 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("")
|
||||
@@ -764,6 +940,7 @@ 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.
|
||||
@@ -772,6 +949,7 @@ 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
|
||||
@@ -795,6 +973,7 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。
|
||||
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -816,35 +995,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
101
shortcuts/apps/apps_db_quota_get.go
Normal file
101
shortcuts/apps/apps_db_quota_get.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// 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)
|
||||
}
|
||||
267
shortcuts/apps/apps_db_recovery.go
Normal file
267
shortcuts/apps/apps_db_recovery.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)"
|
||||
|
||||
// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id,
|
||||
// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。
|
||||
var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-diff",
|
||||
Description: "Preview restoring the database to a point in time (PITR diff)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-diff --app-id <app_id> --target 2h",
|
||||
"Apply with +db-recovery-apply --target <same> --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
preview, err := runRecoveryPreview(rctx, appID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := recoveryDiffOutput(target, preview)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRecoveryDiff(w, target, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes,
|
||||
// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
|
||||
var AppsDBRecoveryApply = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-apply",
|
||||
Description: "Restore the database to a point in time (overwrites current data, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes",
|
||||
"Preview first with +db-recovery-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
// 目标=当前态 → 后端短路 no_changes,不轮询。
|
||||
if strings.ToLower(common.GetString(submit, "status")) == "no_changes" {
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "no_changes", "target": target}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "restored", "ready":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("recovery to %s failed", target)
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "restored", "target": target}
|
||||
if n := intFromAny(final["restore_time_sec"]); n > 0 {
|
||||
out["restore_time_sec"] = n
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
if n, ok := out["restore_time_sec"].(int); ok {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
prid := common.GetString(submit, "preview_request_id")
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
case "success":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = "recovery preview failed"
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
type recoveryChange struct {
|
||||
Table string `json:"table"`
|
||||
Inserted interface{} `json:"inserted,omitempty"`
|
||||
Deleted interface{} `json:"deleted,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
DroppedAt string `json:"dropped_at,omitempty"`
|
||||
}
|
||||
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
Action: common.GetString(m, "action"),
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
|
||||
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
|
||||
changes, _ := out["changes"].([]recoveryChange)
|
||||
if len(changes) == 0 {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
|
||||
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
|
||||
}
|
||||
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
|
||||
}
|
||||
|
||||
// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。
|
||||
func describeRecoveryChange(c recoveryChange) string {
|
||||
switch c.Action {
|
||||
case "restore_table":
|
||||
return "table will be restored"
|
||||
case "drop_table":
|
||||
return "table will be dropped"
|
||||
case "alter_table":
|
||||
return "table will be altered"
|
||||
case "unavailable":
|
||||
if c.DroppedAt != "" {
|
||||
return "diff unavailable: " + c.DroppedAt
|
||||
}
|
||||
return "diff unavailable"
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if n := intFromAny(c.Inserted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("+%d rows", n))
|
||||
}
|
||||
if n := intFromAny(c.Deleted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("-%d rows", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -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 --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env 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 --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
|
||||
|
||||
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
|
||||
//
|
||||
@@ -34,15 +34,17 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
}, 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("table")) == "" {
|
||||
return appsValidationParamError("--table", "--table is required")
|
||||
}
|
||||
@@ -78,7 +80,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": rctx.Str("env")}
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
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`"
|
||||
|
||||
// AppsDBTableList lists tables in an app's database.
|
||||
//
|
||||
@@ -38,15 +38,16 @@ var AppsDBTableList = common.Shortcut{
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]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 {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
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"))
|
||||
@@ -110,7 +111,7 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"env": dbEnv(rctx),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "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", "--env", "dev",
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "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", "--env", "prod", "--as", "user"}, factory, stdout)
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
}
|
||||
|
||||
412
shortcuts/apps/apps_env.go
Normal file
412
shortcuts/apps/apps_env.go
Normal file
@@ -0,0 +1,412 @@
|
||||
// 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
|
||||
}
|
||||
@@ -62,8 +62,9 @@ var AppsEnvPull = common.Shortcut{
|
||||
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
POST(envPullVarsPath(appID)).
|
||||
Desc("Pull app startup env vars into the local .env.local file").
|
||||
Body(envPullVarsBody()).
|
||||
Set("project_path", projectPath).
|
||||
Set("env_file", envFile)
|
||||
},
|
||||
@@ -80,10 +81,9 @@ var AppsEnvPull = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, nil)
|
||||
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
||||
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`")
|
||||
return withAppsHint(err, envPullAPIErrorHint(err, appID))
|
||||
}
|
||||
|
||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||
@@ -116,6 +116,37 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func envPullVarsPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func envPullVarsBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": "dev",
|
||||
}
|
||||
}
|
||||
|
||||
func envPullAPIErrorHint(err error, appID string) string {
|
||||
if isEnvPullDevDBNotInitializedError(err) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
appID = "<app_id>"
|
||||
}
|
||||
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
|
||||
}
|
||||
return appIDListHint
|
||||
}
|
||||
|
||||
func isEnvPullDevDBNotInitializedError(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(p.Message)
|
||||
return strings.Contains(message, "multi-environment database is not initialized") ||
|
||||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
@@ -150,13 +181,19 @@ 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 must be an object or array of key/value entries")
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
@@ -203,7 +240,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 must be an object or array of key/value entries")
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -31,6 +32,11 @@ 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()
|
||||
@@ -255,7 +261,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) {
|
||||
func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
@@ -272,6 +278,9 @@ func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(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)
|
||||
}
|
||||
@@ -283,6 +292,9 @@ 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{}{
|
||||
@@ -550,6 +562,68 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
RawBody: []byte("[]"),
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": -1,
|
||||
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branch:dev",
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
p := requireAppsAPIProblem(t, err)
|
||||
if p.Code != -1 {
|
||||
t.Fatalf("code = %d, want -1", p.Code)
|
||||
}
|
||||
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
409
shortcuts/apps/apps_env_test.go
Normal file
409
shortcuts/apps/apps_env_test.go
Normal file
@@ -0,0 +1,409 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -14,6 +14,9 @@ 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 +") {
|
||||
@@ -50,3 +53,62 @@ 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
|
||||
}
|
||||
|
||||
148
shortcuts/apps/apps_file_delete.go
Normal file
148
shortcuts/apps/apps_file_delete.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// 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 path(high-risk-write,框架自动注入 --yes 确认)。
|
||||
//
|
||||
// POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST
|
||||
// (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths
|
||||
// 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 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))
|
||||
}
|
||||
132
shortcuts/apps/apps_file_delete_test.go
Normal file
132
shortcuts/apps/apps_file_delete_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// 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_required(exit 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_remove,body.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:true;results 按下标 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
125
shortcuts/apps/apps_file_download.go
Normal file
125
shortcuts/apps/apps_file_download.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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_url(presigned,直连对象存储),
|
||||
// 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。
|
||||
var AppsFileDownload = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-download",
|
||||
Description: "Download a file to a local path (via a signed URL)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png --output ./logo.png",
|
||||
"Example (omit --output): lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png # saves to ./1858537546760216.png",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Desc: "remote file path", Required: true},
|
||||
{Name: "output", Desc: "local output path (default: remote file basename in cwd)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
remotePath, _ := requireFilePath(rctx.Str("path"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFileSignPath(appID)).
|
||||
Desc("Sign a download URL, then GET it to --output").
|
||||
Body(map[string]interface{}{"path": remotePath})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath, err := requireFilePath(rctx.Str("path"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 签名拿 presigned signed_url。
|
||||
signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signedURL := common.GetString(signData, "signed_url")
|
||||
if signedURL == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url")
|
||||
}
|
||||
|
||||
// 2. 直连 GET signed_url 落盘。
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
out = path.Base(strings.TrimPrefix(remotePath, "/"))
|
||||
if out == "" || out == "." || out == "/" {
|
||||
out = "download"
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call).
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err)
|
||||
}
|
||||
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply.
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
// 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。
|
||||
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
|
||||
},
|
||||
}
|
||||
122
shortcuts/apps/apps_file_download_test.go
Normal file
122
shortcuts/apps/apps_file_download_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
87
shortcuts/apps/apps_file_get.go
Normal file
87
shortcuts/apps/apps_file_get.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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/value:file_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/value;uploaded_by 只展示 name(id 仅 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)
|
||||
}
|
||||
89
shortcuts/apps/apps_file_get_test.go
Normal file
89
shortcuts/apps/apps_file_get_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// 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 file,path 作为 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/value:size 含 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)
|
||||
}
|
||||
}
|
||||
145
shortcuts/apps/apps_file_list.go
Normal file
145
shortcuts/apps/apps_file_list.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
// server 字段 created_at → 产品语义 uploaded_at。
|
||||
var AppsFileList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-list",
|
||||
Description: "List files in a Miaoda app's storage (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-list --app-id <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.items[].path'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "filter by exact file name"},
|
||||
{Name: "path", Desc: "filter by exact remote path"},
|
||||
{Name: "type", Desc: "filter by MIME type"},
|
||||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
continue
|
||||
}
|
||||
n, err := normalizeTimestamp(rctx.Str(f))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
|
||||
}
|
||||
_ = rctx.Cmd.Flags().Set(f, n)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appFileListPath(appID)).
|
||||
Desc("List Miaoda app files").
|
||||
Params(buildFileListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。
|
||||
items := projectFileItems(data["items"])
|
||||
data["items"] = items
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderFileListPretty(w, items)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfo(created_*→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)
|
||||
}
|
||||
252
shortcuts/apps/apps_file_list_test.go
Normal file
252
shortcuts/apps/apps_file_list_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
// 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 带 TZ:Z 原样、显式偏移换算到 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-dur(5s 容差)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_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_by;created_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)
|
||||
}
|
||||
}
|
||||
93
shortcuts/apps/apps_file_quota_get.go
Normal file
93
shortcuts/apps/apps_file_quota_get.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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)
|
||||
}
|
||||
96
shortcuts/apps/apps_file_quota_get_test.go
Normal file
96
shortcuts/apps/apps_file_quota_get_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额未对接(=0):storage_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)
|
||||
}
|
||||
}
|
||||
82
shortcuts/apps/apps_file_sign.go
Normal file
82
shortcuts/apps/apps_file_sign.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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_sign,body {path, expires_in}。
|
||||
// pretty 模式只打 signed_url(便于直接管道 / curl);json 返 {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
|
||||
}
|
||||
74
shortcuts/apps/apps_file_sign_test.go
Normal file
74
shortcuts/apps/apps_file_sign_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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_sign,body 携带 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)
|
||||
}
|
||||
}
|
||||
218
shortcuts/apps/apps_file_upload.go
Normal file
218
shortcuts/apps/apps_file_upload.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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 取本地 basename;path 由平台生成 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 把它存成对象 metadata,callback 阶段后端
|
||||
// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key
|
||||
// (平台 16 位 ID)当文件名 —— 即「上传后文件名变成 ID」的根因。
|
||||
//
|
||||
//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL.
|
||||
func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err)
|
||||
}
|
||||
req.ContentLength = int64(len(content))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// 用 mime.FormatMediaType 规范生成 Content-Disposition(自动按 RFC 2045 处理引号/转义),
|
||||
// 不手工拼接 header,杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName
|
||||
// 做 encodeURIComponent(控制字符/分隔符均 %XX 化),此处是第二道防线。
|
||||
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)})
|
||||
if disposition == "" {
|
||||
disposition = "attachment"
|
||||
}
|
||||
req.Header.Set("Content-Disposition", disposition)
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 400 {
|
||||
// 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。
|
||||
if resp.StatusCode >= 500 {
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// sanitizeUploadFileName 对齐 miaoda:先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent
|
||||
// (UTF-8 百分号编码,兼容中文等非 ASCII,且让 Content-Disposition header 合法),空则兜底 download_file。
|
||||
func sanitizeUploadFileName(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch r {
|
||||
case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';':
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
enc := encodeURIComponent(b.String())
|
||||
if enc == "" {
|
||||
return "download_file"
|
||||
}
|
||||
// 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件,
|
||||
// 前置下划线消除隐藏文件语义。
|
||||
if strings.HasPrefix(enc, ".") {
|
||||
enc = "_" + enc
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
// encodeURIComponent 复刻 JS encodeURIComponent:除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。
|
||||
func encodeURIComponent(s string) string {
|
||||
const keep = "-_.!~*'()"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("%%%02X", c))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mimeByExt 按扩展名推断 Content-Type,未知回退 application/octet-stream。
|
||||
func mimeByExt(name string) string {
|
||||
if t := mime.TypeByExtension(filepath.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// renderFileUploadPretty 打 ✓ Uploaded <local> → <path> + size / download_url。
|
||||
func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) {
|
||||
fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path)
|
||||
fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes))
|
||||
if info.DownloadURL != "" {
|
||||
fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL)
|
||||
}
|
||||
}
|
||||
182
shortcuts/apps/apps_file_upload_test.go
Normal file
182
shortcuts/apps/apps_file_upload_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。
|
||||
func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// --file is a cobra-required flag; pass whitespace so cobra's required check
|
||||
// passes and our Validate (which trims) rejects it with a typed error.
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。
|
||||
func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["file_name"] != "logo.png" {
|
||||
t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
var putContentType, putCD string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
putContentType = r.Header.Get("Content-Type")
|
||||
putCD = r.Header.Get("Content-Disposition")
|
||||
w.Header().Set("ETag", `"etag-123"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png",
|
||||
"download_url": "/spark/app/x/1858537546760216.png",
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PNGBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
if putContentType != "image/png" {
|
||||
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
||||
}
|
||||
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
||||
// 断言按解析结果(format-agnostic):mime.FormatMediaType 对无 tspecial 的名不加引号,
|
||||
// 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。
|
||||
if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" {
|
||||
t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
||||
t.Errorf("output missing uploaded path:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"logo.png", "logo.png"},
|
||||
{"a b.png", "a%20b.png"}, // 空格 → %20(encodeURIComponent)
|
||||
{`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)
|
||||
}
|
||||
}
|
||||
@@ -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", "--env", "dev", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "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", "--env", "dev", "--as", "user"},
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "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")
|
||||
|
||||
@@ -21,6 +21,9 @@ 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,
|
||||
|
||||
877
shortcuts/apps/apps_logs.go
Normal file
877
shortcuts/apps/apps_logs.go
Normal file
@@ -0,0 +1,877 @@
|
||||
// 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
|
||||
}
|
||||
664
shortcuts/apps/apps_logs_test.go
Normal file
664
shortcuts/apps/apps_logs_test.go
Normal file
@@ -0,0 +1,664 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
587
shortcuts/apps/apps_metrics.go
Normal file
587
shortcuts/apps/apps_metrics.go
Normal file
@@ -0,0 +1,587 @@
|
||||
// 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
|
||||
}
|
||||
298
shortcuts/apps/apps_metrics_test.go
Normal file
298
shortcuts/apps/apps_metrics_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
202
shortcuts/apps/apps_observability_common.go
Normal file
202
shortcuts/apps/apps_observability_common.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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)
|
||||
}
|
||||
138
shortcuts/apps/apps_observability_common_test.go
Normal file
138
shortcuts/apps/apps_observability_common_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// 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
|
||||
}
|
||||
233
shortcuts/apps/apps_openapi_key_common.go
Normal file
233
shortcuts/apps/apps_openapi_key_common.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// allowedScopeAPIMethods is the HTTP method whitelist for --scope-api / request_scope.
|
||||
var allowedScopeAPIMethods = map[string]struct{}{
|
||||
"GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {},
|
||||
}
|
||||
|
||||
// validateScopeAPIMethod rejects methods outside the whitelist (e.g. TRACE, CONNECT, empty).
|
||||
func validateScopeAPIMethod(method string) error {
|
||||
if _, ok := allowedScopeAPIMethods[method]; !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http method %q not allowed; use one of GET, POST, PUT, PATCH, DELETE", method)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScopeAPIPath enforces basic openapi route hygiene as a first line of defense.
|
||||
func validateScopeAPIPath(p string) error {
|
||||
if p == "" || !strings.HasPrefix(p, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must start with '/', got %q", p)
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must not contain '..': %q", p)
|
||||
}
|
||||
if strings.Contains(p, "//") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must not contain '//': %q", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequestScopeFields constrains a request_scope object to the documented
|
||||
// schema: only allow_all (bool) and http_infos ([{http_method, http_path}]). This
|
||||
// closes the raw --scope escape hatch from injecting undocumented fields.
|
||||
func validateRequestScopeFields(rs map[string]interface{}) error {
|
||||
for k := range rs {
|
||||
switch k {
|
||||
case "allow_all", "http_infos":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown field %q; only allow_all and http_infos are allowed", k)
|
||||
}
|
||||
}
|
||||
if v, ok := rs["allow_all"]; ok {
|
||||
if _, isBool := v.(bool); !isBool {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "allow_all must be a boolean")
|
||||
}
|
||||
}
|
||||
if v, ok := rs["http_infos"]; ok {
|
||||
arr, isArr := v.([]interface{})
|
||||
if !isArr {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "http_infos must be an array")
|
||||
}
|
||||
for _, item := range arr {
|
||||
m, isMap := item.(map[string]interface{})
|
||||
if !isMap {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "each http_infos entry must be an object")
|
||||
}
|
||||
for k := range m {
|
||||
switch k {
|
||||
case "http_method", "http_path":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown field %q in http_infos entry; only http_method and http_path are allowed", k)
|
||||
}
|
||||
}
|
||||
method, _ := m["http_method"].(string)
|
||||
if err := validateScopeAPIMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
path, _ := m["http_path"].(string)
|
||||
if err := validateScopeAPIPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRawScope parses a raw --scope JSON value: it must be an object that
|
||||
// conforms to the request_scope schema (validated by validateRequestScopeFields).
|
||||
func parseRawScope(scopeRaw string) (map[string]interface{}, error) {
|
||||
var rs interface{}
|
||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, ok := rs.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope must be a JSON object")
|
||||
}
|
||||
if err := validateRequestScopeFields(obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case
|
||||
// httpInfo, validating the method against the whitelist and the path format.
|
||||
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)
|
||||
}
|
||||
method := strings.ToUpper(fields[0])
|
||||
if err := validateScopeAPIMethod(method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fields[1]
|
||||
if err := validateScopeAPIPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"http_method": method, "http_path": path}, 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")
|
||||
}
|
||||
return parseRawScope(scopeRaw)
|
||||
}
|
||||
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"))
|
||||
scopeAPIs := rctx.StrArray("scope-api")
|
||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(scopeAPIs) > 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 != "" {
|
||||
if _, err := parseRawScope(scopeRaw); err != nil {
|
||||
return appsValidationParamError("--scope", "invalid --scope: %s", err).
|
||||
WithHint("--scope takes a JSON object with only allow_all (bool) and http_infos ([{http_method, http_path}]); methods: GET, POST, PUT, PATCH, DELETE")
|
||||
}
|
||||
}
|
||||
for _, a := range scopeAPIs {
|
||||
if _, err := parseScopeAPI(a); err != nil {
|
||||
return appsValidationParamError("--scope-api", "invalid --scope-api: %s", err).
|
||||
WithHint("format: 'METHOD /openapi/path'; method one of GET, POST, PUT, PATCH, DELETE; path starts with '/', no '..' or '//'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
356
shortcuts/apps/apps_openapi_key_common_test.go
Normal file
356
shortcuts/apps/apps_openapi_key_common_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// 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 TestValidateScopeAPIMethod(t *testing.T) {
|
||||
for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
|
||||
if err := validateScopeAPIMethod(m); err != nil {
|
||||
t.Errorf("validateScopeAPIMethod(%q) = %v, want nil", m, err)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"TRACE", "CONNECT", "OPTIONS", "HEAD", "", "get"} {
|
||||
if err := validateScopeAPIMethod(m); err == nil {
|
||||
t.Errorf("validateScopeAPIMethod(%q) = nil, want error", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopeAPIPath(t *testing.T) {
|
||||
for _, p := range []string{"/openapi/orders", "/openapi/v1/x"} {
|
||||
if err := validateScopeAPIPath(p); err != nil {
|
||||
t.Errorf("validateScopeAPIPath(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"", "openapi/x", "/openapi/../admin", "/..", "/openapi//x", "//x"} {
|
||||
if err := validateScopeAPIPath(p); err == nil {
|
||||
t.Errorf("validateScopeAPIPath(%q) = nil, want error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestScopeFields(t *testing.T) {
|
||||
ok := []map[string]interface{}{
|
||||
{"allow_all": true},
|
||||
{"allow_all": false, "http_infos": []interface{}{
|
||||
map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"},
|
||||
}},
|
||||
{},
|
||||
}
|
||||
for _, rs := range ok {
|
||||
if err := validateRequestScopeFields(rs); err != nil {
|
||||
t.Errorf("validateRequestScopeFields(%v) = %v, want nil", rs, err)
|
||||
}
|
||||
}
|
||||
bad := []map[string]interface{}{
|
||||
{"foo": 1}, // unknown top-level field
|
||||
{"allow_all": "yes"}, // wrong type
|
||||
{"http_infos": "x"}, // not an array
|
||||
{"http_infos": []interface{}{"x"}}, // entry not an object
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "TRACE", "http_path": "/x"}}}, // bad method
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "../x"}}}, // bad path
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "/x", "extra": 1}}}, // unknown entry field
|
||||
}
|
||||
for _, rs := range bad {
|
||||
if err := validateRequestScopeFields(rs); err == nil {
|
||||
t.Errorf("validateRequestScopeFields(%v) = nil, want error", rs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRawScope(t *testing.T) {
|
||||
if _, err := parseRawScope(`{"allow_all":true}`); err != nil {
|
||||
t.Errorf("valid object errored: %v", err)
|
||||
}
|
||||
for _, raw := range []string{`["x"]`, `"s"`, `123`, `{"foo":1}`, `{bad`} {
|
||||
if _, err := parseRawScope(raw); err == nil {
|
||||
t.Errorf("parseRawScope(%q) = nil, want error", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScopeAPI_Rejects(t *testing.T) {
|
||||
bad := []string{"TRACE /openapi/x", "CONNECT /x", "GET ../admin", "GET openapi/x", "GET /a//b"}
|
||||
for _, in := range bad {
|
||||
if _, err := parseScopeAPI(in); err == nil {
|
||||
t.Errorf("parseScopeAPI(%q) = nil, want error", in)
|
||||
}
|
||||
}
|
||||
// regression: legitimate input still parses (and lowercases the method)
|
||||
info, err := parseScopeAPI("get /openapi/orders")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info["http_method"] != "GET" || info["http_path"] != "/openapi/orders" {
|
||||
t.Errorf("info = %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestScope_RawValidation(t *testing.T) {
|
||||
// unknown field now rejected (HIGH-2)
|
||||
if _, err := buildRequestScope(false, nil, `{"foo":1}`); err == nil {
|
||||
t.Errorf("raw scope with unknown field must error")
|
||||
}
|
||||
// non-object rejected
|
||||
if _, err := buildRequestScope(false, nil, `["x"]`); err == nil {
|
||||
t.Errorf("non-object raw scope must error")
|
||||
}
|
||||
// nested bad method rejected
|
||||
if _, err := buildRequestScope(false, nil, `{"http_infos":[{"http_method":"TRACE","http_path":"/x"}]}`); err == nil {
|
||||
t.Errorf("raw scope with bad nested method must error")
|
||||
}
|
||||
// regression: documented fields pass
|
||||
if _, err := buildRequestScope(false, nil, `{"allow_all":true}`); err != nil {
|
||||
t.Errorf("valid raw scope errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
110
shortcuts/apps/apps_openapi_key_create.go
Normal file
110
shortcuts/apps/apps_openapi_key_create.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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
|
||||
}
|
||||
86
shortcuts/apps/apps_openapi_key_create_test.go
Normal file
86
shortcuts/apps/apps_openapi_key_create_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
47
shortcuts/apps/apps_openapi_key_delete.go
Normal file
47
shortcuts/apps/apps_openapi_key_delete.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
35
shortcuts/apps/apps_openapi_key_delete_test.go
Normal file
35
shortcuts/apps/apps_openapi_key_delete_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
33
shortcuts/apps/apps_openapi_key_disable.go
Normal file
33
shortcuts/apps/apps_openapi_key_disable.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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)
|
||||
},
|
||||
}
|
||||
53
shortcuts/apps/apps_openapi_key_enable.go
Normal file
53
shortcuts/apps/apps_openapi_key_enable.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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)
|
||||
}
|
||||
72
shortcuts/apps/apps_openapi_key_get.go
Normal file
72
shortcuts/apps/apps_openapi_key_get.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// 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
|
||||
}
|
||||
49
shortcuts/apps/apps_openapi_key_get_test.go
Normal file
49
shortcuts/apps/apps_openapi_key_get_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
104
shortcuts/apps/apps_openapi_key_list.go
Normal file
104
shortcuts/apps/apps_openapi_key_list.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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
|
||||
}
|
||||
91
shortcuts/apps/apps_openapi_key_list_test.go
Normal file
91
shortcuts/apps/apps_openapi_key_list_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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
|
||||
}
|
||||
50
shortcuts/apps/apps_openapi_key_reset.go
Normal file
50
shortcuts/apps/apps_openapi_key_reset.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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"))))
|
||||
}
|
||||
48
shortcuts/apps/apps_openapi_key_reset_test.go
Normal file
48
shortcuts/apps/apps_openapi_key_reset_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
43
shortcuts/apps/apps_openapi_key_status_test.go
Normal file
43
shortcuts/apps/apps_openapi_key_status_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
82
shortcuts/apps/apps_openapi_key_update.go
Normal file
82
shortcuts/apps/apps_openapi_key_update.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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
|
||||
}
|
||||
63
shortcuts/apps/apps_openapi_key_update_test.go
Normal file
63
shortcuts/apps/apps_openapi_key_update_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
351
shortcuts/apps/apps_output_schema.go
Normal file
351
shortcuts/apps/apps_output_schema.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// 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
|
||||
}
|
||||
56
shortcuts/apps/apps_output_schema_test.go
Normal file
56
shortcuts/apps/apps_output_schema_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
420
shortcuts/apps/apps_plugin_install.go
Normal file
420
shortcuts/apps/apps_plugin_install.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// 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"},
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate (install or update to latest)",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0 (install or update to specific version)",
|
||||
"Example: lark-cli apps +plugin-install (batch install all declared plugins from package.json actionPlugins)",
|
||||
},
|
||||
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
|
||||
}
|
||||
if key := strings.TrimSpace(rctx.Str("name")); key != "" {
|
||||
if err := validatePluginKey(key); 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, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(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(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
|
||||
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, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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}.
|
||||
|
||||
const pluginDownloadMaxBytes = 10 * 1024 * 1024
|
||||
|
||||
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(io.LimitReader(resp.Body, 4096))
|
||||
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)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1))
|
||||
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)
|
||||
}
|
||||
if len(data) > pluginDownloadMaxBytes {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)).
|
||||
WithHint("contact plugin maintainer to reduce package size")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
181
shortcuts/apps/apps_plugin_install_test.go
Normal file
181
shortcuts/apps/apps_plugin_install_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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 {
|
||||
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)
|
||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
||||
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"))
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
82
shortcuts/apps/apps_plugin_list.go
Normal file
82
shortcuts/apps/apps_plugin_list.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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 locally installed plugin packages and their installation status",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"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
|
||||
},
|
||||
}
|
||||
121
shortcuts/apps/apps_plugin_list_test.go
Normal file
121
shortcuts/apps/apps_plugin_list_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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)
|
||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
||||
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()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint: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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
90
shortcuts/apps/apps_plugin_uninstall.go
Normal file
90
shortcuts/apps/apps_plugin_uninstall.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"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",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"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 key := strings.TrimSpace(rctx.Str("name")); key == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
} else if err := validatePluginKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
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, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
187
shortcuts/apps/apps_plugin_uninstall_test.go
Normal file
187
shortcuts/apps/apps_plugin_uninstall_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// 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)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
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) {
|
||||
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)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
|
||||
// Create a capability that references this plugin
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755)
|
||||
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 {
|
||||
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)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
|
||||
// Create a capability that references a DIFFERENT plugin — should not block
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755)
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
50
shortcuts/apps/apps_security_fixes_test.go
Normal file
50
shortcuts/apps/apps_security_fixes_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRejectOutputTraversal pins HIGH-3: --output rejects absolute paths and
|
||||
// any .. traversal component; empty and ordinary relative paths pass.
|
||||
func TestRejectOutputTraversal(t *testing.T) {
|
||||
ok := []string{"", "out.csv", "./out.csv", "sub/dir/out.csv", "a..b.csv", "foo..bar/x.csv"}
|
||||
for _, p := range ok {
|
||||
if err := rejectOutputTraversal(p); err != nil {
|
||||
t.Errorf("rejectOutputTraversal(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
bad := []string{"/etc/passwd", "../x", "../../etc/cron.d/evil", "sub/../../x", "./../x"}
|
||||
for _, p := range bad {
|
||||
if err := rejectOutputTraversal(p); err == nil {
|
||||
t.Errorf("rejectOutputTraversal(%q) = nil, want validation error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName pins HIGH-4 / LOW-1: control & separator chars are
|
||||
// neutralized (percent-encoded, no raw CR/LF/TAB/NUL/quote) and the result never
|
||||
// starts with a dot (hidden-file overwrite guard).
|
||||
func TestSanitizeUploadFileName(t *testing.T) {
|
||||
// LOW-1: dotfiles get a leading underscore.
|
||||
for _, in := range []string{".bashrc", ".ssh", "..hidden"} {
|
||||
got := sanitizeUploadFileName(in)
|
||||
if strings.HasPrefix(got, ".") {
|
||||
t.Errorf("sanitizeUploadFileName(%q) = %q, must not start with '.'", in, got)
|
||||
}
|
||||
}
|
||||
// HIGH-4: header-breaking / control chars must not survive raw.
|
||||
raw := "a\r\nb\tc\x00d\"e.png"
|
||||
got := sanitizeUploadFileName(raw)
|
||||
for _, bad := range []string{"\r", "\n", "\t", "\x00", "\"", " "} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("sanitizeUploadFileName(%q) = %q, still contains raw %q", raw, got, bad)
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("sanitizeUploadFileName returned empty for non-empty input")
|
||||
}
|
||||
}
|
||||
664
shortcuts/apps/apps_traces.go
Normal file
664
shortcuts/apps/apps_traces.go
Normal file
@@ -0,0 +1,664 @@
|
||||
// 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
|
||||
}
|
||||
453
shortcuts/apps/apps_traces_test.go
Normal file
453
shortcuts/apps/apps_traces_test.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -39,3 +40,28 @@ func withAppsHint(err error, hint string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||||
// --output path. The authoritative guard is the local FileIO layer
|
||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||||
// symlinks), so traversal is already blocked at write time; this gives an
|
||||
// earlier, clearer validation error and pins the contract in the command layer.
|
||||
// Empty (use server-derived default) passes through. Absolute paths and any
|
||||
// ".." path component are rejected.
|
||||
func rejectOutputTraversal(output string) error {
|
||||
o := strings.TrimSpace(output)
|
||||
if o == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(o) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output must be a relative path within the current directory, got %q", o).WithParam("--output")
|
||||
}
|
||||
for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) {
|
||||
if seg == ".." {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output must not contain .. path traversal, got %q", o).WithParam("--output")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,79 @@
|
||||
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_id,CLI 自己 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(复用存量「获取数据表列表」接口)。
|
||||
@@ -32,11 +99,167 @@ 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 发布(预览/落地共用)URL:db/env_migrate。
|
||||
func appEnvMigratePath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appEnvMigrateStatusPath 返回发布异步任务状态查询 URL:db/env_migrate_status。
|
||||
func appEnvMigrateStatusPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appRecoveryPath 返回 PITR 数据恢复(预览/落地共用)URL:db/env_recovery。
|
||||
func appRecoveryPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appRecoveryDiffStatusPath 返回恢复预览(diff)异步状态查询 URL:db/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 返回恢复落地异步状态查询 URL:db/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 配额查询 URL:db/quota。
|
||||
func appDbQuotaPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// ── 变更追溯(changelog / audit)路由 ──
|
||||
|
||||
// appChangelogListPath 返回 DDL 变更记录列表 URL:db/changelog_list。
|
||||
func appChangelogListPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appAuditStatusPath 返回表审计开关状态查询 URL:db/audit_status。
|
||||
func appAuditStatusPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appAuditSetPath 返回表审计开关设置 URL:db/audit_set。
|
||||
func appAuditSetPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appAuditListPath 返回行级审计事件列表 URL:db/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/json,export 还接受 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 "", appsValidationParamError("--app-id", "--app-id is required")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user