mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
11 Commits
feat-ppe-s
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419d7df266 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 | ||
|
|
cdd9d3409b | ||
|
|
06f6b0b18c | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
@@ -1398,6 +1421,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
|
||||
@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### Skills bullet skill names, or name/relpath references
|
||||
(lark-contact/references/x.md), to read for usage;
|
||||
merged with the domain `> skill:` default (deduped,
|
||||
domain first)
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
|
||||
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
|
||||
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
|
||||
(validated against the path); help drops any that don't resolve, so a typo shows
|
||||
nothing. Point a command at its own reference (e.g. `+search-user` →
|
||||
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
|
||||
domain skill, which the `> skill:` default already covers. When a shortcut also
|
||||
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
|
||||
replace the Go tips (not merged), so keep tips in one place.
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
|
||||
so the heading must equal the shortcut command exactly (`## +history-revert`).
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## +search-user
|
||||
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-user.md
|
||||
|
||||
### Avoid when
|
||||
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
|
||||
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Find a user by name**
|
||||
```bash
|
||||
lark-cli contact +search-user --query "alice" --as user
|
||||
```
|
||||
|
||||
**Fetch known users by open_id (me = yourself)**
|
||||
```bash
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-get-user.md
|
||||
|
||||
### Avoid when
|
||||
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
|
||||
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
|
||||
|
||||
### Tips
|
||||
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
|
||||
- --user-id-type must match the id you pass (default open_id)
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1069,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one api --file upload through the mock
|
||||
// transport and returns a map of field name -> part filename parsed from the
|
||||
// captured multipart body, plus the map of text form fields. It fails the test
|
||||
// if the captured request is not multipart/form-data.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
fields := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
} else {
|
||||
buf := &bytes.Buffer{}
|
||||
_, _ = buf.ReadFrom(part)
|
||||
fields[part.FormName()] = buf.String()
|
||||
}
|
||||
}
|
||||
return filenames, fields
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if _, ok := filenames["upload"]; !ok {
|
||||
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
|
||||
}
|
||||
if got := filenames["upload"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
|
||||
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, fields := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
|
||||
}
|
||||
if got := fields["type"]; got != "attachment" {
|
||||
t.Fatalf("text field type = %q, want %q", got, "attachment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "unknown-file" {
|
||||
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
|
||||
}
|
||||
}
|
||||
|
||||
92
cmd/build.go
92
cmd/build.go
@@ -21,12 +21,16 @@ import (
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -42,6 +46,7 @@ type buildConfig struct {
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -59,6 +64,17 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmbeddedSkills customizes the CLI's embedded skills for a caller that builds
|
||||
// the command tree directly instead of registering a plugin. It is the
|
||||
// build-option analogue of a plugin's EmbeddedSkills(...): the same single-owner
|
||||
// rule applies, so combining WithEmbeddedSkills with a plugin that also customizes
|
||||
// skills aborts startup. nil is a no-op.
|
||||
func WithEmbeddedSkills(spec *platform.SkillsOverlay) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skillsOverlay = spec
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
@@ -71,6 +87,21 @@ var embeddedSkillContent fs.FS
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// withEmbeddedSkillsOwner labels a WithEmbeddedSkills contribution in the skill resolver so a
|
||||
// WithEmbeddedSkills + plugin-Skills collision names a stable, non-plugin owner.
|
||||
const withEmbeddedSkillsOwner = "cmd.WithEmbeddedSkills"
|
||||
|
||||
// resolveSkillContent composes the effective embedded skill tree from the CLI
|
||||
// default, an optional WithEmbeddedSkills spec, and plugin SkillsOverlays, enforcing the
|
||||
// single-owner rule across all sources.
|
||||
func resolveSkillContent(cfg *buildConfig, pluginSkills []skillpolicy.PluginSkill) (fs.FS, error) {
|
||||
sources := pluginSkills
|
||||
if cfg.skillsOverlay != nil {
|
||||
sources = append([]skillpolicy.PluginSkill{{PluginName: withEmbeddedSkillsOwner, SkillsOverlay: cfg.skillsOverlay}}, sources...)
|
||||
}
|
||||
return skillpolicy.Resolve(embeddedSkillContent, sources)
|
||||
}
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -154,6 +185,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Reset every process-global snapshot up front, not only inside
|
||||
// applyUserPolicyPruning: the skipPlugins and install-failure paths
|
||||
// return before pruning runs, and a previous build's state must not
|
||||
// leak into this one (long-lived embedders, test sequences).
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
cmdpolicy.SetActive(nil)
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
@@ -175,7 +214,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
// The skill-content getter also gates on the skills command domain:
|
||||
// every pointer it feeds renders as `lark-cli skills read ...`, so with
|
||||
// that domain plugin-denied the pointers would all be dead ends even
|
||||
// though the content itself still exists.
|
||||
installTipsHelpFunc(rootCmd, func() fs.FS {
|
||||
if policystate.DomainDeniedByPlugin("skills") {
|
||||
return nil
|
||||
}
|
||||
return f.SkillContent
|
||||
})
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
@@ -223,6 +271,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
installHelpCommand(rootCmd)
|
||||
resolved, err := resolveSkillContent(cfg, nil)
|
||||
if err != nil {
|
||||
installPluginSkillErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
f.SkillContent = resolved
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
@@ -233,23 +288,52 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var pluginSkills []skillpolicy.PluginSkill
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
pluginSkills = installResult.PluginSkills
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
|
||||
if policyErr != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
installPluginConflictGuard(rootCmd, policyErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
warnPolicyError(cfg.streams.ErrOut, policyErr)
|
||||
}
|
||||
|
||||
// The custom help command attaches AFTER policy evaluation on purpose:
|
||||
// it is a framework meta command, and inside the evaluated tree an
|
||||
// allow-list rule (Allow: ["im/**"]) would deny it as
|
||||
// domain_not_allowed — cobra's stock help command is likewise attached
|
||||
// only at Execute time and never evaluated.
|
||||
installHelpCommand(rootCmd)
|
||||
|
||||
// Presentation passes: a capability an integrator plugin denied
|
||||
// presents as absent — retired global flags (--profile), no skills
|
||||
// footer, diagnostics hidden or retired. Enforcement stays with
|
||||
// cmdpolicy.Apply above; these only shape help and fixed hints.
|
||||
applyPluginPresentation(rootCmd, installResult, denied)
|
||||
|
||||
// Resolve the embedded skill tree BEFORE wiring hooks: an invalid
|
||||
// SkillsOverlay must fail fast, before wireHooks emits Startup, so a Startup
|
||||
// side effect is never stranded without its Shutdown. Both skill readers
|
||||
// -- `skills list`/`read` and the --help guidance -- then read this one
|
||||
// f.SkillContent. Fails closed: never silently ship defaults once a
|
||||
// customization is declared.
|
||||
resolvedSkills, skillErr := resolveSkillContent(cfg, pluginSkills)
|
||||
if skillErr != nil {
|
||||
installPluginSkillErrorGuard(rootCmd, skillErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
f.SkillContent = resolvedSkills
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
type noopConfigKeychain struct{}
|
||||
@@ -564,3 +565,54 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The "no active profile" hint points at `lark-cli profile list`; that pointer
|
||||
// must be gated on the profile domain still being present. When a plugin denies
|
||||
// the profile domain, the hint would be a dead end, so it is omitted — the error
|
||||
// itself is unchanged.
|
||||
func TestConfigShowRun_ProfileHintGatedByPluginDenial(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
|
||||
hintOf := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configShowRun(&ConfigShowOptions{Factory: f})
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Fatalf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
return cfgErr.Hint
|
||||
}
|
||||
|
||||
t.Run("profile domain present: hint points at profile list", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
if h := hintOf(t); !strings.Contains(h, "lark-cli profile list") {
|
||||
t.Errorf("hint = %q, want it to reference `lark-cli profile list`", h)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile domain plugin-denied: hint omitted", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"profile": true})
|
||||
if h := hintOf(t); h != "" {
|
||||
t.Errorf("hint = %q, want empty when the profile domain is plugin-denied", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
if len(p.Rules) > 0 {
|
||||
entry["rules"] = p.Rules
|
||||
}
|
||||
if p.EmbeddedSkills != nil {
|
||||
entry["embedded_skills"] = p.EmbeddedSkills
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
|
||||
93
cmd/config/plugins_test.go
Normal file
93
cmd/config/plugins_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// config plugins show must surface a plugin's EmbeddedSkills contribution in
|
||||
// the rendered JSON, not only in the internal inventory struct: this command is
|
||||
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
|
||||
// Base summary has to reach stdout. Guards the render layer, which asserting the
|
||||
// inventory struct alone does not exercise.
|
||||
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
EmbeddedSkills: &internalplatform.SkillsOverlayView{
|
||||
Allow: []string{"lark-im"},
|
||||
Remove: []string{"lark-a"},
|
||||
Overlay: true,
|
||||
Base: true,
|
||||
},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Plugins []struct {
|
||||
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got.Plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
|
||||
}
|
||||
es := got.Plugins[0].EmbeddedSkills
|
||||
if es == nil {
|
||||
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
|
||||
}
|
||||
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
|
||||
!es.Overlay || !es.Base {
|
||||
t.Errorf("embedded_skills summary mismatch: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that did not customize embedded skills must not emit an
|
||||
// embedded_skills key, so the field's presence is a reliable signal that a fork
|
||||
// trimmed the tree.
|
||||
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
|
||||
internalplatform.SetActiveInventory(&internalplatform.Inventory{
|
||||
Plugins: []internalplatform.PluginEntry{{
|
||||
Name: "acme",
|
||||
Version: "1.0",
|
||||
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
|
||||
}},
|
||||
})
|
||||
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
|
||||
if err := runConfigPluginsShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
plugins, ok := raw["plugins"].([]any)
|
||||
if !ok || len(plugins) != 1 {
|
||||
t.Fatalf("want 1 plugin in output, got: %s", out.String())
|
||||
}
|
||||
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
|
||||
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
e := errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile")
|
||||
// No recovery hint when the profile domain is absent from this build.
|
||||
if !policystate.DomainDeniedByPlugin("profile") {
|
||||
e = e.WithHint("run: lark-cli profile list")
|
||||
}
|
||||
return e
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
109
cmd/flag_gate.go
Normal file
109
cmd/flag_gate.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
// globalFlagDomains maps each root persistent flag to the command domain
|
||||
// it belongs to. A new domain-tied global flag must add a row.
|
||||
var globalFlagDomains = map[string]string{
|
||||
"profile": "profile",
|
||||
}
|
||||
|
||||
// flagGateAnnotation distinguishes a policy-retired flag from one hidden
|
||||
// cosmetically (single-app mode force-shows the latter in root help).
|
||||
const flagGateAnnotation = "lark:policy_denied_flag"
|
||||
|
||||
// applyPluginFlagGate hides and rejects the global flags whose whole
|
||||
// domain a plugin denied. yaml denials do not gate flags. Must run after
|
||||
// RegisterGlobalFlags and cmdpolicy.Apply.
|
||||
func applyPluginFlagGate(root *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||
gated := false
|
||||
for flagName, domain := range globalFlagDomains {
|
||||
d, ok := denied[domain]
|
||||
if !ok || !cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
continue
|
||||
}
|
||||
fl := root.PersistentFlags().Lookup(flagName)
|
||||
if fl == nil {
|
||||
continue
|
||||
}
|
||||
fl.Hidden = true
|
||||
if fl.Annotations == nil {
|
||||
fl.Annotations = map[string][]string{}
|
||||
}
|
||||
fl.Annotations[flagGateAnnotation] = []string{"true"}
|
||||
gated = true
|
||||
}
|
||||
if gated {
|
||||
installFlagGateRejection(root)
|
||||
}
|
||||
}
|
||||
|
||||
func isPolicyGatedFlag(fl *pflag.Flag) bool {
|
||||
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
|
||||
}
|
||||
|
||||
// installFlagGateRejection rejects gated flags right after parsing.
|
||||
// pflag has no runtime unregister, so the flag still parses; and cobra
|
||||
// resolves PersistentPreRunE "first non-nil wins" walking up from the
|
||||
// leaf, so every command carrying its own (cmd/auth, cmd/config) must be
|
||||
// wrapped too, not just the root.
|
||||
func installFlagGateRejection(root *cobra.Command) {
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
if prev := c.PersistentPreRunE; prev != nil {
|
||||
c.PersistentPreRunE = func(cc *cobra.Command, args []string) error {
|
||||
if err := rejectGatedFlags(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
return prev(cc, args)
|
||||
}
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
prevRun := root.PersistentPreRun
|
||||
root.PersistentPreRun = nil
|
||||
root.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||
if err := rejectGatedFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if prevRun != nil {
|
||||
prevRun(c, args)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, child := range root.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
|
||||
// rejectGatedFlags fails a set policy-retired flag with the same shape an
|
||||
// unregistered flag produces (see flagDidYouMean).
|
||||
//
|
||||
// VisitAll + fl.Changed, not Visit: cobra parses a persistent flag on the leaf
|
||||
// command's merged flagset, so root.PersistentFlags()'s "changed" table stays
|
||||
// empty and Visit (which only walks that table) never fires on the dispatch
|
||||
// path. The flag objects are shared by pointer across the merge, so fl.Changed
|
||||
// reflects a real leaf-level parse.
|
||||
func rejectGatedFlags(c *cobra.Command) error {
|
||||
var rejected error
|
||||
c.Root().PersistentFlags().VisitAll(func(fl *pflag.Flag) {
|
||||
if rejected == nil && fl.Changed && isPolicyGatedFlag(fl) {
|
||||
rejected = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+fl.Name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + fl.Name, Reason: "unknown flag"}).
|
||||
WithHint("run `%s --help` to see valid flags", c.CommandPath())
|
||||
}
|
||||
})
|
||||
return rejected
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,15 @@ const userPolicyFileName = "policy.yml"
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
//
|
||||
// The returned denied map (nil when no rule denied anything) feeds the
|
||||
// post-pruning presentation passes in build.go: the global-flag gate and
|
||||
// the fixed-hint filter both key off which domains a plugin denied.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
|
||||
// Reset up front so every early return leaves the process-global
|
||||
// snapshot clean; the success path re-populates it.
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
|
||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||
@@ -65,7 +74,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return lerr
|
||||
return nil, lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
@@ -77,11 +86,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
@@ -94,17 +103,39 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
ruleName = rules[0].Name
|
||||
}
|
||||
|
||||
// DeniedMessage is build-level: the first non-empty message across
|
||||
// the single owner's rules speaks for all of them.
|
||||
deniedMessage := ""
|
||||
for _, r := range rules {
|
||||
if r.DeniedMessage != "" {
|
||||
deniedMessage = r.DeniedMessage
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
engine := cmdpolicy.NewSet(rules)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName, deniedMessage)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
DeniedByPath: denied,
|
||||
})
|
||||
return nil
|
||||
|
||||
// Whole-domain denials surface as aggregate entries keyed by the
|
||||
// bare domain name (no slash); record them for render-time hint
|
||||
// emitters (internal/auth, internal/client, the notice provider).
|
||||
pluginDomains := map[string]bool{}
|
||||
for path, d := range denied {
|
||||
if !strings.Contains(path, "/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
pluginDomains[path] = true
|
||||
}
|
||||
}
|
||||
policystate.SetPluginDeniedDomains(pluginDomains)
|
||||
return denied, nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
@@ -156,7 +187,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
|
||||
for _, ps := range installResult.PluginSkills {
|
||||
if ps.SkillsOverlay == nil {
|
||||
continue
|
||||
}
|
||||
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
|
||||
PluginName: ps.PluginName,
|
||||
View: internalplatform.SkillsOverlayView{
|
||||
Allow: ps.SkillsOverlay.Allow,
|
||||
Remove: ps.SkillsOverlay.Remove,
|
||||
Overlay: ps.SkillsOverlay.Overlay != nil,
|
||||
Base: ps.SkillsOverlay.Base != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
|
||||
@@ -116,7 +116,7 @@ max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
_, err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
@@ -110,6 +111,27 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
|
||||
// error before any command runs. Two failure modes, split by reason code:
|
||||
//
|
||||
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
|
||||
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
|
||||
//
|
||||
// The CLI must NOT silently fall back to default skills once an
|
||||
// integrator has declared a customization.
|
||||
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() error {
|
||||
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
|
||||
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
|
||||
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
|
||||
WithHint("plugin skill customization is broken (reason_code %s); fix the plugin's SkillsOverlay or remove the conflicting plugin", reasonCode).
|
||||
WithCause(err)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
|
||||
169
cmd/presentation.go
Normal file
169
cmd/presentation.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// applyPluginPresentation makes plugin-denied capabilities present as
|
||||
// absent: retired flags, no skills footer, diagnostics hidden or retired.
|
||||
// Presentation only — enforcement happened in cmdpolicy.Apply.
|
||||
func applyPluginPresentation(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||
applyPluginFlagGate(rootCmd, denied)
|
||||
|
||||
if domainDeniedByPlugin(denied, "skills") {
|
||||
rootCmd.SetUsageTemplate(strings.Replace(rootUsageTemplate, skillsSetupFooter, "", 1))
|
||||
}
|
||||
|
||||
if installResult != nil && hideDiagnosticsOwner(installResult.Plugins) != "" {
|
||||
retireDiagnostics(rootCmd, installResult, denied)
|
||||
} else {
|
||||
concealDiagnostics(rootCmd, denied)
|
||||
}
|
||||
}
|
||||
|
||||
// domainDeniedByPlugin reads the freshly-built denied map, unlike
|
||||
// policystate.DomainDeniedByPlugin which serves render-time consumers.
|
||||
func domainDeniedByPlugin(denied map[string]cmdpolicy.Denial, domain string) bool {
|
||||
d, ok := denied[domain]
|
||||
return ok && cmdpolicy.IsPluginPolicySource(d.PolicySource)
|
||||
}
|
||||
|
||||
// hideDiagnosticsOwner returns the plugin that declared HideDiagnostics,
|
||||
// or "". The host already enforced that it also Restricts.
|
||||
func hideDiagnosticsOwner(plugins []internalplatform.PluginInfo) string {
|
||||
for _, p := range plugins {
|
||||
if p.Capabilities.HideDiagnostics {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// retireDiagnostics installs the unavailable presentation on the
|
||||
// diagnostic exemptions, same as any other plugin-denied command.
|
||||
func retireDiagnostics(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||
source := "plugin:" + hideDiagnosticsOwner(installResult.Plugins)
|
||||
message := ""
|
||||
for _, d := range denied {
|
||||
if cmdpolicy.IsPluginPolicySource(d.PolicySource) && d.DeniedMessage != "" {
|
||||
message = d.DeniedMessage
|
||||
break
|
||||
}
|
||||
}
|
||||
diag := map[string]cmdpolicy.Denial{}
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
diag[path] = cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: source,
|
||||
ReasonCode: "diagnostics_hidden",
|
||||
Reason: "policy self-inspection hidden by the integrator",
|
||||
DeniedMessage: message,
|
||||
}
|
||||
}
|
||||
cmdpolicy.Apply(rootCmd, diag)
|
||||
}
|
||||
|
||||
// concealDiagnostics hides the diagnostic exemptions from help — without
|
||||
// touching their RunE, so they stay dispatchable — once every non-exempt
|
||||
// sibling in their domain is already hidden. The group itself then gets
|
||||
// the unavailable stub: it escaped denial aggregation only because the
|
||||
// exemptions kept a runnable descendant alive, and its unknown-subcommand
|
||||
// guard RunE would otherwise keep it listed in help.
|
||||
func concealDiagnostics(rootCmd *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||
for _, group := range diagnosticDomainGroups(rootCmd) {
|
||||
if !pluginDeniedUnder(denied, cmdpolicy.CanonicalPath(group)) {
|
||||
continue
|
||||
}
|
||||
exemptAncestors := map[*cobra.Command]bool{}
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
for c := findByPath(rootCmd, path); c != nil && c != group; c = c.Parent() {
|
||||
exemptAncestors[c] = true
|
||||
}
|
||||
}
|
||||
allOthersHidden := true
|
||||
for _, child := range group.Commands() {
|
||||
if child.Name() == "help" || exemptAncestors[child] {
|
||||
continue
|
||||
}
|
||||
if !child.Hidden {
|
||||
allOthersHidden = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allOthersHidden {
|
||||
continue
|
||||
}
|
||||
for c := range exemptAncestors {
|
||||
c.Hidden = true
|
||||
}
|
||||
var sample cmdpolicy.Denial
|
||||
for path, d := range denied {
|
||||
if strings.HasPrefix(path, cmdpolicy.CanonicalPath(group)+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
sample = d
|
||||
break
|
||||
}
|
||||
}
|
||||
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
|
||||
cmdpolicy.CanonicalPath(group): {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: sample.PolicySource,
|
||||
RuleName: sample.RuleName,
|
||||
ReasonCode: "all_children_denied",
|
||||
Reason: "all child commands are denied",
|
||||
DeniedMessage: sample.DeniedMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticDomainGroups returns the top-level groups containing a
|
||||
// diagnostic exemption (today just `config`).
|
||||
func diagnosticDomainGroups(rootCmd *cobra.Command) []*cobra.Command {
|
||||
seen := map[*cobra.Command]bool{}
|
||||
var out []*cobra.Command
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
top, _, _ := strings.Cut(path, "/")
|
||||
if c := findByPath(rootCmd, top); c != nil && !seen[c] {
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginDeniedUnder(denied map[string]cmdpolicy.Denial, prefix string) bool {
|
||||
for path, d := range denied {
|
||||
if strings.HasPrefix(path, prefix+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findByPath resolves a canonical slash path (e.g. "config/policy/show")
|
||||
// to the command node, or nil.
|
||||
func findByPath(rootCmd *cobra.Command, path string) *cobra.Command {
|
||||
cur := rootCmd
|
||||
for _, seg := range strings.Split(path, "/") {
|
||||
var next *cobra.Command
|
||||
for _, child := range cur.Commands() {
|
||||
if child.Name() == seg {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
397
cmd/presentation_test.go
Normal file
397
cmd/presentation_test.go
Normal file
@@ -0,0 +1,397 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
// restrictingPlugin registers a plugin that denies the given globs; extra
|
||||
// customizes the builder further (nil for none).
|
||||
func restrictingPlugin(t *testing.T, deny []string, extra func(*platform.Builder) *platform.Builder) {
|
||||
t.Helper()
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
b := platform.NewPlugin("acme", "1.0").Restrict(&platform.Rule{Deny: deny})
|
||||
if extra != nil {
|
||||
b = extra(b)
|
||||
}
|
||||
platform.Register(b.MustBuild())
|
||||
}
|
||||
|
||||
// runnableUnder returns the first runnable non-exempt descendant under
|
||||
// the named top-level group of the real command tree (the diagnostic
|
||||
// exemptions keep their original RunE and would not exercise the stub).
|
||||
func runnableUnder(t *testing.T, root *cobra.Command, group string) *cobra.Command {
|
||||
t.Helper()
|
||||
g := findByPath(root, group)
|
||||
if g == nil {
|
||||
t.Fatalf("group %q not found in command tree", group)
|
||||
}
|
||||
var find func(c *cobra.Command) *cobra.Command
|
||||
find = func(c *cobra.Command) *cobra.Command {
|
||||
if c.RunE != nil && len(c.Commands()) == 0 && !cmdpolicy.IsDiagnosticPath(cmdpolicy.CanonicalPath(c)) {
|
||||
return c
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
if leaf := find(child); leaf != nil {
|
||||
return leaf
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
leaf := find(g)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable non-exempt leaf under %q", group)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
// A plugin-denied command answers with command_unavailable: default
|
||||
// message, no hint, no policy vocabulary.
|
||||
func TestBuildInternal_pluginDenyPresentsUnavailable(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := runnableUnder(t, root, "config")
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("subtype = %q, want command_unavailable", ve.Subtype)
|
||||
}
|
||||
if ve.Message != cmdpolicy.DefaultUnavailableMessage || ve.Hint != "" {
|
||||
t.Errorf("message=%q hint=%q, want default message and empty hint", ve.Message, ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule.DeniedMessage speaks in the integrator's product voice.
|
||||
func TestBuildInternal_deniedMessageOverride(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
Restrict(&platform.Rule{Deny: []string{"config/**"}, DeniedMessage: "not part of acme cli"}).
|
||||
MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := runnableUnder(t, root, "config")
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Message != "not part of acme cli" {
|
||||
t.Errorf("message = %q, want the integrator's DeniedMessage", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit help on a plugin-denied command must not render the original
|
||||
// usage; it answers with the same unavailable envelope.
|
||||
func TestBuildInternal_pluginDenyHelpIntercepted(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := runnableUnder(t, root, "config")
|
||||
|
||||
var buf bytes.Buffer
|
||||
leaf.SetOut(&buf)
|
||||
leaf.SetErr(&buf)
|
||||
root.HelpFunc()(leaf, nil)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "command_unavailable") {
|
||||
t.Errorf("explicit help must answer command_unavailable, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "Usage:") {
|
||||
t.Errorf("explicit help must not render the original usage, got:\n%s", out)
|
||||
}
|
||||
|
||||
// yaml-source presentation is untouched: a command outside the denied
|
||||
// domain still renders normal help.
|
||||
var normal bytes.Buffer
|
||||
alive := findByPath(root, "skills")
|
||||
alive.SetOut(&normal)
|
||||
alive.SetErr(&normal)
|
||||
root.HelpFunc()(alive, nil)
|
||||
if strings.Contains(normal.String(), "command_unavailable") {
|
||||
t.Errorf("non-denied command help must render normally, got:\n%s", normal.String())
|
||||
}
|
||||
}
|
||||
|
||||
// `lark-cli help <plugin-restricted-cmd>` must fail with the typed
|
||||
// unavailable error (exit non-zero), not print an envelope and exit 0.
|
||||
func TestBuildInternal_helpCommandReturnsUnavailable(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
helpCmd := findByPath(root, "help")
|
||||
if helpCmd == nil || helpCmd.RunE == nil {
|
||||
t.Fatal("custom help command not installed")
|
||||
}
|
||||
|
||||
err := helpCmd.RunE(helpCmd, []string{"config"})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("help on a restricted command must return command_unavailable, got %v", err)
|
||||
}
|
||||
|
||||
// A live target renders normally and returns nil.
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
if err := helpCmd.RunE(helpCmd, []string{"skills"}); err != nil {
|
||||
t.Errorf("help on a live command must succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// help is a framework meta command: an allow-list rule must not deny it.
|
||||
// `help <live-cmd>` renders; `help <denied-cmd>` returns unavailable.
|
||||
func TestBuildInternal_helpSurvivesAllowList(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
Restrict(&platform.Rule{Allow: []string{"im/**"}, AllowUnannotated: true}).
|
||||
MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
helpCmd := findByPath(root, "help")
|
||||
if helpCmd == nil || helpCmd.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||
t.Fatalf("help must not be policy-denied under an allow-list; annotations=%v", helpCmd.Annotations)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
if err := helpCmd.RunE(helpCmd, []string{"im"}); err != nil {
|
||||
t.Errorf("help on an allowed domain must render, got %v", err)
|
||||
}
|
||||
|
||||
err := helpCmd.RunE(helpCmd, []string{"config"})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("help on a denied domain must return command_unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin inventory surfaces the new contributions so `config plugins
|
||||
// show` can answer "what did this build customize".
|
||||
func TestBuildInternal_inventoryCoversNewCapabilities(t *testing.T) {
|
||||
tmpHome(t)
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
restrictingPlugin(t, []string{"profile/**"}, func(b *platform.Builder) *platform.Builder {
|
||||
return b.HideDiagnostics().
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||
})
|
||||
|
||||
buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
inv := internalplatform.GetActiveInventory()
|
||||
if inv == nil || len(inv.Plugins) != 1 {
|
||||
t.Fatalf("inventory = %+v, want 1 plugin", inv)
|
||||
}
|
||||
p := inv.Plugins[0]
|
||||
if !p.Capabilities.HideDiagnostics {
|
||||
t.Error("inventory must surface HideDiagnostics")
|
||||
}
|
||||
if p.EmbeddedSkills == nil || len(p.EmbeddedSkills.Remove) != 1 || p.EmbeddedSkills.Remove[0] != "lark-a" {
|
||||
t.Errorf("inventory must summarise EmbeddedSkills, got %+v", p.EmbeddedSkills)
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the whole profile domain retires --profile: hidden from help,
|
||||
// and setting it fails like an unknown flag.
|
||||
func TestBuildInternal_profileFlagGate(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
fl := root.PersistentFlags().Lookup("profile")
|
||||
if fl == nil {
|
||||
t.Fatal("--profile not registered")
|
||||
}
|
||||
if !fl.Hidden || !isPolicyGatedFlag(fl) {
|
||||
t.Errorf("flag should be hidden and policy-gated; hidden=%v gated=%v", fl.Hidden, isPolicyGatedFlag(fl))
|
||||
}
|
||||
|
||||
// Setting the flag (as cobra's parse would) must be rejected as unknown.
|
||||
if err := root.PersistentFlags().Set("profile", "prod"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
err := rejectGatedFlags(root)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||
t.Errorf("setting a gated flag must fail as unknown flag, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must also fire on the real dispatch path. cobra parses a persistent
|
||||
// flag on the leaf command's merged flagset, so a gate that walks
|
||||
// root.PersistentFlags()'s changed table is a no-op once the flag is passed to
|
||||
// a subcommand. Drive root.Execute end to end and confirm the gated --profile
|
||||
// is rejected before the command body runs.
|
||||
func TestBuildInternal_profileFlagGate_RejectsOnDispatch(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
ranBody := false
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "gateprobe",
|
||||
RunE: func(*cobra.Command, []string) error { ranBody = true; return nil },
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
root.SetArgs([]string{"--profile", "prod", "gateprobe"})
|
||||
|
||||
err := root.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||
t.Errorf("a gated --profile passed on the dispatch path must fail as unknown flag, got %v", err)
|
||||
}
|
||||
if ranBody {
|
||||
t.Error("gated --profile must be rejected before the command body runs")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the profile domain denied, the gate stays inert.
|
||||
func TestBuildInternal_profileFlagUntouchedWithoutDenial(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if fl := root.PersistentFlags().Lookup("profile"); isPolicyGatedFlag(fl) {
|
||||
t.Error("--profile must not be gated when its domain is not denied")
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the skills domain drops the root-help skills-setup footer.
|
||||
func TestBuildInternal_skillsFooterSuppressed(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if strings.Contains(root.UsageTemplate(), "Skills setup") {
|
||||
t.Error("skills-setup footer must be dropped when the skills domain is denied")
|
||||
}
|
||||
|
||||
// Control: without the denial the footer stays.
|
||||
platform.ResetForTesting()
|
||||
policystate.ResetForTesting()
|
||||
_, root2, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if !strings.Contains(root2.UsageTemplate(), "Skills setup") {
|
||||
t.Error("skills-setup footer must stay in the default build")
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the skills command domain kills every `skills read` pointer in
|
||||
// domain help, even when the skill content itself is still embedded — the
|
||||
// command the pointers name is absent, so they would all be dead ends.
|
||||
func TestBuildInternal_skillsDomainDenyKillsHelpPointers(t *testing.T) {
|
||||
tmpHome(t)
|
||||
withBaseSkills(t, map[string]string{"lark-im/SKILL.md": "---\ndescription: im\n---\n"})
|
||||
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
im := findByPath(root, "im")
|
||||
if im == nil {
|
||||
t.Fatal("im domain not in tree")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
im.SetOut(&buf)
|
||||
im.SetErr(&buf)
|
||||
root.HelpFunc()(im, nil)
|
||||
if strings.Contains(buf.String(), "skills read") {
|
||||
t.Errorf("domain help must not point at the denied skills command, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the update domain silences the _notice providers that would
|
||||
// steer the caller to `lark-cli update`.
|
||||
func TestComposePendingNotice_updateDomainDenied(t *testing.T) {
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "1.0.1"})
|
||||
t.Cleanup(func() { update.SetPending(nil) })
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"update": true})
|
||||
if got := composePendingNotice(); got != nil {
|
||||
t.Errorf("notices must be silenced with the update domain denied, got %+v", got)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
if got := composePendingNotice(); got == nil {
|
||||
t.Error("notices must render without the denial")
|
||||
}
|
||||
}
|
||||
|
||||
// Default: diagnostics stay executable but leave help when their whole
|
||||
// domain is denied — cobra then drops the empty config group entirely.
|
||||
func TestBuildInternal_concealDiagnostics(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
show := findByPath(root, "config/policy/show")
|
||||
if show == nil {
|
||||
t.Fatal("config policy show not in tree")
|
||||
}
|
||||
if !show.Hidden {
|
||||
t.Error("exempt diagnostic should be hidden from help when its domain is denied")
|
||||
}
|
||||
// Still dispatchable: its RunE is the original, not an unavailable stub.
|
||||
if show.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||
t.Error("exempt diagnostic must not carry a denial stub by default")
|
||||
}
|
||||
if cfg := findByPath(root, "config"); cfg.IsAvailableCommand() {
|
||||
t.Error("config group with no visible children must drop from help")
|
||||
}
|
||||
}
|
||||
|
||||
// HideDiagnostics retires the exemptions like any other denied command.
|
||||
func TestBuildInternal_hideDiagnostics(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
|
||||
return b.HideDiagnostics()
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
show := findByPath(root, "config/policy/show")
|
||||
if show == nil {
|
||||
t.Fatal("config policy show not in tree")
|
||||
}
|
||||
err := show.RunE(show, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("hidden diagnostic must answer command_unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -105,8 +106,14 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
hint := fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)
|
||||
// The switch-policy pointer names `config strict-mode`; with
|
||||
// the config domain plugin-denied it would be a dead end.
|
||||
if !policystate.DomainDeniedByPlugin("config") {
|
||||
hint += "; " + stubHint
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithHint("%s", hint).
|
||||
WithCause(cd)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -379,3 +380,41 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
// The strict-mode stub's RunE appends a `config strict-mode` switch-policy
|
||||
// pointer to its hint — but only when the config domain is still present. With
|
||||
// the config domain plugin-denied that pointer is a dead end, so it is omitted;
|
||||
// the denial itself (message, subtype) is unchanged.
|
||||
func TestStrictModeStub_ConfigHintGatedByPluginDenial(t *testing.T) {
|
||||
hintOf := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
stub := strictModeStubFrom(child, core.StrictModeBot)
|
||||
err := stub.RunE(stub, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
return verr.Hint
|
||||
}
|
||||
|
||||
t.Run("config domain present: switch-policy pointer included", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
if h := hintOf(t); !strings.Contains(h, "config strict-mode") {
|
||||
t.Errorf("hint = %q, want it to reference `config strict-mode`", h)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config domain plugin-denied: switch-policy pointer omitted", func(t *testing.T) {
|
||||
policystate.ResetForTesting()
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
|
||||
if h := hintOf(t); strings.Contains(h, "config strict-mode") {
|
||||
t.Errorf("hint = %q, want no `config strict-mode` pointer when config is plugin-denied", h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
93
cmd/root.go
93
cmd/root.go
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -80,11 +82,16 @@ Global Flags:
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}` + skillsSetupFooter + `
|
||||
`
|
||||
|
||||
// skillsSetupFooter is the root-help pointer at the human one-time skills
|
||||
// setup. Split out so the presentation pass can drop it from the template
|
||||
// when an integrator plugin denies the skills domain.
|
||||
const skillsSetupFooter = `{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
@@ -167,6 +174,10 @@ func setupNotices() {
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
// All three notices steer the caller to `lark-cli update`.
|
||||
if policystate.DomainDeniedByPlugin("update") {
|
||||
return nil
|
||||
}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
@@ -658,16 +669,80 @@ func visibleFlagNames(c *cobra.Command) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// installHelpCommand replaces cobra's default help command so that
|
||||
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
|
||||
// instead of printing an envelope and exiting 0 — cobra's stock help
|
||||
// command has no error channel.
|
||||
func installHelpCommand(root *cobra.Command) {
|
||||
helpCmd := &cobra.Command{
|
||||
Use: "help [command]",
|
||||
Short: "Help about any command",
|
||||
Long: "Help provides help for any command in the application.",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
target, _, err := root.Find(args)
|
||||
if err != nil || target == nil {
|
||||
c.Printf("Unknown help topic %#q\n", args)
|
||||
return root.Usage()
|
||||
}
|
||||
if msg, ok := unavailableHelpMessage(target); ok {
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
|
||||
}
|
||||
target.InitDefaultHelpFlag()
|
||||
return target.Help()
|
||||
},
|
||||
}
|
||||
// help attaches after policy evaluation (framework meta command, never
|
||||
// policy-evaluated); the read annotation is defensive in case a future
|
||||
// pass re-evaluates the finished tree.
|
||||
cmdutil.SetRisk(helpCmd, "read")
|
||||
cmdutil.DisableAuthCheck(helpCmd)
|
||||
root.SetHelpCommand(helpCmd)
|
||||
// SetHelpCommand alone defers attachment to Execute's
|
||||
// InitDefaultHelpCmd; add it now so the built tree is complete
|
||||
// (InitDefaultHelpCmd re-adds idempotently).
|
||||
root.AddCommand(helpCmd)
|
||||
}
|
||||
|
||||
// unavailableHelpMessage returns the message to render in place of help
|
||||
// for a plugin-restricted command. yaml-source denials keep original help.
|
||||
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerPolicy ||
|
||||
!cmdpolicy.IsPluginPolicySource(cmd.Annotations[cmdpolicy.AnnotationDenialSource]) {
|
||||
return "", false
|
||||
}
|
||||
if msg := cmd.Annotations[cmdpolicy.AnnotationDenialMessage]; msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
return cmdpolicy.DefaultUnavailableMessage, true
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
//
|
||||
// skillContent is read lazily at help-render time (not captured up front) so
|
||||
// the domain-guide pointer reflects the resolved skill tree -- the same
|
||||
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
|
||||
// customization is applied after this help func is installed.
|
||||
func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
// Explicit help on a plugin-restricted command answers the same
|
||||
// unavailable envelope as its RunE stub, not the original usage.
|
||||
if msg, ok := unavailableHelpMessage(cmd); ok {
|
||||
output.WriteTypedErrorEnvelope(cmd.ErrOrStderr(),
|
||||
errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg), "")
|
||||
return
|
||||
}
|
||||
if cmd == root {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
// Force-show flags hidden by single-app mode; never a
|
||||
// policy-retired one.
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
@@ -675,11 +750,15 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
if service.PrepareDomainHelp(cmd, skillContent()) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
if service.PrepareMethodHelp(cmd, skillContent()) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, skillContent()) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// nilSkills is the skill-content getter used by help-func tests that do
|
||||
// not exercise the domain-guide pointer.
|
||||
func nilSkills() fs.FS { return nil }
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
|
||||
@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// domainHelpBase returns the description to seed domain help with — the
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
// hand-authored Long when present, else the Short.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return captureHelpBase(cmd, domainBaseAnnotation)
|
||||
}
|
||||
|
||||
// captureHelpBase records a command's pristine lead text once — its
|
||||
// hand-authored Long, or Short when Long is empty — into the given annotation,
|
||||
// so lazy re-renders compose onto the original text instead of onto an
|
||||
// already-augmented Long. This is what lets a shortcut's PostMount-authored
|
||||
// Long survive: it becomes the base the affordance block is appended below.
|
||||
func captureHelpBase(cmd *cobra.Command, key string) string {
|
||||
if base, ok := cmd.Annotations[key]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
cmd.Annotations[key] = base
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
}
|
||||
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmdmeta.SetAffordanceRef(cmd, service, methodID)
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
|
||||
// pointers: each is emitted only when it resolves in the skill tree (see
|
||||
// affordance.SkillStatPath), so a typo or a build without embedded skills never
|
||||
// prints a `skills read` that cannot be opened.
|
||||
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
writeRisk(&b, cmd)
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
@@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
writeRelatedSkills(&b, skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
// read the skill" directive) keeps it — the affordance block is appended below,
|
||||
// never clobbering it.
|
||||
//
|
||||
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
|
||||
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
|
||||
// the overlay declares none; when the overlay has tips, the Go tips are dropped
|
||||
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
|
||||
// therefore silently retires that shortcut's Go Tips — consolidate into one.
|
||||
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
a.Tips = cmdutil.GetTips(cmd)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
|
||||
writeRisk(&b, cmd)
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
|
||||
// high-risk-write commands. A no-op when the command has no risk annotation.
|
||||
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
|
||||
level, ok := cmdutil.GetRisk(cmd)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRelatedSkills appends the "Related skills" block for the entries that
|
||||
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
|
||||
// so help never prints a `skills read` pointer that cannot be opened.
|
||||
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||
if skillFS == nil || len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
var avail []string
|
||||
for _, s := range skills {
|
||||
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
|
||||
avail = append(avail, s)
|
||||
}
|
||||
}
|
||||
if len(avail) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
|
||||
for _, s := range avail {
|
||||
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordanceValue(a)
|
||||
}
|
||||
|
||||
// renderAffordanceValue renders an already-parsed affordance. Split from
|
||||
// renderAffordance so callers can render a value they have adjusted first (e.g.
|
||||
// a shortcut folding its declarative tips into an overlay that has none).
|
||||
func renderAffordanceValue(a meta.Affordance) string {
|
||||
var sections []string
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||
}
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
|
||||
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
if !PrepareMethodHelp(cmd, nil) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
|
||||
// top layout as method help (no schema pointer), folding declarative tips when
|
||||
// the overlay declares none, and leaves shortcuts without an overlay entry (and
|
||||
// non-shortcut commands) for the default help path.
|
||||
func TestPrepareShortcutHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service == "calendar" && methodID == "+create" {
|
||||
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||
cmdutil.SetRisk(sc, "write")
|
||||
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
|
||||
|
||||
if !PrepareShortcutHelp(sc, nil) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||
}
|
||||
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sc.Long, "Full parameter schema:") {
|
||||
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
|
||||
}
|
||||
|
||||
// No overlay entry -> leave it for the default help path.
|
||||
bare := &cobra.Command{Use: "+bare", Short: "x"}
|
||||
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
|
||||
if PrepareShortcutHelp(bare, nil) {
|
||||
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
|
||||
}
|
||||
|
||||
// Non-shortcut source is ignored even with a ref.
|
||||
notSc := &cobra.Command{Use: "create", Short: "x"}
|
||||
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
|
||||
if PrepareShortcutHelp(notSc, nil) {
|
||||
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
|
||||
}
|
||||
}
|
||||
|
||||
// Related-skill pointers are gated on existence: a skill that resolves in the
|
||||
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
|
||||
// and a nil skill FS suppresses the whole block.
|
||||
func TestRelatedSkillsStatGating(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
|
||||
}
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-real/SKILL.md": {Data: []byte("# real")},
|
||||
"lark-real/references/deep.md": {Data: []byte("# deep")},
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
|
||||
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
if !PrepareMethodHelp(cmd, skillFS) {
|
||||
t.Fatal("PrepareMethodHelp returned false")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
|
||||
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "lark-typo") {
|
||||
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
|
||||
}
|
||||
// A name/relpath reference to an existing file renders; a missing one drops.
|
||||
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
|
||||
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "references/missing.md") {
|
||||
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// nil skill FS: the whole Related-skills block is suppressed.
|
||||
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
PrepareMethodHelp(bare, nil)
|
||||
if strings.Contains(bare.Long, "Related skills") {
|
||||
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
|
||||
// PostMount) keeps it as the lead: the affordance block is appended below, not
|
||||
// clobbered, and re-rendering does not double-append.
|
||||
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
|
||||
}
|
||||
|
||||
const authored = "Custom docs help. AI agents MUST read the skill first."
|
||||
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||
|
||||
if !PrepareShortcutHelp(sc, nil) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||
}
|
||||
if !strings.HasPrefix(sc.Long, authored) {
|
||||
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
|
||||
}
|
||||
if !strings.Contains(sc.Long, "When to use:") {
|
||||
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
|
||||
}
|
||||
// Re-render must reuse the captured base, not append the block twice.
|
||||
PrepareShortcutHelp(sc, nil)
|
||||
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
|
||||
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
@@ -174,6 +297,26 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
// The domain-guide pointer is likewise gated: removing the domain's skill
|
||||
// drops the pointer instead of leaving it dangling.
|
||||
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
|
||||
present := domainCmd("Consume and manage real-time events", "")
|
||||
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
|
||||
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
|
||||
}
|
||||
|
||||
removed := domainCmd("Consume and manage real-time events", "")
|
||||
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if strings.Contains(removed.Long, "skills read lark-event") {
|
||||
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1132,6 +1136,63 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one service-method --file upload through the
|
||||
// mock transport and returns a map of field name -> part filename parsed from
|
||||
// the captured multipart body. Mirrors cmd/api's helper of the same name
|
||||
// (inlined here rather than shared, since the two live in different packages)
|
||||
// to give BuildFormdata's shared local-file fix a second real entry-point
|
||||
// covering it.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
}
|
||||
}
|
||||
return filenames
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["image"]; got != "photo.jpg" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
|
||||
227
cmd/skill_customization_test.go
Normal file
227
cmd/skill_customization_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
)
|
||||
|
||||
// withBaseSkills swaps the process-global embedded skill tree for the
|
||||
// duration of a test, restoring it afterward.
|
||||
func withBaseSkills(t *testing.T, files map[string]string) {
|
||||
t.Helper()
|
||||
base := fstest.MapFS{}
|
||||
for p, content := range files {
|
||||
base[p] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
saved := embeddedSkillContent
|
||||
t.Cleanup(func() { embeddedSkillContent = saved })
|
||||
embeddedSkillContent = base
|
||||
}
|
||||
|
||||
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
|
||||
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
|
||||
// (The --help pointers are gated on the same f.SkillContent; that gating is
|
||||
// covered by the PrepareDomainHelp/PrepareMethodHelp tests in cmd/service.)
|
||||
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||
})
|
||||
|
||||
overlay := fstest.MapFS{
|
||||
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
|
||||
}
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{
|
||||
Remove: []string{"lark-shared"},
|
||||
Overlay: overlay,
|
||||
}).MustBuild())
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if f.SkillContent == nil {
|
||||
t.Fatal("f.SkillContent is nil after skill resolution")
|
||||
}
|
||||
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
|
||||
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two plugins each customizing skills must abort at dispatch with a
|
||||
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
|
||||
// silently fall back to the default tree.
|
||||
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
platform.Register(platform.NewPlugin("globex", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("skill conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow keeps only the listed skills from the base — a CLI upgrade adding
|
||||
// new embedded skills cannot widen an allow-listed build.
|
||||
func TestBuildInternal_appliesAllowList(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
t.Cleanup(func() { policystate.ResetForTesting() })
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
|
||||
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
|
||||
})
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
|
||||
MustBuild())
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
|
||||
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
|
||||
// from the base) must abort with reason_code invalid_skills_overlay.
|
||||
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
|
||||
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmbeddedSkills customizes skills for a caller that builds the tree directly
|
||||
// (no plugin) — the build-option analogue of a plugin's EmbeddedSkills(...).
|
||||
func TestBuildInternal_withSkillsOption(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{
|
||||
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
|
||||
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
|
||||
})
|
||||
|
||||
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t),
|
||||
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}}))
|
||||
skills, err := skillcontent.New(f.SkillContent).List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range skills {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if got := strings.Join(names, ","); got != "lark-a" {
|
||||
t.Errorf("skills = %q, want lark-a (shared removed via WithEmbeddedSkills)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// WithEmbeddedSkills and a plugin's EmbeddedSkills() are two owners of skill content; the
|
||||
// single-owner rule aborts startup.
|
||||
func TestBuildInternal_withSkillsPlusPluginConflicts(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
|
||||
platform.Register(platform.NewPlugin("acme", "1.0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t),
|
||||
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}))
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatal("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
|
||||
t.Errorf("WithEmbeddedSkills + plugin should conflict; hint = %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
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)
|
||||
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
@@ -60,6 +60,7 @@ You should see `audit` in the plugin list.
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
||||
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | No (customizes embedded skills) |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
@@ -79,7 +80,7 @@ sequenceDiagram
|
||||
Host->>SDK: InstallAll()
|
||||
SDK->>Plugin: Capabilities()
|
||||
SDK->>Plugin: Install(Registrar)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
|
||||
SDK->>Plugin: On(Startup) fire
|
||||
|
||||
Note over Host,Plugin: Each command dispatch
|
||||
@@ -113,6 +114,35 @@ the rejected dispatch.
|
||||
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
||||
may itself list several rules under `rules:`) is shadowed by any plugin
|
||||
Restrict.
|
||||
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
|
||||
skill tree — `Allow` keeps only the listed skills (the allow-list
|
||||
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
|
||||
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
|
||||
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base` —
|
||||
layered over the CLI default. Unlike `Restrict()`
|
||||
it is NOT a security boundary and does not imply `FailClosed`: it
|
||||
shapes fail-open guidance content. Removing a skill only drops its
|
||||
`skills read` / `--help` guidance — it does NOT disable the matching
|
||||
commands (use `Restrict()` for that). Only ONE plugin per binary may
|
||||
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
|
||||
`multiple_skills_overlay_plugins` error.
|
||||
- A command denied by a **plugin** Rule presents as absent, not as
|
||||
forbidden: it leaves `--help` and completion, explicit help on it is
|
||||
intercepted, and invoking it answers `subtype=command_unavailable`
|
||||
with no policy vocabulary and no recovery hint. Set
|
||||
`Rule.DeniedMessage` to replace the default
|
||||
"command not included in this build" with your product's own wording.
|
||||
yaml-source denials keep the classic `command_denied` presentation —
|
||||
the user owns that policy and needs to see how to adjust it. Denying a
|
||||
whole domain also retires what points at it: `--profile` (profile
|
||||
domain) hides and rejects use, the root-help skills footer (skills),
|
||||
update notices (update), and the `auth login` recovery hint (auth)
|
||||
stop rendering.
|
||||
- `config policy show` / `config plugins show` stay executable under any
|
||||
plugin policy (hidden from help when their domain is denied) so an
|
||||
operator can still inspect the rule that locked the build. An
|
||||
integrator shipping a fully-managed distribution opts out with
|
||||
`HideDiagnostics()` — requires `Restrict()` on the same plugin.
|
||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||
install time. Long-lived state (clients, caches, metrics counters)
|
||||
must live on the Plugin struct or in package-level variables.
|
||||
@@ -153,6 +183,8 @@ messages are localised and may change between releases.
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||
| `invalid_skills_overlay` | Staging fault (`EmbeddedSkills(nil)` / second call in one plugin) honours FailurePolicy; a `SkillsOverlay` that can't compose (`Remove` names a skill absent from the base, `Overlay` entry lacks `SKILL.md`) always aborts via a fatal dispatch guard | Mixed — see left |
|
||||
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
|
||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||
|
||||
@@ -187,8 +219,8 @@ should additionally check `detail.layer == "policy"`.
|
||||
- [Runnable example: audit observer](./examples/audit-observer/)
|
||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
|
||||
`HideDiagnostics`, `FailOpen`/`FailClosed`, `MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
|
||||
@@ -36,8 +36,9 @@ type Builder struct {
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
@@ -81,6 +82,15 @@ func (b *Builder) FailClosed() *Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
// HideDiagnostics retires the policy self-inspection commands
|
||||
// (`config policy show`, `config plugins show`), which otherwise stay
|
||||
// executable under a plugin policy as the operator's escape hatch.
|
||||
// Requires Restrict() on the same plugin; Build fails otherwise.
|
||||
func (b *Builder) HideDiagnostics() *Builder {
|
||||
b.caps.HideDiagnostics = true
|
||||
return b
|
||||
}
|
||||
|
||||
// Observer registers an Observer. Multiple calls accumulate.
|
||||
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
||||
if !b.validateHookName(hookName, "observer") {
|
||||
@@ -145,6 +155,35 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
return b
|
||||
}
|
||||
|
||||
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay)
|
||||
// customizing the CLI's embedded skill content. Unlike Restrict it does
|
||||
// NOT imply FailClosed: skill customization is guidance content, not a
|
||||
// security boundary. A plugin owns at most one SkillsOverlay, so calling
|
||||
// EmbeddedSkills more than once is a build error.
|
||||
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
|
||||
if spec == nil {
|
||||
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
|
||||
return b
|
||||
}
|
||||
if b.skillsOverlay != nil {
|
||||
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
|
||||
return b
|
||||
}
|
||||
b.skillsOverlay = cloneSkillsOverlay(spec)
|
||||
return b
|
||||
}
|
||||
|
||||
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
|
||||
// same *SkillsOverlay cannot alter the staged copy. The Remove slice is
|
||||
// copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS
|
||||
// is a read-only view, not caller-mutable state).
|
||||
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
|
||||
cp := *spec
|
||||
cp.Allow = append([]string(nil), spec.Allow...)
|
||||
cp.Remove = append([]string(nil), spec.Remove...)
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Build returns the configured Plugin, or an error if any builder
|
||||
// step found a fault. MustBuild panics on the same error.
|
||||
//
|
||||
@@ -155,15 +194,20 @@ func (b *Builder) Build() (Plugin, error) {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
if b.caps.HideDiagnostics && len(b.rules) == 0 {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"HideDiagnostics() requires Restrict(): there is no integrator policy to hide"))
|
||||
}
|
||||
if len(b.errs) > 0 {
|
||||
return nil, errors.Join(b.errs...)
|
||||
}
|
||||
return &builtPlugin{
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
skillsOverlay: b.skillsOverlay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -202,11 +246,12 @@ func (b *Builder) validateHookName(hookName, kind string) bool {
|
||||
|
||||
// builtPlugin is the Plugin implementation the builder emits.
|
||||
type builtPlugin struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
@@ -216,6 +261,9 @@ func (p *builtPlugin) Install(r Registrar) error {
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
if p.skillsOverlay != nil {
|
||||
r.EmbeddedSkills(p.skillsOverlay)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
// recorder Registrar captures everything a builder schedules so the
|
||||
// test can assert what Install produced without involving the host.
|
||||
type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
skillCalls int
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
@@ -30,6 +32,10 @@ func (r *recorder) Restrict(rule *platform.Rule) {
|
||||
r.rule = rule
|
||||
r.rules = append(r.rules, rule)
|
||||
}
|
||||
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||
r.skillsOverlay = spec
|
||||
r.skillCalls++
|
||||
}
|
||||
|
||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||
// same *Rule object across two Restrict calls must still get two distinct
|
||||
@@ -211,3 +217,78 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddedSkills() must snapshot Remove: a caller that mutates the same slice
|
||||
// after the call must still get the value staged at call time.
|
||||
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
|
||||
remove := []string{"lark-shared"}
|
||||
b := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: remove})
|
||||
remove[0] = "mutated"
|
||||
|
||||
p, err := b.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.skillCalls != 1 {
|
||||
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
|
||||
}
|
||||
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
|
||||
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsNilRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
|
||||
if err == nil {
|
||||
t.Fatal("EmbeddedSkills(nil) must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_skillsTwiceRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
|
||||
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatal("calling EmbeddedSkills() twice must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddedSkills() customizes guidance content, not a security boundary, so
|
||||
// unlike Restrict() it must NOT force FailClosed.
|
||||
func TestBuilder_skillsDoesNotForceFailClosed(t *testing.T) {
|
||||
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if caps.Restricts {
|
||||
t.Error("EmbeddedSkills() must not set Restricts")
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailOpen {
|
||||
t.Errorf("FailurePolicy = %v, want FailOpen (default)", caps.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// HideDiagnostics only makes sense alongside Restrict: without a rule
|
||||
// there is no integrator policy whose inspection could be hidden.
|
||||
func TestBuilder_hideDiagnosticsRequiresRestrict(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").HideDiagnostics().Build()
|
||||
if err == nil {
|
||||
t.Fatal("HideDiagnostics() without Restrict() must fail Build")
|
||||
}
|
||||
p, err := platform.NewPlugin("p", "0").
|
||||
Restrict(&platform.Rule{Deny: []string{"config/**"}}).
|
||||
HideDiagnostics().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("HideDiagnostics()+Restrict() must build: %v", err)
|
||||
}
|
||||
if !p.Capabilities().HideDiagnostics {
|
||||
t.Error("Capabilities.HideDiagnostics must be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,9 @@ type Capabilities struct {
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
FailurePolicy FailurePolicy
|
||||
|
||||
// HideDiagnostics retires the policy self-inspection commands
|
||||
// (`config policy show`, `config plugins show`) from the build.
|
||||
// Requires Restricts=true; the install fails otherwise.
|
||||
HideDiagnostics bool
|
||||
}
|
||||
|
||||
@@ -35,4 +35,18 @@ type Registrar interface {
|
||||
// Plugin rules take precedence over the yaml source; two distinct
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
|
||||
// Skills contributes a SkillsOverlay customizing the CLI's embedded skill
|
||||
// content (see SkillsOverlay). Skill content has a single owner: a second
|
||||
// customizing plugin, or a SkillsOverlay that cannot compose, aborts
|
||||
// startup unconditionally. A malformed call inside one plugin --
|
||||
// EmbeddedSkills(nil) or a second EmbeddedSkills() -- is a staging fault handled like
|
||||
// any Install error (FailClosed aborts, FailOpen skips); the Builder
|
||||
// rejects it earlier, at MustBuild.
|
||||
//
|
||||
// Unlike Restrict, EmbeddedSkills is not a security boundary -- it shapes
|
||||
// fail-open guidance content. Removing a skill drops its guidance but
|
||||
// does not disable any command; use Restrict to actually block a
|
||||
// command.
|
||||
EmbeddedSkills(spec *SkillsOverlay)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ package platform
|
||||
// Rule is the declarative policy rule data structure. yaml files and
|
||||
// Plugin.Restrict() both produce the same Rule.
|
||||
//
|
||||
// At any moment there is at most one effective Rule -- the resolver decides
|
||||
// which source wins (Plugin > yaml > none). This package only defines the
|
||||
// At any moment there is at most one effective SOURCE of rules -- the
|
||||
// resolver decides which wins (Plugin > yaml > none); the winning source
|
||||
// may contribute several scoped rules. This package only defines the
|
||||
// shape; selection lives in internal/cmdpolicy.
|
||||
//
|
||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||
@@ -57,4 +58,11 @@ type Rule struct {
|
||||
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
|
||||
// platform stays free of a yaml library dependency.
|
||||
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
|
||||
|
||||
// DeniedMessage replaces the default "command not included in this
|
||||
// build" message for plugin-denied commands. The message is
|
||||
// build-level: the first non-empty DeniedMessage across the owning
|
||||
// plugin's rules applies to every denial, so declare it once. yaml
|
||||
// rules ignore it (they keep the command-denied presentation).
|
||||
DeniedMessage string `json:"denied_message,omitempty"`
|
||||
}
|
||||
|
||||
49
extension/platform/skillsoverlay.go
Normal file
49
extension/platform/skillsoverlay.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// SkillsOverlay declares how a plugin customizes the CLI's embedded
|
||||
// skill content, contributed via Builder.EmbeddedSkills. At most one
|
||||
// source may own skill content; two customizing plugins abort startup.
|
||||
//
|
||||
// Allow / Remove mirror Rule's Allow / Deny: an allow-list keeps only
|
||||
// what it names, a remove-list drops what it names, and Remove wins
|
||||
// over Allow. Composition order is fixed: Base (or the CLI default) ->
|
||||
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
|
||||
//
|
||||
// Skills are addressed by exact name (a directory carrying SKILL.md,
|
||||
// e.g. "lark-doc"), not by command path and not by glob — the skill
|
||||
// list is flat, so misspellings abort startup instead of silently
|
||||
// matching nothing. Removing a skill only drops its guidance; it does
|
||||
// not disable any command (use Restrict for that).
|
||||
type SkillsOverlay struct {
|
||||
// Allow, when non-empty, keeps only these skills (by name) from the
|
||||
// base tree — the allow-list counterpart of Rule.Allow. Skills the
|
||||
// CLI adds in future versions stay out of the build until listed
|
||||
// here, which a Remove-only spec cannot guarantee. A name not
|
||||
// present in the base aborts startup. Overlay entries are exempt:
|
||||
// content the integrator explicitly ships needs no allow-listing.
|
||||
Allow []string
|
||||
|
||||
// Remove hides these skills, by name (e.g. "lark-shared"), from the
|
||||
// base tree; it wins over Allow, mirroring Rule's Deny-over-Allow. A
|
||||
// name not present in the base aborts startup rather than being
|
||||
// silently ignored.
|
||||
Remove []string
|
||||
|
||||
// Overlay contributes skills laid over the base: a same-named skill
|
||||
// replaces the base's entirely, a new name adds one. It is rooted at
|
||||
// the skill list (entries like "my-skill/SKILL.md"); each top-level
|
||||
// entry must be a "<name>/" directory containing SKILL.md. Any fs.FS
|
||||
// works (embed.FS, os.DirFS, fstest.MapFS); embed.FS is not required.
|
||||
Overlay fs.FS
|
||||
|
||||
// Base replaces the entire base skill tree instead of layering over
|
||||
// the CLI default. nil keeps the CLI default. Rare: most integrators
|
||||
// leave it nil and use Remove/Overlay so unchanged skills follow the
|
||||
// CLI version with no copy to maintain.
|
||||
Base fs.FS
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -83,10 +83,9 @@ func commandFormResolver(service string) func(string) string {
|
||||
}
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
if id, ok := byForm[strings.TrimSpace(h)]; ok {
|
||||
return id
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
return headingToKey(h) // one home for the shortcut/method key convention
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||
@@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||
}
|
||||
}
|
||||
|
||||
// The ### Skills section merges with the domain `> skill:` default: domain
|
||||
// first, then per-command entries, de-duplicated. A command with no ### Skills
|
||||
// still inherits the domain default.
|
||||
func TestParseDomainMD_SkillsMerge(t *testing.T) {
|
||||
md := "# d\n> skill: lark-d\n\n" +
|
||||
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
|
||||
"## bar\ndoes bar.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
|
||||
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
|
||||
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
|
||||
}
|
||||
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
|
||||
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
|
||||
// matches the shortcut command as mounted.
|
||||
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
|
||||
md := "# d\n\n## +create\ncreate via shortcut.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
if _, ok := got["+create"]; !ok {
|
||||
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]meta.Affordance) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||
// ### Tips -> tips
|
||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||
// ### Skills -> skills: bullet skill names, added to the domain default
|
||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||
//
|
||||
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
|
||||
"Prerequisites": "prerequisites",
|
||||
"Tips": "tips",
|
||||
"Examples": "examples",
|
||||
"Skills": "skills",
|
||||
}
|
||||
|
||||
// mergeSkills returns the domain-default skill followed by a command's own skill
|
||||
// entries, de-duplicated in author order and empties dropped. Backticks (left by
|
||||
// the shared bullet parse) are stripped so each entry is a bare skill name.
|
||||
func mergeSkills(domain string, extra []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
add := func(s string) {
|
||||
s = strings.Trim(strings.TrimSpace(s), "`")
|
||||
if s == "" || seen[s] {
|
||||
return
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
add(domain)
|
||||
for _, s := range extra {
|
||||
add(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||
|
||||
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
|
||||
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
|
||||
// while an entry containing a slash is a name/relative-path reference (e.g.
|
||||
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
|
||||
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
|
||||
// skills read already accepts — so a per-command entry can point at that
|
||||
// command's own reference file, not just re-point the domain skill.
|
||||
func SkillStatPath(entry string) string {
|
||||
if strings.Contains(entry, "/") {
|
||||
return entry
|
||||
}
|
||||
return entry + "/SKILL.md"
|
||||
}
|
||||
|
||||
// headingToKey maps a command heading ("instances get") to its affordance key
|
||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||
func headingToKey(h string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||
h = strings.TrimSpace(h)
|
||||
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
|
||||
return h
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
if len(useWhen) > 0 {
|
||||
a.UseWhen = useWhen
|
||||
}
|
||||
var perCmdSkills []string
|
||||
for _, s := range secs {
|
||||
switch standardSection[s.label] {
|
||||
case "avoid_when":
|
||||
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
a.Tips = s.items
|
||||
case "examples":
|
||||
a.Examples = s.cases
|
||||
case "skills":
|
||||
perCmdSkills = s.items
|
||||
default:
|
||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||
}
|
||||
}
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
|
||||
a.Skills = s
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
inFence, fence = true, nil
|
||||
} else {
|
||||
inFence = false
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -41,11 +42,15 @@ func (e *NeedAuthorizationError) Error() string {
|
||||
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
|
||||
// errors.As / errors.Is traversal.
|
||||
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
|
||||
WithUserOpenID(userOpenID).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
|
||||
// No recovery hint when the auth domain is absent from this build.
|
||||
if !policystate.DomainDeniedByPlugin("auth") {
|
||||
e = e.WithHint("run: lark-cli auth login to re-authorize")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
|
||||
|
||||
28
internal/auth/hint_gate_test.go
Normal file
28
internal/auth/hint_gate_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
// The auth-login recovery hint points into the auth domain; when an
|
||||
// integrator plugin denied that whole domain the hint would be a dead
|
||||
// end, so it stays off the error.
|
||||
func TestNeedUserAuthorization_hintFollowsAuthDomain(t *testing.T) {
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
if e := NewNeedUserAuthorizationError("ou_x"); !strings.Contains(e.Hint, "auth login") {
|
||||
t.Errorf("default build must keep the auth login hint, got %q", e.Hint)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
|
||||
if e := NewNeedUserAuthorizationError("ou_x"); e.Hint != "" {
|
||||
t.Errorf("auth-denied build must not steer to auth login, got %q", e.Hint)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -71,10 +72,14 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
|
||||
// for the defensive empty-token branch) and is preserved for errors.Is /
|
||||
// errors.Unwrap traversal without being serialized on the wire.
|
||||
func newTokenMissingError(as core.Identity, cause error) error {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no access token available for %s", as).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(cause)
|
||||
// No recovery hint when the auth domain is absent from this build.
|
||||
if !policystate.DomainDeniedByPlugin("auth") {
|
||||
e = e.WithHint("run: lark-cli auth login to re-authorize")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||
|
||||
31
internal/client/hint_gate_test.go
Normal file
31
internal/client/hint_gate_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/policystate"
|
||||
)
|
||||
|
||||
// Same gate as internal/auth: the token-missing recovery hint points into
|
||||
// the auth domain and stays off the error when a plugin denied it.
|
||||
func TestTokenMissing_hintFollowsAuthDomain(t *testing.T) {
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
var ae *errs.AuthenticationError
|
||||
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || !strings.Contains(ae.Hint, "auth login") {
|
||||
t.Errorf("default build must keep the auth login hint, got %v", err)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
|
||||
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || ae.Hint != "" {
|
||||
t.Errorf("auth-denied build must not steer to auth login, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package cmdmeta is the single source of truth for command metadata that the
|
||||
// policy engine and the hook selector both consume. It wraps the existing
|
||||
// cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need.
|
||||
// policy engine, the hook selector, and help rendering consume. It wraps the
|
||||
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need, plus the
|
||||
// affordance ref (service, method id) that lets service-method and shortcut
|
||||
// help share one usage-guidance lookup path.
|
||||
//
|
||||
// Three axes:
|
||||
//
|
||||
@@ -51,6 +53,12 @@ const (
|
||||
|
||||
sourceAnnotationKey = "cmdmeta.source"
|
||||
generatedAnnotationKey = "cmdmeta.generated"
|
||||
|
||||
// affordance{Service,Method}Key locate the command's usage-guidance overlay
|
||||
// entry (see internal/affordance). Both service-method commands and
|
||||
// +-prefixed shortcuts set these so help rendering shares one lookup path.
|
||||
affordanceServiceKey = "cmdmeta.affordance.service"
|
||||
affordanceMethodKey = "cmdmeta.affordance.method"
|
||||
)
|
||||
|
||||
// Meta groups the three command-level metadata axes consumed by the policy
|
||||
@@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAffordanceRef records which affordance overlay entry (service, method id)
|
||||
// a command maps to, so help rendering can look up its usage guidance. Stored
|
||||
// on the command itself (no inheritance): each method / shortcut owns its ref.
|
||||
// A no-op if either coordinate is empty.
|
||||
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
|
||||
if service == "" || method == "" {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[affordanceServiceKey] = service
|
||||
cmd.Annotations[affordanceMethodKey] = method
|
||||
}
|
||||
|
||||
// AffordanceRef returns the command's own affordance overlay coordinates.
|
||||
// ok is false when the command carries no ref.
|
||||
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return "", "", false
|
||||
}
|
||||
service = cmd.Annotations[affordanceServiceKey]
|
||||
method = cmd.Annotations[affordanceMethodKey]
|
||||
if service == "" || method == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return service, method, true
|
||||
}
|
||||
|
||||
// Domain returns the nearest-ancestor domain for the command. Empty string
|
||||
// when no ancestor has the annotation -- this is the "unknown" state the
|
||||
// policy engine must treat as ALLOW.
|
||||
|
||||
@@ -23,6 +23,9 @@ type ActivePolicy struct {
|
||||
Rules []*platform.Rule
|
||||
Source ResolveSource
|
||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||
|
||||
// DeniedByPath is the full post-aggregation denial map.
|
||||
DeniedByPath map[string]Denial
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -81,6 +84,12 @@ func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||
cp.Rules[i] = &rule
|
||||
}
|
||||
}
|
||||
if in.DeniedByPath != nil {
|
||||
cp.DeniedByPath = make(map[string]Denial, len(in.DeniedByPath))
|
||||
for k, v := range in.DeniedByPath {
|
||||
cp.DeniedByPath[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
|
||||
}
|
||||
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent", "")
|
||||
|
||||
// Both leaves denied.
|
||||
if _, ok := denied["im/+send"]; !ok {
|
||||
@@ -110,7 +110,7 @@ func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
|
||||
Deny: []string{"docs/+delete"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy", "")
|
||||
|
||||
if _, ok := denied["docs"]; ok {
|
||||
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
|
||||
@@ -129,7 +129,7 @@ func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "", "")
|
||||
|
||||
// Every leaf should be denied. We do not assert on the root entry
|
||||
// because Apply skips the root regardless; the contract is "root
|
||||
@@ -156,7 +156,7 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
||||
Allow: []string{"docs"},
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "", "")
|
||||
|
||||
// docs/+delete denied (path doesn't match Allow=["docs"]).
|
||||
if _, ok := denied["docs/+delete"]; !ok {
|
||||
@@ -170,13 +170,13 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
||||
|
||||
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
|
||||
// consumers rely on:
|
||||
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
|
||||
// subtype + exit code 2)
|
||||
// 1. cmd/root.go's envelope writer (errs.ProblemOf; plugin-source
|
||||
// denials use subtype command_unavailable + exit code 2)
|
||||
// 2. in-process consumers extracting the platform.CommandDeniedError as
|
||||
// the typed error's Cause via errors.As
|
||||
//
|
||||
// The policy metadata (layer / policy_source / rule_name / reason_code)
|
||||
// is folded into the Hint text rather than a separate detail map.
|
||||
// Plugin-source denials keep the policy metadata OFF the wire (no hint,
|
||||
// no source / rule vocabulary); it stays reachable on the Cause only.
|
||||
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
root := buildTree()
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
@@ -196,29 +196,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
t.Fatalf("denied command should return error")
|
||||
}
|
||||
|
||||
// Path 1: typed-envelope view. The denial is a failed_precondition
|
||||
// ValidationError so cmd/root.go renders the structured envelope and
|
||||
// the process exits 2 (ExitValidation).
|
||||
// Path 1: typed-envelope view. A plugin-source denial presents as
|
||||
// "command unavailable": the capability is absent from this build, so
|
||||
// the envelope carries no policy metadata and no recovery hint.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
||||
}
|
||||
// The policy metadata is folded into the Hint text: reason_code,
|
||||
// policy_source, and rule_name must all be discoverable there.
|
||||
if !strings.Contains(ve.Hint, "write_not_allowed") {
|
||||
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
|
||||
if ve.Message != cmdpolicy.DefaultUnavailableMessage {
|
||||
t.Errorf("message = %q, want default unavailable message", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
||||
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
|
||||
// No hint, no policy vocabulary: the wire must not steer the caller
|
||||
// toward a policy the integrator locked into the build.
|
||||
if ve.Hint != "" {
|
||||
t.Errorf("plugin-source denial must carry no hint, got %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "secaudit-policy") {
|
||||
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
|
||||
for _, leak := range []string{"policy", "plugin:secaudit", "secaudit-policy", "write_not_allowed"} {
|
||||
if strings.Contains(ve.Message, leak) {
|
||||
t.Errorf("message leaks %q: %q", leak, ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
|
||||
@@ -301,7 +303,7 @@ func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
decisions := e.EvaluateAll(root)
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
|
||||
|
||||
if _, ok := denied["docs"]; !ok {
|
||||
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
|
||||
@@ -332,7 +334,7 @@ func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
|
||||
|
||||
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
|
||||
decisions := e.EvaluateAll(root)
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
|
||||
|
||||
if _, ok := denied["drive"]; !ok {
|
||||
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -73,6 +75,10 @@ const (
|
||||
AnnotationDenialLayer = "lark:policy_denied_layer"
|
||||
AnnotationDenialSource = "lark:policy_denied_source"
|
||||
|
||||
// AnnotationDenialMessage carries the resolved unavailable message
|
||||
// for the cmd layer's help interceptor (plugin-source denials only).
|
||||
AnnotationDenialMessage = "lark:policy_denied_message"
|
||||
|
||||
// AnnotationPureGroup marks a cobra.Command that is logically a
|
||||
// parent-only group but had a RunE attached by the bootstrap-time
|
||||
// unknown-subcommand guard. The engine treats annotated commands
|
||||
@@ -124,6 +130,37 @@ func BuildDenialError(path string, d Denial) *errs.ValidationError {
|
||||
WithCause(cd)
|
||||
}
|
||||
|
||||
// IsPluginPolicySource reports whether a policy source names a plugin.
|
||||
// Plugin sources select the "command unavailable" presentation.
|
||||
func IsPluginPolicySource(source string) bool {
|
||||
return strings.HasPrefix(source, "plugin:")
|
||||
}
|
||||
|
||||
// DefaultUnavailableMessage is the message shown when a plugin-restricted
|
||||
// command is invoked and the integrator supplied no Rule.DeniedMessage.
|
||||
const DefaultUnavailableMessage = "command not included in this build"
|
||||
|
||||
// messageOf resolves the effective unavailable message for a denial.
|
||||
func messageOf(d Denial) string {
|
||||
if d.DeniedMessage != "" {
|
||||
return d.DeniedMessage
|
||||
}
|
||||
return DefaultUnavailableMessage
|
||||
}
|
||||
|
||||
// BuildUnavailableError is the plugin-source counterpart of
|
||||
// BuildDenialError: no hint, no policy vocabulary on the wire. The
|
||||
// *platform.CommandDeniedError stays reachable as the Cause for
|
||||
// in-process consumers; Cause is never serialized.
|
||||
func BuildUnavailableError(path string, d Denial) *errs.ValidationError {
|
||||
msg := d.DeniedMessage
|
||||
if msg == "" {
|
||||
msg = DefaultUnavailableMessage
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg).
|
||||
WithCause(CommandDeniedFromDenial(path, d))
|
||||
}
|
||||
|
||||
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
||||
// which does RemoveCommand+AddCommand (changing the pointer), we modify
|
||||
// the existing node so any external reference (snapshots, alias targets)
|
||||
@@ -194,11 +231,19 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
|
||||
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
|
||||
|
||||
denial := d // capture by value for the closure
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
// The typed message carries the user-facing semantic ("a command
|
||||
// was denied"); the hint carries the layer / source / rule
|
||||
// distinction ("policy" vs "strict_mode") for debugging.
|
||||
return BuildDenialError(path, denial)
|
||||
if IsPluginPolicySource(d.PolicySource) {
|
||||
// The message annotation feeds the cmd layer's help interceptor.
|
||||
cmd.Annotations[AnnotationDenialMessage] = messageOf(d)
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
return BuildUnavailableError(path, denial)
|
||||
}
|
||||
} else {
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
// The typed message carries the user-facing semantic ("a command
|
||||
// was denied"); the hint carries the layer / source / rule
|
||||
// distinction ("policy" vs "strict_mode") for debugging.
|
||||
return BuildDenialError(path, denial)
|
||||
}
|
||||
}
|
||||
// Clear any pre-existing Run hook: cobra prefers RunE when both are
|
||||
// set, but leaving a stale Run around is a foot-gun for future
|
||||
|
||||
@@ -25,6 +25,10 @@ type Denial struct {
|
||||
RuleName string // matched Rule.Name (if any)
|
||||
ReasonCode string // closed enum, see docs/extension/reason-codes.md
|
||||
Reason string // human-readable
|
||||
|
||||
// DeniedMessage is Rule.DeniedMessage for the plugin-source
|
||||
// unavailable presentation; empty means the default message.
|
||||
DeniedMessage string
|
||||
}
|
||||
|
||||
// ChildDenial is what AggregateChildren consumes — it pairs a Denial
|
||||
|
||||
@@ -27,3 +27,15 @@ var diagnosticPaths = map[string]bool{
|
||||
func IsDiagnosticPath(path string) bool {
|
||||
return diagnosticPaths[path]
|
||||
}
|
||||
|
||||
// DiagnosticPaths returns the exempt self-inspection command paths, for
|
||||
// the presentation layer: an integrator's HideDiagnostics retires exactly
|
||||
// this set, and the help-concealment pass hides it from listings when the
|
||||
// surrounding domain is plugin-denied.
|
||||
func DiagnosticPaths() []string {
|
||||
out := make([]string, 0, len(diagnosticPaths))
|
||||
for p := range diagnosticPaths {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -269,8 +269,9 @@ func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
|
||||
// so `--help` and similar remain available.
|
||||
//
|
||||
// source / ruleName populate PolicySource and RuleName on the produced
|
||||
// Denial values, so envelope output can attribute denials.
|
||||
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
|
||||
// Denial values, so envelope output can attribute denials. deniedMessage
|
||||
// is build-level and applies uniformly, aggregates included.
|
||||
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string, deniedMessage string) map[string]Denial {
|
||||
out := map[string]Denial{}
|
||||
|
||||
sourceLabel := policySourceLabel(source)
|
||||
@@ -287,6 +288,13 @@ func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, sourc
|
||||
}
|
||||
|
||||
aggregateParents(root, out)
|
||||
|
||||
if deniedMessage != "" {
|
||||
for path, d := range out {
|
||||
d.DeniedMessage = deniedMessage
|
||||
out[path] = d
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
|
||||
cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
|
||||
}, "my-readonly-rule")
|
||||
}, "my-readonly-rule", "")
|
||||
|
||||
cmdpolicy.Apply(root, denied)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
@@ -77,7 +77,7 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
||||
})
|
||||
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
|
||||
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
|
||||
"secaudit-policy")
|
||||
"secaudit-policy", "")
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
@@ -85,10 +85,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
// The plugin name IS surfaced (in-binary, part of the contract): it
|
||||
// must appear in the Hint so an integrator debugging a denial knows
|
||||
// which plugin fired.
|
||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
||||
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
|
||||
// A plugin-source denial presents as absent: no hint, no plugin name
|
||||
// on the wire. Plugin attribution moves to the in-process Cause
|
||||
// (*platform.CommandDeniedError) for integrators debugging a denial.
|
||||
if ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
|
||||
}
|
||||
if ve.Hint != "" || strings.Contains(ve.Message, "plugin:secaudit") {
|
||||
t.Errorf("wire must not expose the plugin source; hint=%q message=%q", ve.Hint, ve.Message)
|
||||
}
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) || cd.PolicySource != "plugin:secaudit" {
|
||||
t.Errorf("in-process Cause must carry plugin:secaudit, got %+v", cd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// Add top-level JSON keys as text form fields.
|
||||
|
||||
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
|
||||
@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
|
||||
@@ -8,8 +8,11 @@ import "encoding/json"
|
||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||
// model the envelope renderer and the command help both parse, so the
|
||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
|
||||
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
|
||||
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
|
||||
// each against the embedded skill tree (a name → its SKILL.md, a reference →
|
||||
// that path) and drops any that do not resolve.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
|
||||
@@ -53,4 +53,12 @@ const (
|
||||
ReasonInstallPanic = "install_panic"
|
||||
ReasonDuplicatePluginName = "duplicate_plugin_name"
|
||||
ReasonMultipleRestricts = "multiple_restrict_plugins"
|
||||
// ReasonInvalidSkillsOverlay flags a plugin's SkillsOverlay that cannot
|
||||
// compose -- EmbeddedSkills() called twice, a Remove naming a skill absent
|
||||
// from the base, or an Overlay entry missing SKILL.md.
|
||||
ReasonInvalidSkillsOverlay = "invalid_skills_overlay"
|
||||
// ReasonMultipleSkillsOverlays flags two or more plugins each contributing
|
||||
// a SkillsOverlay; only one may own skill content (mirrors
|
||||
// ReasonMultipleRestricts).
|
||||
ReasonMultipleSkillsOverlays = "multiple_skills_overlay_plugins"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/skillpolicy"
|
||||
)
|
||||
|
||||
// PluginInfo is the metadata of a successfully-installed plugin,
|
||||
@@ -29,9 +30,10 @@ type PluginInfo struct {
|
||||
// every plugin that committed successfully (FailOpen-skipped plugins
|
||||
// are absent), for downstream diagnostics.
|
||||
type InstallResult struct {
|
||||
Registry *hook.Registry
|
||||
PluginRules []cmdpolicy.PluginRule
|
||||
Plugins []PluginInfo
|
||||
Registry *hook.Registry
|
||||
PluginRules []cmdpolicy.PluginRule
|
||||
PluginSkills []skillpolicy.PluginSkill
|
||||
Plugins []PluginInfo
|
||||
}
|
||||
|
||||
// InstallAll runs every registered plugin through the staging
|
||||
@@ -142,6 +144,16 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
||||
}
|
||||
}
|
||||
|
||||
// The Builder rejects this at Build time; hand-written plugins are
|
||||
// caught here (authoring error, aborts unconditionally).
|
||||
if caps.HideDiagnostics && !caps.Restricts {
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInvalidCapability,
|
||||
Reason: "HideDiagnostics=true requires Restricts=true",
|
||||
}
|
||||
}
|
||||
|
||||
// Version compatibility check. Two distinct failure modes:
|
||||
//
|
||||
// 1. Parse error (constraint is malformed, e.g. ">=abc")
|
||||
@@ -207,6 +219,12 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
||||
Rule: rule,
|
||||
})
|
||||
}
|
||||
if staging.skillsOverlay != nil {
|
||||
result.PluginSkills = append(result.PluginSkills, skillpolicy.PluginSkill{
|
||||
PluginName: name,
|
||||
SkillsOverlay: staging.skillsOverlay,
|
||||
})
|
||||
}
|
||||
|
||||
// Record the plugin in the inventory. Version is fetched here under
|
||||
// a recover-wrapped helper so a plugin's Version() panic does not
|
||||
|
||||
@@ -431,3 +431,79 @@ func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
|
||||
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// skillPlugin contributes a SkillsOverlay via r.EmbeddedSkills; the install pipeline
|
||||
// must capture it into PluginSkills for the skill resolver.
|
||||
type skillPlugin struct{}
|
||||
|
||||
func (skillPlugin) Name() string { return "skiller" }
|
||||
func (skillPlugin) Version() string { return "1.0.0" }
|
||||
func (skillPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (skillPlugin) Install(r platform.Registrar) error {
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_skillsCommitted(t *testing.T) {
|
||||
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
if len(result.PluginSkills) != 1 {
|
||||
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
|
||||
}
|
||||
ps := result.PluginSkills[0]
|
||||
if ps.PluginName != "skiller" {
|
||||
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
|
||||
}
|
||||
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
|
||||
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
|
||||
// FailClosed so the staging error aborts InstallAll deterministically.
|
||||
type doubleSkillPlugin struct{}
|
||||
|
||||
func (doubleSkillPlugin) Name() string { return "double-skiller" }
|
||||
func (doubleSkillPlugin) Version() string { return "1.0.0" }
|
||||
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (doubleSkillPlugin) Install(r platform.Registrar) error {
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
|
||||
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
// hideDiagnosticsOnlyPlugin declares HideDiagnostics without Restricts —
|
||||
// an authoring error the host rejects unconditionally.
|
||||
type hideDiagnosticsOnlyPlugin struct{}
|
||||
|
||||
func (hideDiagnosticsOnlyPlugin) Name() string { return "hider" }
|
||||
func (hideDiagnosticsOnlyPlugin) Version() string { return "1.0.0" }
|
||||
func (hideDiagnosticsOnlyPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{HideDiagnostics: true, FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (hideDiagnosticsOnlyPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
func TestInstallAll_hideDiagnosticsRequiresRestricts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{hideDiagnosticsOnlyPlugin{}}, nil)
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
|
||||
t.Fatalf("HideDiagnostics without Restricts must abort with invalid_capability, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ type PluginEntry struct {
|
||||
// plugin did not call r.Restrict.
|
||||
Rules []*RuleView
|
||||
|
||||
// EmbeddedSkills summarises the plugin's EmbeddedSkills contribution;
|
||||
// nil when the plugin did not customize embedded skills.
|
||||
EmbeddedSkills *SkillsOverlayView `json:"embedded_skills,omitempty"`
|
||||
|
||||
Observers []HookEntry
|
||||
Wrappers []HookEntry
|
||||
Lifecycles []HookEntry
|
||||
@@ -41,6 +45,7 @@ type CapabilitiesView struct {
|
||||
Restricts bool `json:"restricts"`
|
||||
FailurePolicy string `json:"failure_policy"`
|
||||
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
|
||||
HideDiagnostics bool `json:"hide_diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
// NewCapabilitiesView converts a platform.Capabilities value into the
|
||||
@@ -50,6 +55,7 @@ func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
|
||||
Restricts: c.Restricts,
|
||||
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
|
||||
RequiredCLIVersion: c.RequiredCLIVersion,
|
||||
HideDiagnostics: c.HideDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +80,22 @@ type RuleView struct {
|
||||
AllowUnannotated bool `json:"allow_unannotated"`
|
||||
}
|
||||
|
||||
// SkillsOverlayView is the displayable summary of an EmbeddedSkills
|
||||
// contribution. Overlay / Base report presence only (an fs.FS has no
|
||||
// display form).
|
||||
type SkillsOverlayView struct {
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
Remove []string `json:"remove,omitempty"`
|
||||
Overlay bool `json:"overlay"`
|
||||
Base bool `json:"base"`
|
||||
}
|
||||
|
||||
// SkillsInventorySource pairs a plugin name with its overlay summary.
|
||||
type SkillsInventorySource struct {
|
||||
PluginName string
|
||||
View SkillsOverlayView
|
||||
}
|
||||
|
||||
// Inventory is the full snapshot.
|
||||
type Inventory struct {
|
||||
Plugins []PluginEntry
|
||||
@@ -108,7 +130,7 @@ type RuleInventorySource struct {
|
||||
// Hooks are attributed to plugins by the namespaced name convention:
|
||||
// each entry's Name starts with "<plugin>.", and we group by the
|
||||
// leading segment up to the first dot.
|
||||
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
|
||||
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource, skills []SkillsInventorySource) *Inventory {
|
||||
byPlugin := make(map[string]*PluginEntry, len(plugins))
|
||||
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
|
||||
for _, p := range plugins {
|
||||
@@ -162,6 +184,15 @@ func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, ru
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, sk := range skills {
|
||||
if entry := byPlugin[sk.PluginName]; entry != nil {
|
||||
v := sk.View
|
||||
v.Allow = append([]string(nil), sk.View.Allow...)
|
||||
v.Remove = append([]string(nil), sk.View.Remove...)
|
||||
entry.EmbeddedSkills = &v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -263,6 +294,12 @@ func cloneInventory(in *Inventory) *Inventory {
|
||||
entry.Rules[j] = &rv
|
||||
}
|
||||
}
|
||||
if p.EmbeddedSkills != nil {
|
||||
sv := *p.EmbeddedSkills
|
||||
sv.Allow = append([]string(nil), p.EmbeddedSkills.Allow...)
|
||||
sv.Remove = append([]string(nil), p.EmbeddedSkills.Remove...)
|
||||
entry.EmbeddedSkills = &sv
|
||||
}
|
||||
entry.Observers = append([]HookEntry(nil), p.Observers...)
|
||||
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
|
||||
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) {
|
||||
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, r, rules)
|
||||
inv := internalplatform.BuildInventory(plugins, r, rules, nil)
|
||||
|
||||
if got := len(inv.Plugins); got != 2 {
|
||||
t.Fatalf("Plugins len = %d, want 2", got)
|
||||
@@ -89,7 +89,7 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
||||
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, nil, rules)
|
||||
inv := internalplatform.BuildInventory(plugins, nil, rules, nil)
|
||||
a := findPlugin(inv, "a")
|
||||
if a == nil {
|
||||
t.Fatalf("missing entry a")
|
||||
@@ -103,12 +103,45 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInventory_empty(t *testing.T) {
|
||||
inv := internalplatform.BuildInventory(nil, nil, nil)
|
||||
inv := internalplatform.BuildInventory(nil, nil, nil, nil)
|
||||
if got := len(inv.Plugins); got != 0 {
|
||||
t.Errorf("Plugins len = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-nil SkillsInventorySource must surface on the owning plugin's entry as
|
||||
// an EmbeddedSkills summary, and BuildInventory must clone the Allow/Remove
|
||||
// slices so a later mutation of the source cannot corrupt the recorded view.
|
||||
func TestBuildInventory_populatesAndClonesEmbeddedSkills(t *testing.T) {
|
||||
plugins := []internalplatform.PluginInventorySource{{Name: "acme", Version: "1.0"}}
|
||||
allow := []string{"lark-im"}
|
||||
remove := []string{"lark-shared"}
|
||||
skills := []internalplatform.SkillsInventorySource{
|
||||
{PluginName: "acme", View: internalplatform.SkillsOverlayView{
|
||||
Allow: allow, Remove: remove, Overlay: true, Base: false,
|
||||
}},
|
||||
}
|
||||
|
||||
inv := internalplatform.BuildInventory(plugins, nil, nil, skills)
|
||||
entry := findPlugin(inv, "acme")
|
||||
if entry == nil || entry.EmbeddedSkills == nil {
|
||||
t.Fatalf("acme entry missing EmbeddedSkills: %+v", entry)
|
||||
}
|
||||
es := entry.EmbeddedSkills
|
||||
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
|
||||
len(es.Remove) != 1 || es.Remove[0] != "lark-shared" ||
|
||||
!es.Overlay || es.Base {
|
||||
t.Errorf("EmbeddedSkills summary mismatch: %+v", es)
|
||||
}
|
||||
|
||||
// Mutating the source slices must not leak into the recorded view.
|
||||
allow[0] = "MUTATED"
|
||||
remove[0] = "MUTATED"
|
||||
if es.Allow[0] != "lark-im" || es.Remove[0] != "lark-shared" {
|
||||
t.Errorf("EmbeddedSkills slices not cloned; source mutation leaked: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
|
||||
for i := range inv.Plugins {
|
||||
if inv.Plugins[i].Name == name {
|
||||
|
||||
@@ -41,6 +41,13 @@ type stagingRegistrar struct {
|
||||
// can detect the call.
|
||||
actuallyRestricted bool
|
||||
|
||||
// skillsOverlay holds the staged Skills contribution, captured for the
|
||||
// host to feed into the skill resolver later. nil means the plugin
|
||||
// did not call r.EmbeddedSkills. overlaySet records that the call happened so a
|
||||
// second call in the same plugin can be rejected.
|
||||
skillsOverlay *platform.SkillsOverlay
|
||||
overlaySet bool
|
||||
|
||||
// seenHookNames detects duplicate hookName within this plugin's
|
||||
// Install call.
|
||||
seenHookNames map[string]bool
|
||||
@@ -144,6 +151,25 @@ func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
|
||||
r.rules = append(r.rules, &cp)
|
||||
}
|
||||
|
||||
func (r *stagingRegistrar) EmbeddedSkills(spec *platform.SkillsOverlay) {
|
||||
if r.overlaySet {
|
||||
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills() called more than once in the same plugin")
|
||||
return
|
||||
}
|
||||
r.overlaySet = true
|
||||
if spec == nil {
|
||||
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills(nil)")
|
||||
return
|
||||
}
|
||||
// Defensive clone: freeze Remove so a plugin cannot mutate it after
|
||||
// Install returns. Overlay/Base are read-only fs.FS views retained by
|
||||
// reference.
|
||||
cp := *spec
|
||||
cp.Allow = append([]string(nil), spec.Allow...)
|
||||
cp.Remove = append([]string(nil), spec.Remove...)
|
||||
r.skillsOverlay = &cp
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func (r *stagingRegistrar) namespaced(name string) string {
|
||||
|
||||
47
internal/policystate/policystate.go
Normal file
47
internal/policystate/policystate.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package policystate answers "did an integrator plugin deny this whole
|
||||
// command domain?" for render-time hint emitters. It is dependency-free
|
||||
// because internal/auth and internal/client sit below internal/cmdpolicy
|
||||
// in the import graph and cannot ask it directly. Written once by the
|
||||
// bootstrap after policy pruning; read-only thereafter.
|
||||
package policystate
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
pluginDeniedDomains map[string]bool
|
||||
)
|
||||
|
||||
// SetPluginDeniedDomains records the plugin-denied top-level domains.
|
||||
// nil clears.
|
||||
func SetPluginDeniedDomains(domains map[string]bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if domains == nil {
|
||||
pluginDeniedDomains = nil
|
||||
return
|
||||
}
|
||||
cp := make(map[string]bool, len(domains))
|
||||
for d, v := range domains {
|
||||
cp[d] = v
|
||||
}
|
||||
pluginDeniedDomains = cp
|
||||
}
|
||||
|
||||
// DomainDeniedByPlugin reports whether the whole top-level domain was
|
||||
// denied by an integrator plugin. yaml denials never register here.
|
||||
func DomainDeniedByPlugin(domain string) bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return pluginDeniedDomains[domain]
|
||||
}
|
||||
|
||||
// ResetForTesting clears the recorded state.
|
||||
func ResetForTesting() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
pluginDeniedDomains = nil
|
||||
}
|
||||
209
internal/skillpolicy/overlay.go
Normal file
209
internal/skillpolicy/overlay.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// overlayFS layers an upper skill tree over a lower one at skill (top
|
||||
// path segment) granularity: a skill present in upper is served wholly
|
||||
// from upper, a skill named in removed is hidden, and everything else
|
||||
// falls through to lower. It is the runtime form of a SkillsOverlay's
|
||||
// Base -> Remove -> Overlay composition.
|
||||
//
|
||||
// It implements the io/fs fast-path interfaces (ReadDir/Stat/ReadFile)
|
||||
// so the io/fs helpers route through the merge instead of hitting a
|
||||
// single underlying tree.
|
||||
type overlayFS struct {
|
||||
lower fs.FS // base (or SkillsOverlay.Base); may be nil
|
||||
upper fs.FS // SkillsOverlay.Overlay; may be nil
|
||||
removed map[string]bool // skill names whited out from lower
|
||||
upperNames map[string]bool // skill names owned by upper
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.FS = (*overlayFS)(nil)
|
||||
_ fs.ReadDirFS = (*overlayFS)(nil)
|
||||
_ fs.StatFS = (*overlayFS)(nil)
|
||||
_ fs.ReadFileFS = (*overlayFS)(nil)
|
||||
)
|
||||
|
||||
func newOverlayFS(lower, upper fs.FS, remove []string) *overlayFS {
|
||||
o := &overlayFS{
|
||||
lower: lower,
|
||||
upper: upper,
|
||||
removed: make(map[string]bool, len(remove)),
|
||||
upperNames: map[string]bool{},
|
||||
}
|
||||
for _, r := range remove {
|
||||
o.removed[r] = true
|
||||
}
|
||||
if upper != nil {
|
||||
if entries, err := fs.ReadDir(upper, "."); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
o.upperNames[e.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// route picks the tree owning name by its top path segment. whiteout is
|
||||
// true when name belongs to a removed skill (present in neither tree).
|
||||
func (o *overlayFS) route(name string) (target fs.FS, whiteout bool) {
|
||||
top := name
|
||||
if i := strings.IndexByte(name, '/'); i >= 0 {
|
||||
top = name[:i]
|
||||
}
|
||||
switch {
|
||||
case o.upperNames[top]:
|
||||
return o.upper, false
|
||||
case o.removed[top]:
|
||||
return nil, true
|
||||
default:
|
||||
return o.lower, false
|
||||
}
|
||||
}
|
||||
|
||||
func (o *overlayFS) Open(name string) (fs.File, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name == "." {
|
||||
entries, err := o.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rootDir{entries: entries}, nil
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return target.Open(name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) Stat(name string) (fs.FileInfo, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name == "." {
|
||||
return rootInfo{}, nil
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.Stat(target, name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) ReadFile(name string) ([]byte, error) {
|
||||
if !fs.ValidPath(name) || name == "." {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.ReadFile(target, name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name != "." {
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.ReadDir(target, name)
|
||||
}
|
||||
return o.mergedRoot()
|
||||
}
|
||||
|
||||
// mergedRoot lists the top-level skills: every upper skill, plus every
|
||||
// lower skill that is neither removed nor shadowed by a same-named upper.
|
||||
// Sorted so listings are deterministic.
|
||||
func (o *overlayFS) mergedRoot() ([]fs.DirEntry, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []fs.DirEntry
|
||||
if o.upper != nil {
|
||||
entries, err := fs.ReadDir(o.upper, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
seen[e.Name()] = true
|
||||
}
|
||||
}
|
||||
if o.lower != nil {
|
||||
entries, err := fs.ReadDir(o.lower, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if o.removed[e.Name()] || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
seen[e.Name()] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rootInfo is the synthetic FileInfo for the overlay's "." directory,
|
||||
// which has no single backing entry in either tree.
|
||||
type rootInfo struct{}
|
||||
|
||||
func (rootInfo) Name() string { return "." }
|
||||
func (rootInfo) Size() int64 { return 0 }
|
||||
func (rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 }
|
||||
func (rootInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (rootInfo) IsDir() bool { return true }
|
||||
func (rootInfo) Sys() any { return nil }
|
||||
|
||||
// rootDir is the synthetic directory file returned by Open(".").
|
||||
type rootDir struct {
|
||||
entries []fs.DirEntry
|
||||
off int
|
||||
}
|
||||
|
||||
func (d *rootDir) Stat() (fs.FileInfo, error) { return rootInfo{}, nil }
|
||||
func (d *rootDir) Close() error { return nil }
|
||||
|
||||
func (d *rootDir) Read([]byte) (int, error) {
|
||||
return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid}
|
||||
}
|
||||
|
||||
func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if n <= 0 {
|
||||
rest := d.entries[d.off:]
|
||||
d.off = len(d.entries)
|
||||
return rest, nil
|
||||
}
|
||||
if d.off >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := d.off + n
|
||||
if end > len(d.entries) {
|
||||
end = len(d.entries)
|
||||
}
|
||||
part := d.entries[d.off:end]
|
||||
d.off = end
|
||||
return part, nil
|
||||
}
|
||||
156
internal/skillpolicy/resolver.go
Normal file
156
internal/skillpolicy/resolver.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillpolicy composes the CLI's effective embedded skill tree
|
||||
// from a base skill FS and at most one plugin-supplied SkillsOverlay. It is
|
||||
// the skill-side analogue of internal/cmdpolicy: plugins contribute a
|
||||
// delta over a base, and one resolver produces the single tree that both
|
||||
// skill readers consume -- `skills list`/`read` and the --help
|
||||
// domain-guide pointer.
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// PluginSkill pairs a plugin name with the SkillsOverlay it contributed, so a
|
||||
// conflict can be attributed to specific owners. Mirrors
|
||||
// cmdpolicy.PluginRule.
|
||||
type PluginSkill struct {
|
||||
PluginName string
|
||||
SkillsOverlay *platform.SkillsOverlay
|
||||
}
|
||||
|
||||
// ErrMultipleSkillsOverlays reports that more than one plugin tried to
|
||||
// customize skill content. Mirrors cmdpolicy.ErrMultipleRestricts: only
|
||||
// one owner is allowed so independent plugins cannot silently overwrite
|
||||
// each other's skill tree.
|
||||
var ErrMultipleSkillsOverlays = errors.New("multiple plugins customized skills; only one plugin may own skill content")
|
||||
|
||||
// Resolve composes the effective skill tree from base and the supplied
|
||||
// specs. base is the CLI's embedded skill FS (nil when the build embeds
|
||||
// none). With no spec it returns base unchanged. With exactly one spec it
|
||||
// applies, in fixed order, Base override -> Allow -> Remove -> Overlay,
|
||||
// returning an overlay FS in which a same-named skill resolves to
|
||||
// Overlay. Two or more distinct owners is a configuration error.
|
||||
func Resolve(base fs.FS, specs []PluginSkill) (fs.FS, error) {
|
||||
owners := distinctOwners(specs)
|
||||
if len(owners) > 1 {
|
||||
return nil, fmt.Errorf("%w: %v", ErrMultipleSkillsOverlays, owners)
|
||||
}
|
||||
if len(specs) == 0 || specs[0].SkillsOverlay == nil {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
owner, spec := specs[0].PluginName, specs[0].SkillsOverlay
|
||||
lower := base
|
||||
if spec.Base != nil {
|
||||
lower = spec.Base
|
||||
}
|
||||
if err := validate(lower, spec); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q skill spec: %w", owner, err)
|
||||
}
|
||||
if lower == nil && spec.Overlay == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return newOverlayFS(lower, spec.Overlay, effectiveRemove(lower, spec)), nil
|
||||
}
|
||||
|
||||
// effectiveRemove folds a non-empty Allow into the remove set: every
|
||||
// base skill not allow-listed is treated as removed, and explicit
|
||||
// Remove entries win regardless (Remove-over-Allow, mirroring Rule's
|
||||
// Deny-over-Allow). Overlay entries are never folded in — the overlay
|
||||
// FS resolves them from the upper layer before the whiteout applies.
|
||||
func effectiveRemove(lower fs.FS, spec *platform.SkillsOverlay) []string {
|
||||
if len(spec.Allow) == 0 {
|
||||
return spec.Remove
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, name := range spec.Allow {
|
||||
allowed[name] = true
|
||||
}
|
||||
removed := append([]string(nil), spec.Remove...)
|
||||
entries, err := fs.ReadDir(lower, ".")
|
||||
if err != nil {
|
||||
return removed // validate already vetted lower; nothing more to fold
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !allowed[e.Name()] {
|
||||
removed = append(removed, e.Name())
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// distinctOwners returns the unique contributing plugin names in
|
||||
// first-seen order. Mirrors cmdpolicy.distinctOwners.
|
||||
func distinctOwners(specs []PluginSkill) []string {
|
||||
seen := map[string]bool{}
|
||||
owners := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
if !seen[s.PluginName] {
|
||||
seen[s.PluginName] = true
|
||||
owners = append(owners, s.PluginName)
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
// validate rejects a spec that cannot compose cleanly, so the failure
|
||||
// surfaces at startup with a named cause rather than as a silently
|
||||
// missing skill at read time.
|
||||
func validate(lower fs.FS, spec *platform.SkillsOverlay) error {
|
||||
for _, name := range spec.Allow {
|
||||
if !isSkillName(name) {
|
||||
return fmt.Errorf("Allow: %q is not a valid skill name", name)
|
||||
}
|
||||
if !skillExists(lower, name) {
|
||||
return fmt.Errorf("Allow: skill %q is not in the base tree", name)
|
||||
}
|
||||
}
|
||||
for _, name := range spec.Remove {
|
||||
if !isSkillName(name) {
|
||||
return fmt.Errorf("Remove: %q is not a valid skill name", name)
|
||||
}
|
||||
if !skillExists(lower, name) {
|
||||
return fmt.Errorf("Remove: skill %q is not in the base tree", name)
|
||||
}
|
||||
}
|
||||
if spec.Overlay != nil {
|
||||
entries, err := fs.ReadDir(spec.Overlay, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Overlay: cannot read root: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
return fmt.Errorf("Overlay: %q is not a directory; every Overlay entry must be a <skill>/ dir", e.Name())
|
||||
}
|
||||
if !skillExists(spec.Overlay, e.Name()) {
|
||||
return fmt.Errorf("Overlay: skill %q is missing SKILL.md", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillExists reports whether fsys holds a skill named name -- a
|
||||
// directory carrying SKILL.md, the shape internal/skillcontent treats as
|
||||
// a skill.
|
||||
func skillExists(fsys fs.FS, name string) bool {
|
||||
if fsys == nil {
|
||||
return false
|
||||
}
|
||||
info, err := fs.Stat(fsys, name+"/SKILL.md")
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// isSkillName rejects empty, dotted, or path-bearing names so Remove
|
||||
// cannot smuggle a traversal or match outside the top level.
|
||||
func isSkillName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, `/\`)
|
||||
}
|
||||
272
internal/skillpolicy/resolver_test.go
Normal file
272
internal/skillpolicy/resolver_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// skillFS builds an in-memory skill tree; each map entry is a path -> content.
|
||||
func skillFS(files map[string]string) fstest.MapFS {
|
||||
m := fstest.MapFS{}
|
||||
for p, content := range files {
|
||||
m[p] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// baseTree is a representative base: three skills, one with a reference file.
|
||||
func baseTree() fstest.MapFS {
|
||||
return skillFS(map[string]string{
|
||||
"lark-a/SKILL.md": "base a",
|
||||
"lark-a/references/x.md": "base a ref",
|
||||
"lark-b/SKILL.md": "base b",
|
||||
"lark-shared/SKILL.md": "base shared",
|
||||
})
|
||||
}
|
||||
|
||||
// topLevel returns the sorted top-level skill names of fsys.
|
||||
func topLevel(t *testing.T, fsys fs.FS) []string {
|
||||
t.Helper()
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(.): %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, fsys fs.FS, name string) string {
|
||||
t.Helper()
|
||||
data, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q): %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustResolve(t *testing.T, base fs.FS, spec *platform.SkillsOverlay) fs.FS {
|
||||
t.Helper()
|
||||
got, err := Resolve(base, []PluginSkill{{PluginName: "acme", SkillsOverlay: spec}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: unexpected error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestResolve_NoSpecs_ReturnsBaseUnchanged(t *testing.T) {
|
||||
base := baseTree()
|
||||
got, err := Resolve(base, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if want := []string{"lark-a", "lark-b", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Remove_HidesSkill(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||
|
||||
if want := []string{"lark-a", "lark-b"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
// The removed skill is gone for the affordance reader too (Stat gates it).
|
||||
if _, err := fs.Stat(got, "lark-shared/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("Stat removed skill: err = %v, want ErrNotExist", err)
|
||||
}
|
||||
// Surviving skills still read from base.
|
||||
if c := readFile(t, got, "lark-a/SKILL.md"); c != "base a" {
|
||||
t.Errorf("lark-a content = %q, want %q", c, "base a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Overlay_AddsNewSkill(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-new/SKILL.md": "new skill"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||
|
||||
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if c := readFile(t, got, "lark-new/SKILL.md"); c != "new skill" {
|
||||
t.Errorf("lark-new content = %q, want %q", c, "new skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Overlay_ReplacesSameNameWholeSkill(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-a/SKILL.md": "overlaid a"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||
|
||||
// Same-named skill resolves to the overlay (upper wins).
|
||||
if c := readFile(t, got, "lark-a/SKILL.md"); c != "overlaid a" {
|
||||
t.Errorf("lark-a content = %q, want overlaid", c)
|
||||
}
|
||||
// Replacement is whole-skill: the base's reference file is shadowed, not merged.
|
||||
if _, err := fs.Stat(got, "lark-a/references/x.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("base reference should be shadowed by overlay skill; err = %v, want ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Base_ReplacesEntireTree(t *testing.T) {
|
||||
replacement := skillFS(map[string]string{"lark-only/SKILL.md": "only"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Base: replacement})
|
||||
|
||||
if want := []string{"lark-only"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if _, err := fs.Stat(got, "lark-a/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("base skill should be gone after Base replace; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Base_WithRemoveAndOverlay(t *testing.T) {
|
||||
replacement := skillFS(map[string]string{
|
||||
"lark-p/SKILL.md": "p",
|
||||
"lark-q/SKILL.md": "q",
|
||||
})
|
||||
overlay := skillFS(map[string]string{"lark-r/SKILL.md": "r"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Base: replacement,
|
||||
Remove: []string{"lark-q"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if want := []string{"lark-p", "lark-r"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow keeps only the listed base skills — the allow-list counterpart
|
||||
// of Rule.Allow, so a CLI upgrade adding new embedded skills cannot
|
||||
// widen an allow-listed build.
|
||||
func TestResolve_Allow_KeepsOnlyListed(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Allow: []string{"lark-a"}})
|
||||
|
||||
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if _, err := fs.Stat(got, "lark-b/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("non-allow-listed skill must be absent; err = %v", err)
|
||||
}
|
||||
// Kept skills still read from base, references included.
|
||||
if c := readFile(t, got, "lark-a/references/x.md"); c != "base a ref" {
|
||||
t.Errorf("kept skill content = %q, want base content", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove wins over Allow, mirroring Rule's Deny-over-Allow.
|
||||
func TestResolve_RemoveWinsOverAllow(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Allow: []string{"lark-a", "lark-b"},
|
||||
Remove: []string{"lark-b"},
|
||||
})
|
||||
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay entries are exempt from Allow: content the integrator
|
||||
// explicitly ships needs no allow-listing.
|
||||
func TestResolve_OverlayExemptFromAllow(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"acme-guide/SKILL.md": "mine"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Allow: []string{"lark-a"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if want := []string{"acme-guide", "lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// An Allow name absent from the base aborts startup, same as Remove.
|
||||
func TestResolve_AllowUnknown_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-nope"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error allow-listing a skill absent from base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RemoveUnknown_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-nope"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error removing a skill absent from base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RemoveInvalidName_Errors(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "a/b", `a\b`} {
|
||||
if _, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{bad}}}}); err == nil {
|
||||
t.Errorf("Remove %q: expected error, got nil", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverlayMissingSKILLmd_Errors(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-bad/other.md": "no skill.md here"})
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for overlay entry missing SKILL.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverlayNonDirEntry_Errors(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"loose.md": "top-level file, not a skill dir"})
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-directory overlay entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_TwoOwners_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{
|
||||
{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-a"}}},
|
||||
{PluginName: "globex", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-b"}}},
|
||||
})
|
||||
if !errors.Is(err, ErrMultipleSkillsOverlays) {
|
||||
t.Fatalf("err = %v, want ErrMultipleSkillsOverlays", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_ComposedConformsToFS runs the composed tree through the
|
||||
// standard io/fs conformance checker to catch Open/ReadDir/Stat drift.
|
||||
func TestResolve_ComposedConformsToFS(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{
|
||||
"lark-new/SKILL.md": "new",
|
||||
"lark-new/references/y.md": "new ref",
|
||||
})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Remove: []string{"lark-shared"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if err := fstest.TestFS(got,
|
||||
"lark-a/SKILL.md",
|
||||
"lark-a/references/x.md",
|
||||
"lark-b/SKILL.md",
|
||||
"lark-new/SKILL.md",
|
||||
"lark-new/references/y.md",
|
||||
); err != nil {
|
||||
t.Fatalf("composed FS failed fs conformance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.67",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
{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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
params := map[string]interface{}{"page_size": 100}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
|
||||
// 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)
|
||||
statusParams := map[string]interface{}{}
|
||||
if env != "" {
|
||||
statusParams["env"] = env
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
|
||||
// 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),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"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
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
{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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
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 {
|
||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
|
||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
|
||||
// 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),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
||||
{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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
||||
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")),
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||
"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"))
|
||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
exportQuery := larkcore.QueryParams{
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
exportQuery["env"] = []string{env}
|
||||
}
|
||||
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"))},
|
||||
},
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: exportQuery,
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// 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)
|
||||
params := map[string]interface{}{"page_size": 1}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
{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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
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)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
importQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
QueryParams: importQuery,
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
|
||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(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", "--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)
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
}
|
||||
if p["table"] != "orders" {
|
||||
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||||
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||||
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||||
// 交由下面真实 apply 统一报同样的业务错。
|
||||
pending := 0
|
||||
var previewFrom, previewTo string
|
||||
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||||
pending = len(projectMigrationChanges(preview["changes"]))
|
||||
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
if from == "" {
|
||||
from = previewFrom
|
||||
}
|
||||
if to == "" {
|
||||
to = previewTo
|
||||
}
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||
if applied == 0 && pending > 0 {
|
||||
applied = pending
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--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"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
return dbEnvParams(rctx, map[string]interface{}{
|
||||
"transactional": false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
|
||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
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)
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
|
||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]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},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
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, "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").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]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},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
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, "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").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
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})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
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})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
}
|
||||
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)
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
||||
// 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))
|
||||
raw := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
raw = append(raw, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||
// 两行 + tables_affected 翻倍。
|
||||
hasSchema := map[string]bool{}
|
||||
for _, c := range raw {
|
||||
if c.Action != "" {
|
||||
hasSchema[c.Table] = true
|
||||
}
|
||||
}
|
||||
changes := make([]recoveryChange, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
if c.Action == "" && hasSchema[c.Table] {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, c)
|
||||
}
|
||||
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||
seen := map[string]bool{}
|
||||
for _, c := range changes {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"target": target, "tables_affected": len(seen),
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -80,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": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{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)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
{"non-numeric string", "x", 0, false},
|
||||
{"numeric string", "13.5", 13.5, true},
|
||||
{"numeric string int", "2", 2, true},
|
||||
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||
{"empty string", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
||||
return rctx.Str("environment")
|
||||
}
|
||||
|
||||
// dbEnvParams 把 env 并入 params:仅当显式指定了环境(非空)才带 env 键;未指定(空)时
|
||||
// 省略该键,由服务端按应用多环境状态自动选分支(多环境→dev,单环境→online)。与家族对
|
||||
// 空可选参数的 omit-empty 约定一致——不发空串,wire 上真正不带 env。原样返回同一个 map 便于链式。
|
||||
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||
if rctx.Changed("env") {
|
||||
|
||||
73
shortcuts/base/record_json_shorthand_test.go
Normal file
73
shortcuts/base/record_json_shorthand_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, &cmdutil.Factory{})
|
||||
cmd, _, err := parent.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(%s) error = %v", name, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// record-list 获得 --json 简写
|
||||
func TestRecordListRegistersJSONShorthand(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("+record-list missing --json shorthand")
|
||||
}
|
||||
if fl.Usage != "shorthand for --format json" {
|
||||
t.Errorf("usage = %q, want shorthand", fl.Usage)
|
||||
}
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
|
||||
t.Errorf("format default = %q, want markdown (unchanged)", def)
|
||||
}
|
||||
}
|
||||
|
||||
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
|
||||
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
cmdName string
|
||||
}{
|
||||
{"record-search", BaseRecordSearch, "+record-search"},
|
||||
{"record-get", BaseRecordGet, "+record-get"},
|
||||
} {
|
||||
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatalf("%s: --json (request body) missing", tc.name)
|
||||
}
|
||||
if strings.Contains(fl.Usage, "shorthand") {
|
||||
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
|
||||
}
|
||||
if fl.Value.Type() != "string" {
|
||||
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum 已接入:help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)")
|
||||
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
usage := cmd.Flags().Lookup("format").Usage
|
||||
if !strings.Contains(usage, "(markdown|json)") {
|
||||
t.Fatalf("format usage missing enum suffix: %q", usage)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Enum: []string{"markdown", "json"},
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +889,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
@@ -1026,6 +1027,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
@@ -1171,6 +1173,75 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
|
||||
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
||||
}
|
||||
|
||||
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
|
||||
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
|
||||
// pretty-print switch or base +record-search's request-body payload).
|
||||
// Framework-injected flags never appear in s.Flags, so this cleanly separates
|
||||
// "self-declared json" from "injected shorthand".
|
||||
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "json" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
}
|
||||
}
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
// 1. the command has a format flag (self-declared or framework-injected), AND
|
||||
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
|
||||
// 3. no flag named "json" is registered yet — pflag panics on duplicate
|
||||
// registration, and commands that declare their own --json (event
|
||||
// +subscribe, base +record-search/-get) keep their custom semantics.
|
||||
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
// A shortcut that declares its own "json" flag defines custom semantics
|
||||
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
return
|
||||
}
|
||||
if !shortcutFormatSupportsJSON(s) {
|
||||
return
|
||||
}
|
||||
// Safety net: pflag panics on duplicate registration.
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
return
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
|
||||
// applyJSONShorthand folds the injected --json shorthand into the format flag
|
||||
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
|
||||
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
|
||||
// "json". An explicitly passed --format always wins over the shorthand (the
|
||||
// shorthand only fills in when the user did not choose a format). Shortcuts
|
||||
// that declare their own "json" flag keep its custom semantics untouched.
|
||||
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
return
|
||||
}
|
||||
if set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
}
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
for _, fl := range s.Flags {
|
||||
desc := fl.Desc
|
||||
@@ -1234,10 +1305,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
const jsonShorthandUsage = "shorthand for --format json"
|
||||
|
||||
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
s.Mount(parent, f)
|
||||
cmd, _, err := parent.Find([]string{s.Command})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
|
||||
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
// 默认输出格式不被改变
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
|
||||
t.Errorf("format default = %q, want table", def)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但 Enum 不含 json → 不注册
|
||||
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+no-json", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format Enum lacks json")
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但无 Enum(现状 triage 形态)→ 不注册(Enum 是判定依据)
|
||||
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+legacy", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format has no Enum metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 json flag(subscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
|
||||
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("self-declared --json missing")
|
||||
}
|
||||
if fl.Usage != "pretty-print JSON instead of NDJSON" {
|
||||
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMounted mounts the shortcut and parses args against the command's FlagSet
|
||||
// (registration side effects included), without executing RunE.
|
||||
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := mountTestShortcut(t, s)
|
||||
if err := cmd.ParseFlags(args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", args, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func customFormatShortcut() Shortcut {
|
||||
return Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// --json 单独使用 → format 归一化为 json
|
||||
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 --format 优先于 --json 简写:--format table --json → table
|
||||
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (explicit --format must win)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format json --json → json(一致,无冲突)
|
||||
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 均不传 → 默认值不变
|
||||
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, nil)
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (default untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 string 型 --json(record-search 形态:format+json 双声明)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "base", Command: "+fake-record-search", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
|
||||
{Name: "json", Desc: "request body JSON object"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
|
||||
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
|
||||
t.Fatalf("request-body --json corrupted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 bool 型 --json(subscribe 形态:无自定义 format,框架注入 format)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
// 注入的 format 默认即 json;这里断言的是 Changed 状态未被归一化污染
|
||||
if cmd.Flags().Changed("format") {
|
||||
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
|
||||
}
|
||||
}
|
||||
|
||||
// 无自定义 format(普通命令)→ 注入默认 format + 简写(现状回归)
|
||||
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+plain", Description: "x",
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json missing on default-format shortcut (regression)")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
}
|
||||
@@ -150,12 +150,10 @@ var ContactSearchUser = common.Shortcut{
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice'",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
|
||||
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
|
||||
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
|
||||
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateSearchUser(runtime)
|
||||
|
||||
@@ -623,6 +623,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
||||
decision.Class = "rate_limited"
|
||||
decision.Terminal = true
|
||||
case problem.Code == 1062507:
|
||||
decision.Class = "parent_sibling_limit"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
|
||||
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
|
||||
@@ -1334,6 +1334,75 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll a: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1062507,
|
||||
"msg": "parent node out of sibling num.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
|
||||
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b" {
|
||||
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// help 必须列出 --json 简写
|
||||
func TestMailTriageHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailTriage, []string{"+triage", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("triage help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailWatch, []string{"+watch", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("watch help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
|
||||
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage --json returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"messages"`) {
|
||||
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
|
||||
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
|
||||
f1, stdout1, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1", "--dry-run"}, f1, stdout1); err != nil {
|
||||
t.Fatalf("--json --dry-run error: %v", err)
|
||||
}
|
||||
f2, stdout2, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "json", "--max", "1", "--dry-run"}, f2, stdout2); err != nil {
|
||||
t.Fatalf("--format json --dry-run error: %v", err)
|
||||
}
|
||||
if stdout1.String() != stdout2.String() {
|
||||
t.Fatalf("dry-run outputs differ:\n--json:\n%s\n--format json:\n%s", stdout1.String(), stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级验证:显式 --format table 优先,--json 让位 → 仍走 table 路径
|
||||
func TestMailTriageExplicitTableWinsOverJSONShorthand(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "table", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("explicit --format table must win over --json (expected table hint)\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 错误验证:Enum 硬校验
|
||||
func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "bogus", "--max", "1", "--dry-run"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --format bogus")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T, want typed errs problem carrier", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
|
||||
@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -25,6 +27,9 @@ const (
|
||||
minVCMeetingEventsPageSize = 20
|
||||
maxVCMeetingEventsPageSize = 100
|
||||
maxVCMeetingEventsPages = 200
|
||||
leaveReasonUserLeft = 1
|
||||
leaveReasonMeetingEnded = 2
|
||||
leaveReasonKicked = 3
|
||||
)
|
||||
|
||||
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
@@ -41,11 +46,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// VCMeetingEvents lists bot meeting events for a meeting.
|
||||
// VCMeetingEvents lists meeting events for a meeting.
|
||||
var VCMeetingEvents = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-events",
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Description: "List meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -99,20 +104,28 @@ var VCMeetingEvents = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
events = compactMeetingEvents(events)
|
||||
outData := map[string]interface{}{
|
||||
"events": events,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
|
||||
outData := buildMeetingEventsOutput(data, events, identity, identityWarning)
|
||||
metadata := map[string]interface{}{
|
||||
"row_type": "metadata",
|
||||
"meeting": outData.Meeting,
|
||||
"identity": outData.Identity,
|
||||
"has_more": outData.HasMore,
|
||||
"page_token": outData.PageToken,
|
||||
}
|
||||
if len(outData.Warnings) > 0 {
|
||||
metadata["warnings"] = outData.Warnings
|
||||
}
|
||||
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
|
||||
|
||||
timeline := buildMeetingEventTimeline(events)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
})
|
||||
if runtime.Format == "ndjson" {
|
||||
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
|
||||
} else {
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
renderMeetingEventsCompactPretty(w, outData, timeline)
|
||||
})
|
||||
}
|
||||
if runtime.Format == "pretty" && pageToken != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
||||
if hasMore {
|
||||
@@ -123,6 +136,400 @@ var VCMeetingEvents = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
type meetingEventsOutput struct {
|
||||
Meeting meetingEventsMeeting `json:"meeting"`
|
||||
Identity meetingEventsIdentity `json:"identity"`
|
||||
Events []meetingEventsEvent `json:"events"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsMeeting struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
MeetingNo string `json:"meeting_no,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type meetingEventsIdentity struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ParticipantType string `json:"participant_type,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
EventTime string `json:"event_time,omitempty"`
|
||||
Actors []meetingEventsIdentity `json:"actors,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsEndSignal struct {
|
||||
Ended bool
|
||||
EndTime time.Time
|
||||
HasEndTime bool
|
||||
}
|
||||
|
||||
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
|
||||
output := meetingEventsOutput{
|
||||
Meeting: meetingEventsMeetingFromPayload(nil),
|
||||
Identity: identity,
|
||||
HasMore: common.GetBool(data, "has_more"),
|
||||
PageToken: common.GetString(data, "page_token"),
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
if warning = strings.TrimSpace(warning); warning != "" {
|
||||
output.Warnings = append(output.Warnings, warning)
|
||||
}
|
||||
}
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if meeting := common.GetMap(payload, "meeting"); meeting != nil {
|
||||
output.Meeting = meetingEventsMeetingFromPayload(meeting)
|
||||
}
|
||||
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
|
||||
}
|
||||
applyMeetingEventsEndSignal(&output.Meeting, meetingEventsEndSignalFromEvents(events))
|
||||
return output
|
||||
}
|
||||
|
||||
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
|
||||
if runtime.As() == core.AsBot {
|
||||
botInfo, err := runtime.BotInfo()
|
||||
if err != nil {
|
||||
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
|
||||
}
|
||||
return meetingEventsBotIdentity(botInfo), ""
|
||||
}
|
||||
userOpenID := strings.TrimSpace(runtime.UserOpenId())
|
||||
identity := meetingEventsIdentity{
|
||||
ID: userOpenID,
|
||||
Name: strings.TrimSpace(runtime.Config.UserName),
|
||||
ParticipantType: "human",
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
if userOpenID == "" {
|
||||
return identity, "identity unavailable: current user open_id is unavailable"
|
||||
}
|
||||
return identity, ""
|
||||
}
|
||||
|
||||
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
|
||||
if botInfo == nil {
|
||||
return meetingEventsIdentity{ParticipantType: "bot", Label: "bot"}
|
||||
}
|
||||
identity := meetingEventsIdentity{
|
||||
ID: botInfo.OpenID,
|
||||
Name: botInfo.AppName,
|
||||
ParticipantType: "bot",
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
|
||||
out := meetingEventsMeeting{
|
||||
ID: common.GetString(meeting, "id"),
|
||||
Topic: common.GetString(meeting, "topic"),
|
||||
MeetingNo: common.GetString(meeting, "meeting_no"),
|
||||
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
|
||||
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
|
||||
Status: "unknown",
|
||||
}
|
||||
start, hasStart := parseFlexibleTime(out.StartTime)
|
||||
end, hasEnd := parseFlexibleTime(out.EndTime)
|
||||
if hasStart && !hasEnd {
|
||||
out.Status = "ongoing"
|
||||
}
|
||||
if hasStart && hasEnd {
|
||||
if end.After(start) {
|
||||
out.Status = "ended"
|
||||
} else {
|
||||
out.Status = "ongoing"
|
||||
out.EndTime = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyMeetingEventsEndSignal(meeting *meetingEventsMeeting, signal meetingEventsEndSignal) {
|
||||
if meeting == nil || !signal.Ended {
|
||||
return
|
||||
}
|
||||
meeting.Status = "ended"
|
||||
if signal.HasEndTime {
|
||||
meeting.EndTime = signal.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsEndSignalFromEvents(events []interface{}) meetingEventsEndSignal {
|
||||
var signal meetingEventsEndSignal
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil || meetingEventType(event) != "participant_left" {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
fallbackTime, fallbackOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
for _, rawItem := range common.GetSlice(payload, "participant_left_items") {
|
||||
item, _ := rawItem.(map[string]interface{})
|
||||
if item == nil || int(common.GetFloat(item, "leave_reason")) != leaveReasonMeetingEnded {
|
||||
continue
|
||||
}
|
||||
signal.Ended = true
|
||||
endTime, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
|
||||
if !ok {
|
||||
endTime, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
if ok && (!signal.HasEndTime || endTime.After(signal.EndTime)) {
|
||||
signal.EndTime = endTime
|
||||
signal.HasEndTime = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
|
||||
payload := common.GetMap(event, "payload")
|
||||
out := meetingEventsEvent{
|
||||
EventID: common.GetString(event, "event_id"),
|
||||
EventType: meetingEventType(event),
|
||||
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
|
||||
Payload: payload,
|
||||
}
|
||||
out.Actors = eventActors(out.EventType, payload, selfIdentity)
|
||||
return out
|
||||
}
|
||||
|
||||
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
|
||||
var actors []meetingEventsIdentity
|
||||
addFromItems := func(key, participantKey string) {
|
||||
for _, raw := range common.GetSlice(payload, key) {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if participant := common.GetMap(item, participantKey); participant != nil {
|
||||
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
|
||||
}
|
||||
}
|
||||
}
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
addFromItems("participant_joined_items", "participant")
|
||||
case "participant_left":
|
||||
addFromItems("participant_left_items", "participant")
|
||||
case "transcript_received":
|
||||
addFromItems("transcript_received_items", "speaker")
|
||||
case "chat_received":
|
||||
addFromItems("chat_received_items", "operator")
|
||||
case "magic_share_started":
|
||||
addFromItems("magic_share_started_items", "operator")
|
||||
case "magic_share_ended":
|
||||
addFromItems("magic_share_ended_items", "operator")
|
||||
}
|
||||
return actors
|
||||
}
|
||||
|
||||
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
|
||||
identity := meetingEventsIdentity{
|
||||
ID: common.GetString(participant, "id"),
|
||||
Name: common.GetString(participant, "user_name"),
|
||||
ParticipantType: meetingEventsParticipantType(participant),
|
||||
Role: meetingEventsParticipantRole(participant),
|
||||
}
|
||||
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
|
||||
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
|
||||
identity.ParticipantType = "bot"
|
||||
}
|
||||
if selfIdentity.ParticipantType == "bot" && (identity.Role == "" || identity.Role == "participant") {
|
||||
identity.Role = "bot"
|
||||
}
|
||||
}
|
||||
if identity.ParticipantType == "" {
|
||||
identity.ParticipantType = "human"
|
||||
}
|
||||
if identity.Role == "" {
|
||||
identity.Role = "participant"
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func meetingEventsParticipantType(participant map[string]interface{}) string {
|
||||
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
|
||||
return raw
|
||||
}
|
||||
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
|
||||
}
|
||||
|
||||
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "user", "human":
|
||||
return "human"
|
||||
case "2", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsParticipantRole(participant map[string]interface{}) string {
|
||||
if raw := meetingEventsRoleFromParticipantRole(fieldValueString(participant, "role")); raw != "" {
|
||||
return raw
|
||||
}
|
||||
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
|
||||
}
|
||||
|
||||
func meetingEventsParticipantTypeFromUserType(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "user", "human":
|
||||
return "human"
|
||||
case "2", "10", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsRoleFromParticipantRole(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "host":
|
||||
return "host"
|
||||
case "2", "co_host", "cohost":
|
||||
return "co_host"
|
||||
case "3", "participant", "attendee":
|
||||
return "participant"
|
||||
case "4", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsRoleFromEventUserRole(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "participant", "attendee":
|
||||
return "participant"
|
||||
case "2", "host":
|
||||
return "host"
|
||||
case "4", "bot", "app":
|
||||
return "bot"
|
||||
case "", "0":
|
||||
return ""
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func fieldValueString(values map[string]interface{}, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
switch value := values[key].(type) {
|
||||
case string:
|
||||
return value
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func identityLabel(identity meetingEventsIdentity) string {
|
||||
name := identity.Name
|
||||
if name == "" {
|
||||
name = identity.ID
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
var tags []string
|
||||
if identity.ParticipantType != "" {
|
||||
tags = append(tags, identity.ParticipantType)
|
||||
}
|
||||
if identity.Role != "" && identity.Role != identity.ParticipantType {
|
||||
tags = append(tags, identity.Role)
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
|
||||
}
|
||||
|
||||
func meetingEventsTimeString(raw string) string {
|
||||
if parsed, ok := parseFlexibleTime(raw); ok {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
|
||||
rows := make([]interface{}, 0, len(events)+1)
|
||||
for _, event := range events {
|
||||
row := meetingEventsEventRow(event)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if metadata != nil {
|
||||
rows = append(rows, metadata)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func meetingEventsEventRow(event meetingEventsEvent) map[string]interface{} {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"row_type": "event"}
|
||||
}
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
return map[string]interface{}{"row_type": "event"}
|
||||
}
|
||||
row["row_type"] = "event"
|
||||
return row
|
||||
}
|
||||
|
||||
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
|
||||
if data.Identity.Label != "" {
|
||||
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
|
||||
}
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
}
|
||||
|
||||
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
||||
if runtime.Bool("page-all") {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
@@ -323,7 +730,6 @@ type meetingTimelineEntry struct {
|
||||
when time.Time
|
||||
hasWhen bool
|
||||
sequence int
|
||||
group int
|
||||
subject string
|
||||
description string
|
||||
details []string
|
||||
@@ -332,7 +738,6 @@ type meetingTimelineEntry struct {
|
||||
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
timeline := meetingTimeline{}
|
||||
var sequence int
|
||||
var group int
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
@@ -345,11 +750,11 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
||||
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
||||
}
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
|
||||
timeline.entries = append(timeline.entries, entry)
|
||||
}
|
||||
group++
|
||||
}
|
||||
applyMeetingTimelineEndSignal(&timeline, meetingEventsEndSignalFromEvents(events))
|
||||
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
||||
left := timeline.entries[i]
|
||||
right := timeline.entries[j]
|
||||
@@ -370,6 +775,24 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
return timeline
|
||||
}
|
||||
|
||||
func applyMeetingTimelineEndSignal(timeline *meetingTimeline, signal meetingEventsEndSignal) {
|
||||
if timeline == nil || !signal.Ended {
|
||||
return
|
||||
}
|
||||
if signal.HasEndTime {
|
||||
if !timeline.hasStart || signal.EndTime.After(timeline.startTime) {
|
||||
timeline.endTime = signal.EndTime
|
||||
timeline.hasEnd = true
|
||||
return
|
||||
}
|
||||
timeline.hasEnd = false
|
||||
return
|
||||
}
|
||||
if timeline.hasStart && timeline.hasEnd && !timeline.endTime.After(timeline.startTime) {
|
||||
timeline.hasEnd = false
|
||||
}
|
||||
}
|
||||
|
||||
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
||||
if timeline == nil || meeting == nil {
|
||||
return
|
||||
@@ -391,7 +814,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
|
||||
}
|
||||
}
|
||||
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
return nil
|
||||
@@ -400,26 +823,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
|
||||
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "participant_left":
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "transcript_received":
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "chat_received":
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
default:
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -432,15 +855,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -453,15 +876,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -479,15 +902,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
|
||||
if text != "" {
|
||||
description = text
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -507,15 +930,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
|
||||
} else {
|
||||
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -538,15 +961,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
|
||||
if url != "" {
|
||||
details = append(details, "URL: "+url)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -559,17 +982,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
|
||||
entry := meetingTimelineEntry{
|
||||
when: when,
|
||||
hasWhen: hasWhen,
|
||||
sequence: *sequence,
|
||||
group: group,
|
||||
subject: subject,
|
||||
description: description,
|
||||
details: details,
|
||||
@@ -713,9 +1135,9 @@ func needsColon(description string) bool {
|
||||
|
||||
func leaveAction(item map[string]interface{}) string {
|
||||
switch int(common.GetFloat(item, "leave_reason")) {
|
||||
case 2:
|
||||
case leaveReasonMeetingEnded:
|
||||
return "因会议结束离开了会议"
|
||||
case 3:
|
||||
case leaveReasonKicked:
|
||||
return "被移出了会议"
|
||||
default:
|
||||
return "离开了会议"
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -54,6 +55,33 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
|
||||
}
|
||||
}
|
||||
|
||||
func botInfoStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "bot_001",
|
||||
"app_name": "Demo Bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func botInfoErrorStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "bot info unavailable",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-1",
|
||||
@@ -73,6 +101,8 @@ func participantJoinedEvent() map[string]interface{} {
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 2,
|
||||
"user_role": 4,
|
||||
},
|
||||
"join_time": "2026-04-17T08:00:00Z",
|
||||
},
|
||||
@@ -90,6 +120,36 @@ func participantJoinedEventOngoing() map[string]interface{} {
|
||||
return event
|
||||
}
|
||||
|
||||
func participantLeftEventWithReason(leaveReason int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-left",
|
||||
"event_type": "participant_left",
|
||||
"event_time": "2026-04-17T07:18:50Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "participant_left",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776410100",
|
||||
"end_time": "1776410100",
|
||||
},
|
||||
"participant_left_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 2,
|
||||
"user_role": 4,
|
||||
},
|
||||
"leave_time": "1776410330000",
|
||||
"leave_reason": leaveReason,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func chatReceivedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-2",
|
||||
@@ -112,7 +172,7 @@ func chatReceivedEvent() map[string]interface{} {
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 3,
|
||||
"message_type": 1,
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
@@ -140,7 +200,7 @@ func multiChatReceivedEvent() map[string]interface{} {
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "第一条\n第二行",
|
||||
"message_type": 3,
|
||||
"message_type": 1,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
@@ -149,6 +209,44 @@ func multiChatReceivedEvent() map[string]interface{} {
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "第二条",
|
||||
"message_type": 1,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mixedChatAndReactionEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-reaction",
|
||||
"event_type": "chat_received",
|
||||
"event_time": "2026-04-17T08:05:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "chat_received",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 1,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "OK",
|
||||
"message_type": 3,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
@@ -414,7 +512,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
|
||||
"--start", "1710000000",
|
||||
"--end", "1710003600",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -442,7 +540,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--page-all",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -457,24 +555,39 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--page-all",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v: %s", err, stdout.String())
|
||||
}
|
||||
events := common.GetSlice(common.GetMap(envelope, "data"), "events")
|
||||
if got := len(events); got != 2 {
|
||||
t.Fatalf("events len = %d, want 2: %s", got, stdout.String())
|
||||
}
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if _, ok := event["summary"]; ok {
|
||||
t.Fatalf("event should not expose summary: %s", stdout.String())
|
||||
}
|
||||
if _, ok := event["raw"]; ok {
|
||||
t.Fatalf("event should not expose raw: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
|
||||
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
|
||||
}
|
||||
if !strings.Contains(out, `"has_more":false`) {
|
||||
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
||||
}
|
||||
@@ -483,6 +596,80 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","label":"DemoBot[bot]"}`,
|
||||
`"role":"bot"`,
|
||||
`"event_type":"participant_joined"`,
|
||||
`"actors":[`,
|
||||
`"start_time":"2026-04-17T06:35:00Z"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
`"current_participants":`,
|
||||
`"is_self":`,
|
||||
`"summary":`,
|
||||
`"raw":`,
|
||||
} {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
reg.Register(botInfoErrorStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"participant_joined"`,
|
||||
`"identity":{"participant_type":"bot","label":"bot"}`,
|
||||
`"warnings":[`,
|
||||
`identityunavailable`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
@@ -498,26 +685,205 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"identity":{"id":"ou_testuser","participant_type":"human","label":"ou_testuser[human]"}`,
|
||||
`"event_type":"participant_joined"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
`"has_more":false`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
t.Fatalf("user json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := common.GetMap(envelope, "data")
|
||||
meeting := common.GetMap(data, "meeting")
|
||||
if got := common.GetString(meeting, "status"); got != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
|
||||
}
|
||||
if _, ok := meeting["end_time"]; ok {
|
||||
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ended" {
|
||||
t.Fatalf("meeting status = %q, want ended", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "2026-04-17T07:18:50Z" {
|
||||
t.Fatalf("meeting end_time = %q, want leave time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_NormalLeaveReasonDoesNotEndMeeting(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantLeftEventWithReason(leaveReasonUserLeft),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "" {
|
||||
t.Fatalf("meeting end_time = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMeetingEventsPretty_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||
timeline := buildMeetingEventTimeline([]interface{}{
|
||||
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||
})
|
||||
got := renderMeetingEventsPretty(timeline)
|
||||
|
||||
if strings.Contains(got, "进行中") {
|
||||
t.Fatalf("pretty output should not show ongoing for meeting-ended leave reason: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "会议时间:2026-04-17 15:15:00 - 2026-04-17 15:18:50") {
|
||||
t.Fatalf("pretty output missing derived meeting end window: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_UsesLatestMeetingSnapshot(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantJoinedEventOngoing(),
|
||||
participantJoinedEvent(),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ended" {
|
||||
t.Fatalf("meeting status = %q, want ended", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "2026-04-17T07:35:00Z" {
|
||||
t.Fatalf("meeting end_time = %q, want latest ended snapshot", got)
|
||||
}
|
||||
if got := len(out.Events); got != 2 {
|
||||
t.Fatalf("events len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_EmptyEventsHasUnknownMeetingStatus(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, nil, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "unknown" {
|
||||
t.Fatalf("meeting status = %q, want unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsMeetingFromPayload_StartOnlyIsOngoing(t *testing.T) {
|
||||
got := meetingEventsMeetingFromPayload(map[string]interface{}{
|
||||
"id": "m1",
|
||||
"start_time": "1776410100",
|
||||
})
|
||||
|
||||
if got.Status != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing", got.Status)
|
||||
}
|
||||
if got.StartTime != "2026-04-17T07:15:00Z" {
|
||||
t.Fatalf("meeting start_time = %q, want normalized RFC3339", got.StartTime)
|
||||
}
|
||||
if got.EndTime != "" {
|
||||
t.Fatalf("meeting end_time = %q, want empty", got.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "ndjson",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
|
||||
t.Fatalf("first ndjson row should be event: %s", lines[0])
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
`"summary":`,
|
||||
`"raw":`,
|
||||
} {
|
||||
if strings.Contains(lines[0], unwanted) {
|
||||
t.Fatalf("event ndjson row should not contain %q: %s", unwanted, lines[0])
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"row_type":"metadata"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"identity":`,
|
||||
} {
|
||||
if !strings.Contains(lines[1], want) {
|
||||
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsEventRows_OmitsEmptyEventFields(t *testing.T) {
|
||||
rows := meetingEventsEventRows([]meetingEventsEvent{
|
||||
{EventType: "unknown_event"},
|
||||
}, nil)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
row, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("row type = %T, want map", rows[0])
|
||||
}
|
||||
for _, unwanted := range []string{"event_id", "event_time", "actors", "payload"} {
|
||||
if _, exists := row[unwanted]; exists {
|
||||
t.Fatalf("row should omit %q when empty: %#v", unwanted, row)
|
||||
}
|
||||
}
|
||||
if got := row["row_type"]; got != "event" {
|
||||
t.Fatalf("row_type = %v, want event", got)
|
||||
}
|
||||
if got := row["event_type"]; got != "unknown_event" {
|
||||
t.Fatalf("event_type = %v, want unknown_event", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -536,20 +902,54 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, `"message_type": 3`) {
|
||||
if !strings.Contains(out, `"message_type": 1`) {
|
||||
t.Fatalf("json output should keep numeric fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"chat_received"`,
|
||||
`"chat_received_items":[`,
|
||||
`"content":"OK"`,
|
||||
`"message_type":3`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"im_post"`) {
|
||||
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -558,11 +958,12 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"当前身份:Demo Bot [bot]",
|
||||
"会议主题:项目例会",
|
||||
"会议时间:2026-04-17 15:15:00(进行中)",
|
||||
"Demo Bot(bot_001) 加入了会议",
|
||||
"Alice(u1): [reaction] 第一条\\n第二行",
|
||||
"Alice(u1): [reaction] 第二条",
|
||||
"Alice(u1): [text] 第一条\\n第二行",
|
||||
"Alice(u1): [text] 第二条",
|
||||
"Bob(u2) 开始共享「共享文档」",
|
||||
"URL: https://example.com/doc",
|
||||
"page_token: 1710000000000000000",
|
||||
@@ -582,12 +983,13 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -606,12 +1008,13 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
|
||||
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub(nil, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -850,9 +1253,9 @@ func TestLeaveAction(t *testing.T) {
|
||||
item map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": leaveReasonMeetingEnded}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": leaveReasonKicked}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": leaveReasonUserLeft}, want: "离开了会议"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -884,6 +1287,70 @@ func TestMeetingEventUserWithID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"user_type": 1,
|
||||
"user_role": 2,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "human" || got.Role != "host" {
|
||||
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"user_type": 1,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.Role != "participant" {
|
||||
t.Fatalf("identity = %#v, want role=participant", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "ou_app",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 10,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "bot" {
|
||||
t.Fatalf("identity = %#v, want participant_type=bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UnknownUserType(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u_unknown",
|
||||
"user_name": "Unknown",
|
||||
"user_type": 0,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "unknown" {
|
||||
t.Fatalf("identity = %#v, want participant_type=unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"type": "bot",
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "human" {
|
||||
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -933,6 +1400,22 @@ func TestMeetingEventSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsEventFromPayloadUsesActivityEventTypeFallback(t *testing.T) {
|
||||
event := participantJoinedEvent()
|
||||
delete(event, "event_type")
|
||||
|
||||
got := meetingEventsEventFromPayload(event, meetingEventsIdentity{})
|
||||
if got.EventType != "participant_joined" {
|
||||
t.Fatalf("EventType = %q, want participant_joined", got.EventType)
|
||||
}
|
||||
if len(got.Actors) != 1 {
|
||||
t.Fatalf("actors len = %d, want 1: %#v", len(got.Actors), got.Actors)
|
||||
}
|
||||
if got.Actors[0].ID != "bot_001" {
|
||||
t.Fatalf("actor id = %q, want bot_001", got.Actors[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapePrettyText(t *testing.T) {
|
||||
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
||||
want := `line1\nline2\t\r\u0007`
|
||||
|
||||
@@ -199,6 +199,19 @@ func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != opaqueNodeToken {
|
||||
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -224,11 +237,6 @@ func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -351,10 +359,11 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
@@ -365,7 +374,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wik_parent",
|
||||
"parent_node_token": parentNodeToken,
|
||||
"node_type": "origin",
|
||||
"title": "Child Doc",
|
||||
"has_child": false,
|
||||
@@ -378,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -400,8 +409,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
if len(envelope.Data.Nodes) != 1 {
|
||||
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
|
||||
}
|
||||
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
|
||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
|
||||
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
|
||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
|
||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
|
||||
},
|
||||
@@ -235,29 +235,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
if spec.ObjType == "" {
|
||||
spec.SourceKind = "raw-node"
|
||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
// A raw obj_token needs an explicit obj_type: get_node would
|
||||
// otherwise default to "doc" and fail confusingly for docx /
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,18 +251,6 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
||||
//
|
||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
||||
func looksLikeWikiNodeToken(token string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
||||
}
|
||||
|
||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||
// don't need one.
|
||||
|
||||
@@ -31,6 +31,22 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
|
||||
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -43,23 +59,30 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
||||
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
||||
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
||||
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
||||
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,11 +207,6 @@ func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ metadata:
|
||||
|
||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
||||
|
||||
## 身份与一次性授权
|
||||
|
||||
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain apps
|
||||
```
|
||||
|
||||
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
|
||||
|
||||
## 意图路由
|
||||
|
||||
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||
- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- `--environment` 枚举:`dev` / `online`,**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`)**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **环境 `--environment dev|online`(可省略)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`、未开多环境的走 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式传 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义,**没有** `--environment`。
|
||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`。**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
|
||||
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
|
||||
- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
||||
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -21,6 +21,8 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
@@ -162,7 +164,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
>
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) 的模板通过 `--params` 传 `folder_token` / `page_token`,并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传参并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
|
||||
### files
|
||||
|
||||
@@ -204,10 +206,12 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
### file.statistics
|
||||
|
||||
- `get` — 获取文件统计信息
|
||||
- 获取 docx / 文件统计信息时,建议优先使用 typed flags:`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json`;`--params` JSON 也支持,适合批量拼装或 raw 参数场景。
|
||||
|
||||
### file.view_records
|
||||
|
||||
- `list` — 获取文档的访问者记录
|
||||
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags:`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json`;`--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
|
||||
|
||||
### file.comment.reply.reactions
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
|
||||
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token,或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
|
||||
|
||||
## 删除前门槛
|
||||
|
||||
执行 `drive +delete --yes` 前同时满足:
|
||||
|
||||
| 条件 | 可执行信号 |
|
||||
|------|------------|
|
||||
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token,或用户确认过且可解析的资源列表 |
|
||||
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
|
||||
|
||||
若缺少任一条件,使用 `drive +search`、`drive +inspect` 或只读 API 收集候选并回复待确认清单;启发式规则(打开时间、标题模式、owner、文件类型等)只能作为候选筛选依据,不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token` 和 `--type`。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user