mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
6 Commits
feat/lark-
...
feat/start
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6aa7dc6a | ||
|
|
ed63e12725 | ||
|
|
761aa55cbf | ||
|
|
2098c3c412 | ||
|
|
4215ad9908 | ||
|
|
0b305a2248 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ tests/mail/reports/
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
internal/registry/meta_data.json
|
||||
internal/registry/metastatic/meta_data_gen.go
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
|
||||
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -2,6 +2,8 @@ version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go),
|
||||
# the sole source of the embedded command tree.
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,31 +75,7 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -2,46 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
@@ -1066,7 +1026,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
|
||||
3
Makefile
3
Makefile
@@ -12,6 +12,9 @@ PREFIX ?= /usr/local
|
||||
|
||||
all: test
|
||||
|
||||
# fetch_meta fetches meta_data.json AND regenerates the static Go registry
|
||||
# (internal/registry/metastatic/meta_data_gen.go) — the sole build-time source
|
||||
# of the embedded command tree. Both are gitignored; build/vet/test depend on it.
|
||||
fetch_meta:
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
|
||||
@@ -72,10 +72,12 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
// Brand only — never decrypt the app secret just to build help text
|
||||
// (avoids a keychain read on every `auth login --help` / completion).
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
if f != nil && f.ConfigBrand != nil {
|
||||
if b, ok := f.ConfigBrand(); ok {
|
||||
helpBrand = b
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
@@ -296,11 +298,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -318,9 +319,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -407,11 +406,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
87
cmd/command_tree_dump_test.go
Normal file
87
cmd/command_tree_dump_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Tree-dump tool: dumps the full command tree (paths, flags, descriptions,
|
||||
// annotations) in a canonical, line-stable form so two builds can be diffed
|
||||
// byte-for-byte (e.g. before/after a registry change). Set LARK_TREE_DUMP=<path>
|
||||
// to write the dump; otherwise the test is a no-op. Not a committed golden — the
|
||||
// meta data is fetched/gitignored and drifts.
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func esc(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\t", "\\t")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
func dumpCommandTree(root *cobra.Command) string {
|
||||
var lines []string
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||
head := fmt.Sprintf("CMD %q use=%q short=%q long=%q runnable=%t hidden=%t",
|
||||
path, esc(c.Use), esc(c.Short), esc(c.Long), c.Runnable(), c.Hidden)
|
||||
lines = append(lines, head)
|
||||
|
||||
if len(c.Annotations) > 0 {
|
||||
keys := make([]string, 0, len(c.Annotations))
|
||||
for k := range c.Annotations {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
lines = append(lines, fmt.Sprintf(" ann %s=%q", k, esc(c.Annotations[k])))
|
||||
}
|
||||
}
|
||||
|
||||
var flags []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
flags = append(flags, fmt.Sprintf(" flag --%s -%s type=%s def=%q usage=%q",
|
||||
f.Name, f.Shorthand, f.Value.Type(), esc(f.DefValue), esc(f.Usage)))
|
||||
})
|
||||
sort.Strings(flags)
|
||||
lines = append(lines, flags...)
|
||||
|
||||
subs := c.Commands()
|
||||
sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() })
|
||||
for _, sub := range subs {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func TestDumpCommandTree(t *testing.T) {
|
||||
out := os.Getenv("LARK_TREE_DUMP")
|
||||
if out == "" {
|
||||
t.Skip("set LARK_TREE_DUMP=<path> to dump the command tree")
|
||||
}
|
||||
// Deterministic: embedded meta only (no remote cache), empty config dir so
|
||||
// strict-mode/plugins/policy cannot reshape the tree.
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||
dump := dumpCommandTree(root)
|
||||
if err := os.WriteFile(out, []byte(dump), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote %d bytes, %d lines to %s", len(dump), strings.Count(dump, "\n"), out)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -102,10 +101,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -301,27 +301,23 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -333,21 +329,18 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
@@ -359,10 +352,7 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -78,7 +73,6 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -96,77 +90,6 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -207,7 +130,6 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,17 +89,19 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,19 +145,17 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := permErr.Hint
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,11 +26,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -40,20 +36,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -40,14 +39,12 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -76,7 +73,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -168,7 +165,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -130,38 +129,3 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,6 +64,9 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -30,74 +31,56 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
for _, spec := range registry.TypedServices() {
|
||||
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
|
||||
continue
|
||||
}
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, spec, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
svc := registry.MapToService(spec)
|
||||
svc.Resources = registry.MapToResources(resources)
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
|
||||
specDesc := registry.GetServiceDescription(spec.Name, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
specDesc = spec.Description
|
||||
}
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == specName {
|
||||
if c.Name() == spec.Name {
|
||||
svc = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Use: spec.Name,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
for _, resource := range spec.Resources {
|
||||
registerResourceWithContext(ctx, svc, spec, resource, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, resource metaschema.Resource, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
Use: resource.Name,
|
||||
Short: resource.Name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
for _, method := range resource.Methods {
|
||||
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +108,36 @@ type ServiceMethodOptions struct {
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
// detectFileFieldsTyped returns the names of file-type fields in the method's
|
||||
// request body (used to decide whether to register --file).
|
||||
func detectFileFieldsTyped(m metaschema.Method) []string {
|
||||
var fields []string
|
||||
for _, fld := range m.RequestBody {
|
||||
if fld.Type == "file" {
|
||||
fields = append(fields, fld.Name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, method metaschema.Method, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service
|
||||
// method from map specs (kept for tests; converts to typed internally).
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, registry.MapToService(spec), registry.MapToMethod(name, method), name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec metaschema.Service, method metaschema.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := method.Description
|
||||
httpMethod := method.HTTPMethod
|
||||
risk := method.Risk
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", spec.Name, resName, name)
|
||||
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
}
|
||||
var asStr string
|
||||
@@ -159,6 +147,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Materialize the maps the execution path still reads lazily — only
|
||||
// when THIS command actually runs, never at startup.
|
||||
opts.Spec = registry.ServiceToMap(spec)
|
||||
opts.Method = registry.MethodToMap(method)
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
@@ -188,7 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
fileFields := detectFileFieldsTyped(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
@@ -200,10 +192,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
// meta_data.json carries no per-method tips; SetTips(nil) matches prior behavior.
|
||||
cmdutil.SetTips(cmd, nil)
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
if len(method.AccessTokens) > 0 {
|
||||
toks := make([]interface{}, len(method.AccessTokens))
|
||||
for i, t := range method.AccessTokens {
|
||||
toks[i] = t
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(toks))
|
||||
}
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
got := detectFileFieldsTyped(registry.MapToMethod("", tt.method))
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
|
||||
@@ -5,9 +5,9 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,8 +16,7 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||
// Problem.Code rather than the legacy envelope shape.
|
||||
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||
WithCode(vcNoteDetailNotFoundCode)
|
||||
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||
}
|
||||
if isLarkCode(typedNotFound, 99999) {
|
||||
t.Error("a different expected code must not match")
|
||||
}
|
||||
|
||||
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||
t.Error("typed error with another code must not match")
|
||||
}
|
||||
|
||||
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||
t.Error("untyped error must not match")
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,12 @@ package vc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -147,8 +148,9 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,8 +16,7 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -25,15 +24,11 @@ const cleanupTimeout = 5 * time.Second
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"param whiteboard_id is required for %s", eventType).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -59,16 +58,6 @@ func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("missing whiteboard_id should carry a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
@@ -81,9 +70,6 @@ func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
|
||||
@@ -30,10 +30,11 @@ type InvocationContext struct {
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
ConfigBrand func() (core.LarkBrand, bool) // brand only, no secret decryption — for startup help/registration (avoids keychain)
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
||||
@@ -151,11 +152,14 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
acct, err := f.Credential.ResolveAccount(ctx)
|
||||
if err != nil || acct == nil {
|
||||
// Strict mode is plain config metadata; resolve it WITHOUT decrypting the
|
||||
// app secret so identity-flag registration at startup never touches the
|
||||
// keychain (ResolveStrictMode is called per command during Build).
|
||||
_, supported, ok := f.Credential.ResolveMeta(ctx)
|
||||
if !ok {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
ids := extcred.IdentitySupport(supported)
|
||||
switch {
|
||||
case ids.BotOnly():
|
||||
return core.StrictModeBot
|
||||
|
||||
@@ -78,6 +78,18 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// ConfigBrand resolves just the brand without decrypting the app secret, so
|
||||
// brand-aware help and shortcut registration at startup do not touch the
|
||||
// keychain. It still initializes the registry with the resolved brand — the
|
||||
// same side effect Config has, minus the secret.
|
||||
f.ConfigBrand = sync.OnceValues(func() (core.LarkBrand, bool) {
|
||||
brand, _, ok := f.Credential.ResolveMeta(context.Background())
|
||||
if ok {
|
||||
registry.InitWithBrand(brand)
|
||||
}
|
||||
return brand, ok
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
|
||||
|
||||
@@ -65,7 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
ConfigBrand: func() (core.LarkBrand, bool) {
|
||||
if config != nil {
|
||||
return config.Brand, true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
|
||||
@@ -21,6 +21,14 @@ type DefaultAccountResolver interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// metaResolver is an optional capability: resolve config metadata (brand +
|
||||
// strict-mode identity support) without resolving the app secret (no keychain
|
||||
// access). Providers that don't implement it fall back to ResolveAccount inside
|
||||
// CredentialProvider.ResolveMeta.
|
||||
type metaResolver interface {
|
||||
ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool)
|
||||
}
|
||||
|
||||
// DefaultTokenResolver is implemented by the default token provider.
|
||||
type DefaultTokenResolver interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
@@ -141,6 +149,11 @@ type CredentialProvider struct {
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
|
||||
metaOnce sync.Once
|
||||
metaBrand core.LarkBrand
|
||||
metaIdents uint8
|
||||
metaOK bool
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
hintErr error
|
||||
@@ -172,6 +185,44 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// ResolveMeta resolves config metadata — brand and strict-mode identity support
|
||||
// — cheaply, WITHOUT decrypting the app secret for the default
|
||||
// (config.json/keychain) provider. It mirrors doResolveAccount's provider
|
||||
// selection: external providers (env/sidecar) are asked first via ResolveAccount
|
||||
// (they do not touch the keychain), then the default provider's keychain-free
|
||||
// metaResolver path. Cached after first call. Best-effort: returns ok=false when
|
||||
// nothing is configured, so callers keep their defaults. Used for brand-aware
|
||||
// help text, shortcut registration, and strict-mode checks at startup, where
|
||||
// decrypting the secret would be wasteful.
|
||||
func (p *CredentialProvider) ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
|
||||
p.metaOnce.Do(func() {
|
||||
p.metaBrand, p.metaIdents, p.metaOK = p.doResolveMeta(ctx)
|
||||
})
|
||||
return p.metaBrand, p.metaIdents, p.metaOK
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
return internal.Brand, internal.SupportedIdentities, true
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
if mr, ok := p.defaultAcct.(metaResolver); ok {
|
||||
return mr.ResolveMeta(ctx)
|
||||
}
|
||||
if acct, err := p.defaultAcct.ResolveAccount(ctx); err == nil && acct != nil {
|
||||
return acct.Brand, acct.SupportedIdentities, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
|
||||
@@ -76,6 +76,23 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
return AccountFromCliConfig(cfg), nil
|
||||
}
|
||||
|
||||
// ResolveMeta returns config metadata — brand and the strict-mode identity
|
||||
// support — from config.json WITHOUT resolving the app secret (no keychain
|
||||
// access). Both are plain config fields, so brand-aware help, shortcut
|
||||
// registration, and strict-mode checks at startup need not decrypt the secret.
|
||||
// Returns ok=false when no config exists, so callers keep their defaults.
|
||||
func (p *DefaultAccountProvider) ResolveMeta(_ context.Context) (core.LarkBrand, uint8, bool) {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
app := multi.CurrentAppConfig(p.profile)
|
||||
if app == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return app.Brand, strictModeToIdentitySupport(multi, p.profile), true
|
||||
}
|
||||
|
||||
// strictModeToIdentitySupport maps the config-level strict mode to
|
||||
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
||||
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -45,9 +44,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -83,8 +80,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -94,11 +90,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +152,8 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,15 +169,11 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,21 +8,17 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -5,13 +5,10 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -23,16 +20,6 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -24,8 +23,7 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
@@ -52,9 +51,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,10 +65,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -90,9 +88,7 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
@@ -19,72 +19,32 @@ import (
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
var registryFS embed.FS
|
||||
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
// EmbeddedSpec returns the embedded baseline spec for one service as a map, or
|
||||
// nil if the service is unknown. It reads the static compile-time registry
|
||||
// (metastatic.Registry) and bypasses the remote overlay, so envelope output is
|
||||
// deterministic across machines.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
if svc, ok := baselineServiceByName(serviceName); ok {
|
||||
return ServiceToMap(svc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
// EmbeddedServiceNames returns the embedded baseline service names, sorted
|
||||
// (no remote overlay).
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
svcs := baselineServices()
|
||||
out := make([]string, 0, len(svcs))
|
||||
for _, s := range svcs {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
embeddedVersion string // version from embedded meta_data.json
|
||||
initOnce sync.Once
|
||||
embeddedVersion string // baseline data version (from the static registry)
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
// Init initializes the registry with default brand (feishu).
|
||||
@@ -101,55 +61,27 @@ func Init() {
|
||||
func InitWithBrand(brand core.LarkBrand) {
|
||||
initOnce.Do(func() {
|
||||
configuredBrand = brand
|
||||
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
|
||||
loadEmbeddedIntoMerged()
|
||||
// 2. Remote overlay
|
||||
// 1. Baseline version: the static compile-time registry (metastatic).
|
||||
embeddedVersion = baselineVersion()
|
||||
// 2. Remote overlay — still fetched/refreshed at runtime, decoded into
|
||||
// the same typed shape and merged over the baseline.
|
||||
if remoteEnabled() && cacheWritable() {
|
||||
// Check if brand changed since last cache
|
||||
meta, metaErr := loadCacheMeta()
|
||||
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
_ = loadCachedTyped()
|
||||
}
|
||||
if len(mergedServices) == 0 || brandChanged {
|
||||
// No data at all or brand changed — must sync fetch
|
||||
if !hasTypedData() || brandChanged {
|
||||
// No data at all (e.g. stub build, no cache) or brand changed.
|
||||
doSyncFetch()
|
||||
} else if shouldRefresh(meta) || metaErr != nil {
|
||||
// Have embedded/cached data; refresh in background if TTL expired or first run
|
||||
triggerBackgroundRefresh()
|
||||
}
|
||||
}
|
||||
// 3. Build sorted project list
|
||||
rebuildProjectList()
|
||||
})
|
||||
}
|
||||
|
||||
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
|
||||
// mergedServices. No-op if meta_data.json is not compiled in.
|
||||
func loadEmbeddedIntoMerged() {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return
|
||||
}
|
||||
embeddedVersion = reg.Version
|
||||
overlayMergedServices(®)
|
||||
}
|
||||
|
||||
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
|
||||
func rebuildProjectList() {
|
||||
mergedProjectList = make([]string, 0, len(mergedServices))
|
||||
for name := range mergedServices {
|
||||
mergedProjectList = append(mergedProjectList, name)
|
||||
}
|
||||
sort.Strings(mergedProjectList)
|
||||
}
|
||||
|
||||
var cachedAllScopes map[string][]string
|
||||
|
||||
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
|
||||
@@ -226,7 +158,11 @@ func CollectAllScopesFromMeta(identity string) []string {
|
||||
// It returns data from the merged registry (embedded + cached remote overlay).
|
||||
func LoadFromMeta(project string) map[string]interface{} {
|
||||
Init()
|
||||
return mergedServices[project]
|
||||
svc, ok := typedServiceByName(project)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ServiceToMap(svc)
|
||||
}
|
||||
|
||||
// ListFromMetaProjects lists available service project names (sorted).
|
||||
@@ -234,7 +170,7 @@ func LoadFromMeta(project string) map[string]interface{} {
|
||||
//go:noinline
|
||||
func ListFromMetaProjects() []string {
|
||||
Init()
|
||||
return mergedProjectList
|
||||
return typedServiceNames()
|
||||
}
|
||||
|
||||
// DefaultScopeScore is the score assigned to scopes not in the priorities table.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed meta_data*.json
|
||||
var metaFS embed.FS
|
||||
|
||||
//go:embed meta_data_default.json
|
||||
var embeddedMetaDataDefaultJSON []byte
|
||||
|
||||
func init() {
|
||||
if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 {
|
||||
embeddedMetaJSON = data
|
||||
} else {
|
||||
embeddedMetaJSON = embeddedMetaDataDefaultJSON
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.0.0","services":[]}
|
||||
99
internal/registry/metaschema/types.go
Normal file
99
internal/registry/metaschema/types.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package metaschema defines the typed shape of the command-spec registry
|
||||
// (meta_data.json). The embedded baseline is emitted as static Go data in
|
||||
// package metastatic (no runtime JSON parse, no startup allocation); the remote
|
||||
// overlay is decoded into these same types at runtime.
|
||||
//
|
||||
// All container fields are slices (never maps): a package-level slice literal is
|
||||
// laid out in the binary's data section and costs zero heap allocation at
|
||||
// startup, whereas a map literal builds an hmap at init time. Map keys from the
|
||||
// JSON (resource/method/field names) are preserved in the Name field.
|
||||
package metaschema
|
||||
|
||||
// Registry is the top level of meta_data.json: {version, services:[...]}.
|
||||
type Registry struct {
|
||||
Version string
|
||||
Services []Service
|
||||
}
|
||||
|
||||
// Service is one API domain (e.g. "im", "calendar").
|
||||
type Service struct {
|
||||
Name string
|
||||
Version string
|
||||
Title string
|
||||
Description string
|
||||
ServicePath string
|
||||
Resources []Resource // JSON "resources" map, keyed by Resource.Name
|
||||
}
|
||||
|
||||
// Resource groups methods under a service (e.g. "messages").
|
||||
type Resource struct {
|
||||
Name string
|
||||
Methods []Method // JSON "methods" map, keyed by Method.Name
|
||||
}
|
||||
|
||||
// Method is a single API call.
|
||||
type Method struct {
|
||||
Name string // JSON map key
|
||||
ID string
|
||||
Path string
|
||||
HTTPMethod string
|
||||
Description string
|
||||
Risk string
|
||||
DocURL string
|
||||
Danger bool
|
||||
Scopes []string
|
||||
AccessTokens []string
|
||||
ParameterOrder []string
|
||||
RequiredScopes []string
|
||||
Parameters []Field // JSON "parameters" map, keyed by Field.Name
|
||||
RequestBody []Field // JSON "requestBody" map
|
||||
ResponseBody []Field // JSON "responseBody" map
|
||||
Affordance *Affordance // optional AI-facing usage overlay; nil on most methods
|
||||
}
|
||||
|
||||
// Field is one parameter / request-body / response-body entry. Nested object
|
||||
// fields recurse via Properties.
|
||||
type Field struct {
|
||||
Name string // JSON map key
|
||||
Type string
|
||||
Location string
|
||||
Description string
|
||||
Default string
|
||||
Example string
|
||||
EnumName string
|
||||
Min string
|
||||
Max string
|
||||
Ref string
|
||||
Required bool
|
||||
Options []Option
|
||||
Enum []string
|
||||
Annotations []string
|
||||
Properties []Field
|
||||
}
|
||||
|
||||
// Option is one allowed value for a field with an enum-like option list.
|
||||
type Option struct {
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Affordance is the optional AI-facing usage overlay for a method, surfaced in
|
||||
// the schema envelope as _meta.affordance. Absent (nil) on most methods; it is
|
||||
// authored upstream in registry-config.yaml and merged into meta_data.json.
|
||||
type Affordance struct {
|
||||
UseWhen []string
|
||||
DoNotUseWhen []string
|
||||
Prerequisites []string
|
||||
Examples []AffordanceExample
|
||||
Related []string
|
||||
}
|
||||
|
||||
// AffordanceExample is one ready-to-run example: a one-line description plus a
|
||||
// complete lark-cli command string.
|
||||
type AffordanceExample struct {
|
||||
Description string
|
||||
Command string
|
||||
}
|
||||
255
internal/registry/metastatic/gen.go
Normal file
255
internal/registry/metastatic/gen.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build ignore
|
||||
|
||||
// Command gen reads internal/registry/meta_data.json and emits
|
||||
// meta_data_gen.go: the embedded command spec as a single static
|
||||
// metaschema.Registry literal (zero runtime JSON parse, zero startup heap
|
||||
// allocation). Run via: go run internal/registry/metastatic/gen.go
|
||||
//
|
||||
// Maps in the JSON (resources/methods/fields) are emitted as slices sorted by
|
||||
// key so generation is deterministic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
inPath = "internal/registry/meta_data.json"
|
||||
outPath = "internal/registry/metastatic/meta_data_gen.go"
|
||||
)
|
||||
|
||||
func gs(m map[string]any, k string) string {
|
||||
if v, ok := m[k].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func gb(m map[string]any, k string) bool {
|
||||
if v, ok := m[k].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
func gss(m map[string]any, k string) []string {
|
||||
raw, _ := m[k].([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func gm(m map[string]any, k string) map[string]any {
|
||||
if v, ok := m[k].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func emitStrSlice(b *strings.Builder, name string, vs []string) {
|
||||
if len(vs) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s: []string{", name)
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(b, "%q, ", v)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func emitOptions(b *strings.Builder, raw []any) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("Options: []metaschema.Option{")
|
||||
for _, e := range raw {
|
||||
o, _ := e.(map[string]any)
|
||||
fmt.Fprintf(b, "{Value: %q, Description: %q}, ", gs(o, "value"), gs(o, "description"))
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
// emitFields emits a metaschema.Field slice from a JSON map[fieldName]fieldSpec.
|
||||
func emitFields(b *strings.Builder, label string, fm map[string]any) {
|
||||
if len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s: []metaschema.Field{\n", label)
|
||||
for _, name := range sortedKeys(fm) {
|
||||
f, _ := fm[name].(map[string]any)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(b, "Name: %q, ", name)
|
||||
for _, kv := range []struct{ k, field string }{
|
||||
{"type", "Type"}, {"location", "Location"}, {"description", "Description"},
|
||||
{"default", "Default"}, {"example", "Example"}, {"enumName", "EnumName"},
|
||||
{"min", "Min"}, {"max", "Max"}, {"ref", "Ref"},
|
||||
} {
|
||||
if v := gs(f, kv.k); v != "" {
|
||||
fmt.Fprintf(b, "%s: %q, ", kv.field, v)
|
||||
}
|
||||
}
|
||||
if gb(f, "required") {
|
||||
b.WriteString("Required: true, ")
|
||||
}
|
||||
emitStrSlice(b, "Enum", gss(f, "enum"))
|
||||
emitStrSlice(b, "Annotations", gss(f, "annotations"))
|
||||
if opts, ok := f["options"].([]any); ok {
|
||||
emitOptions(b, opts)
|
||||
}
|
||||
if props := gm(f, "properties"); props != nil {
|
||||
emitFields(b, "Properties", props)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
// emitAffordance emits a metaschema.Affordance literal from a method's
|
||||
// "affordance" JSON object, or nothing when absent/empty.
|
||||
func emitAffordance(b *strings.Builder, raw map[string]any) {
|
||||
if raw == nil {
|
||||
return
|
||||
}
|
||||
useWhen := gss(raw, "use_when")
|
||||
doNot := gss(raw, "do_not_use_when")
|
||||
prereq := gss(raw, "prerequisites")
|
||||
related := gss(raw, "related")
|
||||
examples, _ := raw["examples"].([]any)
|
||||
if len(useWhen) == 0 && len(doNot) == 0 && len(prereq) == 0 && len(related) == 0 && len(examples) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("Affordance: &metaschema.Affordance{")
|
||||
emitStrSlice(b, "UseWhen", useWhen)
|
||||
emitStrSlice(b, "DoNotUseWhen", doNot)
|
||||
emitStrSlice(b, "Prerequisites", prereq)
|
||||
if len(examples) > 0 {
|
||||
b.WriteString("Examples: []metaschema.AffordanceExample{")
|
||||
for _, e := range examples {
|
||||
ex, _ := e.(map[string]any)
|
||||
fmt.Fprintf(b, "{Description: %q, Command: %q}, ", gs(ex, "description"), gs(ex, "command"))
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
emitStrSlice(b, "Related", related)
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func emitMethods(b *strings.Builder, mm map[string]any) {
|
||||
b.WriteString("Methods: []metaschema.Method{\n")
|
||||
for _, name := range sortedKeys(mm) {
|
||||
m, _ := mm[name].(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(b, "Name: %q, ID: %q, Path: %q, HTTPMethod: %q, Description: %q, ",
|
||||
name, gs(m, "id"), gs(m, "path"), gs(m, "httpMethod"), gs(m, "description"))
|
||||
if v := gs(m, "risk"); v != "" {
|
||||
fmt.Fprintf(b, "Risk: %q, ", v)
|
||||
}
|
||||
if v := gs(m, "docUrl"); v != "" {
|
||||
fmt.Fprintf(b, "DocURL: %q, ", v)
|
||||
}
|
||||
if gb(m, "danger") {
|
||||
b.WriteString("Danger: true, ")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
emitStrSlice(b, "Scopes", gss(m, "scopes"))
|
||||
emitStrSlice(b, "AccessTokens", gss(m, "accessTokens"))
|
||||
emitStrSlice(b, "ParameterOrder", gss(m, "parameterOrder"))
|
||||
emitStrSlice(b, "RequiredScopes", gss(m, "requiredScopes"))
|
||||
emitFields(b, "Parameters", gm(m, "parameters"))
|
||||
emitFields(b, "RequestBody", gm(m, "requestBody"))
|
||||
emitFields(b, "ResponseBody", gm(m, "responseBody"))
|
||||
emitAffordance(b, gm(m, "affordance"))
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
data, err := os.ReadFile(inPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "read:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var reg map[string]any
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unmarshal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated from meta_data.json by gen.go. DO NOT EDIT.\n")
|
||||
b.WriteString("// Gitignored; produced at build time by `make fetch_meta`.\n\n")
|
||||
b.WriteString("package metastatic\n\n")
|
||||
b.WriteString("import \"github.com/larksuite/cli/internal/registry/metaschema\"\n\n")
|
||||
b.WriteString("// registryData holds the command spec as static Go data. It is a\n")
|
||||
b.WriteString("// package-level var, so its backing arrays live in the binary's static\n")
|
||||
b.WriteString("// section (zero heap alloc on read). init() wires it into the Registry\n")
|
||||
b.WriteString("// declared by stub.go with a single struct-header copy. No build tag is\n")
|
||||
b.WriteString("// needed: when this generated file is absent (fresh checkout) stub.go's\n")
|
||||
b.WriteString("// empty Registry stands alone; when present, init() augments it.\n")
|
||||
b.WriteString("var registryData = metaschema.Registry{\n")
|
||||
fmt.Fprintf(&b, "Version: %q,\n", gs(reg, "version"))
|
||||
b.WriteString("Services: []metaschema.Service{\n")
|
||||
svcs, _ := reg["services"].([]any)
|
||||
for _, sv := range svcs {
|
||||
s, _ := sv.(map[string]any)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(&b, "Name: %q, Version: %q, Title: %q, Description: %q, ServicePath: %q,\n",
|
||||
gs(s, "name"), gs(s, "version"), gs(s, "title"), gs(s, "description"), gs(s, "servicePath"))
|
||||
b.WriteString("Resources: []metaschema.Resource{\n")
|
||||
res := gm(s, "resources")
|
||||
for _, rname := range sortedKeys(res) {
|
||||
r, _ := res[rname].(map[string]any)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "{Name: %q,\n", rname)
|
||||
emitMethods(&b, gm(r, "methods"))
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n") // Resources
|
||||
b.WriteString("},\n") // Service
|
||||
}
|
||||
b.WriteString("},\n") // Services
|
||||
b.WriteString("}\n\n") // registryData literal
|
||||
b.WriteString("func init() { Registry = registryData }\n")
|
||||
|
||||
src, err := format.Source([]byte(b.String()))
|
||||
if err != nil {
|
||||
// Write unformatted for debugging, then fail.
|
||||
_ = os.WriteFile(outPath+".broken", []byte(b.String()), 0644)
|
||||
fmt.Fprintln(os.Stderr, "gofmt:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(outPath, src, 0644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d services, %d bytes)\n", outPath, len(svcs), len(src))
|
||||
}
|
||||
15
internal/registry/metastatic/stub.go
Normal file
15
internal/registry/metastatic/stub.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package metastatic
|
||||
|
||||
import "github.com/larksuite/cli/internal/registry/metaschema"
|
||||
|
||||
// Registry is the command spec as static Go data. It is declared here (zero
|
||||
// value) so the package always compiles, and populated by meta_data_gen.go's
|
||||
// init() when that generated file is present. On a fresh checkout the generated
|
||||
// file is absent — it is gitignored and produced at build time by
|
||||
// `make gen_meta` — so Registry stays empty. This keeps the "heavy spec is
|
||||
// never committed, only generated" model, now without a build tag: the
|
||||
// generated file augments this one rather than replacing it under a tag.
|
||||
var Registry = metaschema.Registry{}
|
||||
90
internal/registry/metastatic_validate_test.go
Normal file
90
internal/registry/metastatic_validate_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Validation for the static-meta registry: the generated metastatic.Registry is
|
||||
// the sole embedded baseline (no JSON parsed at runtime), and a deep read of it
|
||||
// allocates nothing. The data is generated from meta_data.json at build time
|
||||
// (`make fetch_meta`) and is gitignored, so these tests skip on a bare checkout
|
||||
// where it has not been generated yet.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
)
|
||||
|
||||
func countFieldsStatic(fs []metaschema.Field) int {
|
||||
n := 0
|
||||
for _, f := range fs {
|
||||
n++
|
||||
n += countFieldsStatic(f.Properties)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countStatic() (svc, res, meth, fld int) {
|
||||
svc = len(metastatic.Registry.Services)
|
||||
for _, s := range metastatic.Registry.Services {
|
||||
for _, r := range s.Resources {
|
||||
res++
|
||||
for _, m := range r.Methods {
|
||||
meth++
|
||||
fld += countFieldsStatic(m.Parameters) + countFieldsStatic(m.RequestBody) + countFieldsStatic(m.ResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestStaticRegistryPopulated checks the generated registry carries data. It
|
||||
// skips on a bare checkout where meta_data_gen.go has not been generated yet.
|
||||
func TestStaticRegistryPopulated(t *testing.T) {
|
||||
if len(metastatic.Registry.Services) == 0 {
|
||||
t.Skip("static registry empty; run `make fetch_meta` to generate it")
|
||||
}
|
||||
svc, res, meth, fld := countStatic()
|
||||
t.Logf("static: services=%d resources=%d methods=%d fields=%d", svc, res, meth, fld)
|
||||
if svc == 0 || res == 0 || meth == 0 || fld == 0 {
|
||||
t.Fatalf("static registry incomplete: svc=%d res=%d meth=%d fld=%d", svc, res, meth, fld)
|
||||
}
|
||||
if metastatic.Registry.Version == "" {
|
||||
t.Error("static registry has empty Version")
|
||||
}
|
||||
}
|
||||
|
||||
var sinkInt int
|
||||
|
||||
// --- zero-alloc: a deep read of the static registry must allocate nothing ---
|
||||
|
||||
func deepReadStatic() int {
|
||||
n := 0
|
||||
for _, s := range metastatic.Registry.Services {
|
||||
n += len(s.Name)
|
||||
for _, r := range s.Resources {
|
||||
for _, m := range r.Methods {
|
||||
n += len(m.ID) + len(m.Scopes) + countFieldsStatic(m.Parameters) + countFieldsStatic(m.ResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStaticReadZeroAlloc(t *testing.T) {
|
||||
if len(metastatic.Registry.Services) == 0 {
|
||||
t.Skip("static registry empty; run `make fetch_meta` to generate it")
|
||||
}
|
||||
avg := testing.AllocsPerRun(50, func() { sinkInt = deepReadStatic() })
|
||||
t.Logf("static deep-read: %.1f allocs/op", avg)
|
||||
if avg > 0 {
|
||||
t.Errorf("static read allocates %.1f/op, want 0 (data should be in the binary, not heap)", avg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadStaticRegistry(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sinkInt = deepReadStatic()
|
||||
}
|
||||
}
|
||||
@@ -147,22 +147,6 @@ func saveCacheMeta(meta CacheMeta) error {
|
||||
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
|
||||
}
|
||||
|
||||
func loadCachedMerged() (*MergedRegistry, error) {
|
||||
path := cachePath()
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Cache corrupted — remove it so next run triggers a fresh fetch
|
||||
vfs.Remove(path)
|
||||
vfs.Remove(cacheMetaPath())
|
||||
return nil, err
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
@@ -253,7 +237,7 @@ func doSyncFetch() {
|
||||
Brand: string(configuredBrand),
|
||||
}
|
||||
_ = saveCachedMerged(data, meta)
|
||||
overlayMergedServices(reg)
|
||||
_ = loadCachedTyped()
|
||||
}
|
||||
|
||||
// --- background refresh ---
|
||||
@@ -308,15 +292,3 @@ func shouldRefresh(meta CacheMeta) bool {
|
||||
}
|
||||
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
|
||||
}
|
||||
|
||||
// overlayMergedServices merges remote services into the in-memory map.
|
||||
// Remote entries override embedded entries with the same name.
|
||||
func overlayMergedServices(reg *MergedRegistry) {
|
||||
for _, svc := range reg.Services {
|
||||
name, ok := svc["name"].(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
mergedServices[name] = svc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
)
|
||||
|
||||
// waitBackgroundRefresh blocks until any in-flight background refresh started by
|
||||
@@ -30,8 +32,7 @@ func resetInit() {
|
||||
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedProjectList = nil
|
||||
resetTyped()
|
||||
embeddedVersion = ""
|
||||
cachedAllScopes = nil
|
||||
cachedScopePriorities = nil
|
||||
@@ -55,16 +56,10 @@ func TestResetInitClearsEmbeddedVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
|
||||
// hasEmbeddedServices returns true if the static registry has services compiled
|
||||
// in (generated from meta_data.json at build time).
|
||||
func hasEmbeddedServices() bool {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return false
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(reg.Services) > 0
|
||||
return len(metastatic.Registry.Services) > 0
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
@@ -302,50 +297,36 @@ func TestMetaTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServices(t *testing.T) {
|
||||
func TestRemoteOverlayTyped(t *testing.T) {
|
||||
resetInit()
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
|
||||
setRemoteOverrides([]metaschema.Service{
|
||||
{Name: "existing", Version: "v2"},
|
||||
{Name: "brand_new", Version: "v1"},
|
||||
})
|
||||
|
||||
reg := &MergedRegistry{
|
||||
Services: []map[string]interface{}{
|
||||
{"name": "existing", "version": "v2"},
|
||||
{"name": "brand_new", "version": "v1"},
|
||||
},
|
||||
// override present
|
||||
if s, ok := typedServiceByName("existing"); !ok || s.Version != "v2" {
|
||||
t.Errorf("expected existing override v2, got %+v ok=%v", s, ok)
|
||||
}
|
||||
overlayMergedServices(reg)
|
||||
|
||||
// existing should be overridden
|
||||
if v := mergedServices["existing"]["version"].(string); v != "v2" {
|
||||
t.Errorf("expected existing to be overridden to v2, got %s", v)
|
||||
}
|
||||
// brand_new should be added
|
||||
if _, ok := mergedServices["brand_new"]; !ok {
|
||||
// new service added
|
||||
if _, ok := typedServiceByName("brand_new"); !ok {
|
||||
t.Error("expected brand_new to be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
func TestRemoteOverlayDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
resetInit()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
const leakedExisting = "test_isolation_existing_sentinel"
|
||||
const leakedOverlay = "test_isolation_overlay_sentinel"
|
||||
|
||||
mergedServices = map[string]map[string]interface{}{
|
||||
leakedExisting: {"name": leakedExisting, "version": "v1"},
|
||||
}
|
||||
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
|
||||
const leaked = "test_isolation_overlay_sentinel"
|
||||
setRemoteOverrides([]metaschema.Service{{Name: leaked, Version: "v1"}})
|
||||
|
||||
resetInit()
|
||||
Init()
|
||||
|
||||
if spec := LoadFromMeta(leakedExisting); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
|
||||
}
|
||||
if spec := LoadFromMeta(leakedOverlay); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
|
||||
if spec := LoadFromMeta(leaked); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,8 +406,8 @@ func TestCorruptedCache_SelfHeals(t *testing.T) {
|
||||
metaData, _ := json.Marshal(meta)
|
||||
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
|
||||
|
||||
// loadCachedMerged should fail and remove the corrupted files
|
||||
_, err := loadCachedMerged()
|
||||
// loadCachedTyped should fail and remove the corrupted files
|
||||
err := loadCachedTyped()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupted cache")
|
||||
}
|
||||
|
||||
579
internal/registry/typed.go
Normal file
579
internal/registry/typed.go
Normal file
@@ -0,0 +1,579 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// This file is the typed registry layer for the static-meta migration.
|
||||
//
|
||||
// - The embedded baseline is metastatic.Registry: static Go data laid out in
|
||||
// the binary at compile time (zero startup cost). It is empty on a fresh
|
||||
// checkout (stub.go) until the generated meta_data_gen.go is produced by
|
||||
// `make fetch_meta`; no build tag is involved.
|
||||
// - The remote overlay (~/.lark-cli/cache/remote_meta.json) is still fetched
|
||||
// and refreshed at runtime, decoded into the same typed shape, and merged
|
||||
// over the baseline as per-service overrides.
|
||||
//
|
||||
// Startup (command-tree build) reads these typed structs directly. Execution-
|
||||
// path consumers that still expect map[string]interface{} go through
|
||||
// ServiceToMap, which rebuilds one service's map lazily, on demand — never the
|
||||
// whole spec at startup.
|
||||
|
||||
var (
|
||||
typedMu sync.RWMutex
|
||||
remoteOverrides map[string]metaschema.Service // service name -> remote override
|
||||
typedNamesCache []string
|
||||
)
|
||||
|
||||
// resetTyped clears the typed overlay state (test/teardown helper).
|
||||
func resetTyped() {
|
||||
typedMu.Lock()
|
||||
defer typedMu.Unlock()
|
||||
remoteOverrides = nil
|
||||
typedNamesCache = nil
|
||||
}
|
||||
|
||||
// baselineServices returns the embedded baseline service specs: the static
|
||||
// compile-time data in metastatic.Registry (zero parse, zero alloc). It is
|
||||
// empty only on a fresh checkout where meta_data_gen.go has not been generated
|
||||
// yet (see stub.go).
|
||||
var (
|
||||
baselineOnce sync.Once
|
||||
baselineSvcs []metaschema.Service
|
||||
baselineVer string
|
||||
)
|
||||
|
||||
func loadBaseline() {
|
||||
baselineOnce.Do(func() {
|
||||
baselineSvcs = metastatic.Registry.Services
|
||||
baselineVer = metastatic.Registry.Version
|
||||
})
|
||||
}
|
||||
|
||||
func baselineServices() []metaschema.Service {
|
||||
loadBaseline()
|
||||
return baselineSvcs
|
||||
}
|
||||
|
||||
func baselineVersion() string {
|
||||
loadBaseline()
|
||||
return baselineVer
|
||||
}
|
||||
|
||||
// baselineServiceByName returns the embedded baseline service spec by name.
|
||||
func baselineServiceByName(name string) (metaschema.Service, bool) {
|
||||
svcs := baselineServices()
|
||||
for i := range svcs {
|
||||
if svcs[i].Name == name {
|
||||
return svcs[i], true
|
||||
}
|
||||
}
|
||||
return metaschema.Service{}, false
|
||||
}
|
||||
|
||||
// typedServiceByName returns the effective typed spec for a service: the remote
|
||||
// override if present, otherwise the static baseline.
|
||||
func typedServiceByName(name string) (metaschema.Service, bool) {
|
||||
typedMu.RLock()
|
||||
if s, ok := remoteOverrides[name]; ok {
|
||||
typedMu.RUnlock()
|
||||
return s, true
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
return baselineServiceByName(name)
|
||||
}
|
||||
|
||||
// typedServiceNames returns all effective service names (baseline + remote
|
||||
// additions), sorted. Cached until the overlay changes.
|
||||
func typedServiceNames() []string {
|
||||
typedMu.RLock()
|
||||
if typedNamesCache != nil {
|
||||
out := typedNamesCache
|
||||
typedMu.RUnlock()
|
||||
return out
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, s := range baselineServices() {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
typedMu.RLock()
|
||||
for name := range remoteOverrides {
|
||||
seen[name] = true
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
typedMu.Lock()
|
||||
typedNamesCache = names
|
||||
typedMu.Unlock()
|
||||
return names
|
||||
}
|
||||
|
||||
// setRemoteOverrides installs the parsed remote overlay (called from Init).
|
||||
func setRemoteOverrides(svcs []metaschema.Service) {
|
||||
typedMu.Lock()
|
||||
defer typedMu.Unlock()
|
||||
if remoteOverrides == nil {
|
||||
remoteOverrides = make(map[string]metaschema.Service, len(svcs))
|
||||
}
|
||||
for _, s := range svcs {
|
||||
remoteOverrides[s.Name] = s
|
||||
}
|
||||
typedNamesCache = nil
|
||||
}
|
||||
|
||||
// TypedService returns the effective typed spec for a service (remote override
|
||||
// or static baseline). Public accessor for the command-tree builder.
|
||||
func TypedService(name string) (metaschema.Service, bool) {
|
||||
Init()
|
||||
return typedServiceByName(name)
|
||||
}
|
||||
|
||||
// TypedServices returns all effective service specs, sorted by name. Reading
|
||||
// these builds nothing on the heap (static data); the remote overlay, if any,
|
||||
// was allocated once at Init.
|
||||
func TypedServices() []metaschema.Service {
|
||||
Init()
|
||||
names := typedServiceNames()
|
||||
out := make([]metaschema.Service, 0, len(names))
|
||||
for _, n := range names {
|
||||
if s, ok := typedServiceByName(n); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasTypedData reports whether any typed spec is available (static baseline or
|
||||
// remote overlay). False only when the static registry has not been generated
|
||||
// (fresh checkout) and there is no cache.
|
||||
func hasTypedData() bool {
|
||||
if len(baselineServices()) > 0 {
|
||||
return true
|
||||
}
|
||||
typedMu.RLock()
|
||||
defer typedMu.RUnlock()
|
||||
return len(remoteOverrides) > 0
|
||||
}
|
||||
|
||||
// loadCachedTyped reads the on-disk remote cache, decodes it into the typed
|
||||
// shape, and installs it as the remote overlay (typed replacement for the old
|
||||
// map-based loadCachedMerged + overlay).
|
||||
func loadCachedTyped() error {
|
||||
data, err := vfs.ReadFile(cachePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reg wireRegistry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Cache corrupted — remove it so the next run triggers a fresh fetch.
|
||||
_ = vfs.Remove(cachePath())
|
||||
_ = vfs.Remove(cacheMetaPath())
|
||||
return err
|
||||
}
|
||||
svcs := make([]metaschema.Service, 0, len(reg.Services))
|
||||
for _, ws := range reg.Services {
|
||||
svcs = append(svcs, wireToService(ws))
|
||||
}
|
||||
setRemoteOverrides(svcs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- typed -> map[string]interface{} shim (lazy, per service, execution-path) ---
|
||||
|
||||
func strList(ss []string) []interface{} {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]interface{}, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fieldToMap(f metaschema.Field) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
put := func(k, v string) {
|
||||
if v != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
put("type", f.Type)
|
||||
put("location", f.Location)
|
||||
put("description", f.Description)
|
||||
put("default", f.Default)
|
||||
put("example", f.Example)
|
||||
put("enumName", f.EnumName)
|
||||
put("min", f.Min)
|
||||
put("max", f.Max)
|
||||
put("ref", f.Ref)
|
||||
if f.Required {
|
||||
m["required"] = true
|
||||
}
|
||||
if v := strList(f.Enum); v != nil {
|
||||
m["enum"] = v
|
||||
}
|
||||
if v := strList(f.Annotations); v != nil {
|
||||
m["annotations"] = v
|
||||
}
|
||||
if len(f.Options) > 0 {
|
||||
opts := make([]interface{}, len(f.Options))
|
||||
for i, o := range f.Options {
|
||||
opts[i] = map[string]interface{}{"value": o.Value, "description": o.Description}
|
||||
}
|
||||
m["options"] = opts
|
||||
}
|
||||
if len(f.Properties) > 0 {
|
||||
m["properties"] = fieldsToMap(f.Properties)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func fieldsToMap(fs []metaschema.Field) map[string]interface{} {
|
||||
if len(fs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]interface{}, len(fs))
|
||||
for _, f := range fs {
|
||||
m[f.Name] = fieldToMap(f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// affordanceToMap rebuilds the JSON-shaped affordance object (snake_case keys)
|
||||
// so the schema assembler's parseAffordance(method["affordance"]) keeps working
|
||||
// through the typed registry. Returns nil when the overlay carries nothing.
|
||||
func affordanceToMap(a *metaschema.Affordance) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if v := strList(a.UseWhen); v != nil {
|
||||
m["use_when"] = v
|
||||
}
|
||||
if v := strList(a.DoNotUseWhen); v != nil {
|
||||
m["do_not_use_when"] = v
|
||||
}
|
||||
if v := strList(a.Prerequisites); v != nil {
|
||||
m["prerequisites"] = v
|
||||
}
|
||||
if len(a.Examples) > 0 {
|
||||
ex := make([]interface{}, len(a.Examples))
|
||||
for i, e := range a.Examples {
|
||||
ex[i] = map[string]interface{}{"description": e.Description, "command": e.Command}
|
||||
}
|
||||
m["examples"] = ex
|
||||
}
|
||||
if v := strList(a.Related); v != nil {
|
||||
m["related"] = v
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func MethodToMap(mth metaschema.Method) map[string]interface{} {
|
||||
m := map[string]interface{}{
|
||||
"id": mth.ID,
|
||||
"path": mth.Path,
|
||||
"httpMethod": mth.HTTPMethod,
|
||||
"description": mth.Description,
|
||||
}
|
||||
if mth.Risk != "" {
|
||||
m["risk"] = mth.Risk
|
||||
}
|
||||
if mth.DocURL != "" {
|
||||
m["docUrl"] = mth.DocURL
|
||||
}
|
||||
if mth.Danger {
|
||||
m["danger"] = true
|
||||
}
|
||||
if v := strList(mth.Scopes); v != nil {
|
||||
m["scopes"] = v
|
||||
}
|
||||
if v := strList(mth.AccessTokens); v != nil {
|
||||
m["accessTokens"] = v
|
||||
}
|
||||
if v := strList(mth.ParameterOrder); v != nil {
|
||||
m["parameterOrder"] = v
|
||||
}
|
||||
if v := strList(mth.RequiredScopes); v != nil {
|
||||
m["requiredScopes"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.Parameters); v != nil {
|
||||
m["parameters"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.RequestBody); v != nil {
|
||||
m["requestBody"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.ResponseBody); v != nil {
|
||||
m["responseBody"] = v
|
||||
}
|
||||
if mth.Affordance != nil {
|
||||
if am := affordanceToMap(mth.Affordance); am != nil {
|
||||
m["affordance"] = am
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ServiceToMap rebuilds the JSON-shaped map[string]interface{} for one service,
|
||||
// so execution-path consumers (and method RunE) keep working unchanged.
|
||||
func ServiceToMap(s metaschema.Service) map[string]interface{} {
|
||||
resources := make(map[string]interface{}, len(s.Resources))
|
||||
for _, r := range s.Resources {
|
||||
methods := make(map[string]interface{}, len(r.Methods))
|
||||
for _, mth := range r.Methods {
|
||||
methods[mth.Name] = MethodToMap(mth)
|
||||
}
|
||||
resources[r.Name] = map[string]interface{}{"methods": methods}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"version": s.Version,
|
||||
"title": s.Title,
|
||||
"description": s.Description,
|
||||
"servicePath": s.ServicePath,
|
||||
"resources": resources,
|
||||
}
|
||||
}
|
||||
|
||||
// --- map[string]interface{} -> typed (for the map-based wrappers still used by
|
||||
// tests; production builds from typed directly) ---
|
||||
|
||||
func ifaceStrs(v interface{}) []string {
|
||||
raw, _ := v.([]interface{})
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedMapKeys(m map[string]interface{}) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func mapToField(name string, m map[string]interface{}) metaschema.Field {
|
||||
f := metaschema.Field{
|
||||
Name: name, Type: GetStrFromMap(m, "type"), Location: GetStrFromMap(m, "location"),
|
||||
Description: GetStrFromMap(m, "description"), Default: GetStrFromMap(m, "default"),
|
||||
Example: GetStrFromMap(m, "example"), EnumName: GetStrFromMap(m, "enumName"),
|
||||
Min: GetStrFromMap(m, "min"), Max: GetStrFromMap(m, "max"), Ref: GetStrFromMap(m, "ref"),
|
||||
Enum: ifaceStrs(m["enum"]), Annotations: ifaceStrs(m["annotations"]),
|
||||
}
|
||||
if b, ok := m["required"].(bool); ok {
|
||||
f.Required = b
|
||||
}
|
||||
if opts, ok := m["options"].([]interface{}); ok {
|
||||
for _, o := range opts {
|
||||
om, _ := o.(map[string]interface{})
|
||||
f.Options = append(f.Options, metaschema.Option{Value: GetStrFromMap(om, "value"), Description: GetStrFromMap(om, "description")})
|
||||
}
|
||||
}
|
||||
f.Properties = mapToFields(m["properties"])
|
||||
return f
|
||||
}
|
||||
|
||||
func mapToFields(v interface{}) []metaschema.Field {
|
||||
fm, _ := v.(map[string]interface{})
|
||||
if len(fm) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Field, 0, len(fm))
|
||||
for _, k := range sortedMapKeys(fm) {
|
||||
em, _ := fm[k].(map[string]interface{})
|
||||
out = append(out, mapToField(k, em))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func MapToMethod(name string, m map[string]interface{}) metaschema.Method {
|
||||
return metaschema.Method{
|
||||
Name: name, ID: GetStrFromMap(m, "id"), Path: GetStrFromMap(m, "path"),
|
||||
HTTPMethod: GetStrFromMap(m, "httpMethod"), Description: GetStrFromMap(m, "description"),
|
||||
Risk: GetStrFromMap(m, "risk"), DocURL: GetStrFromMap(m, "docUrl"),
|
||||
Danger: boolFromMap(m, "danger"),
|
||||
Scopes: ifaceStrs(m["scopes"]),
|
||||
AccessTokens: ifaceStrs(m["accessTokens"]),
|
||||
ParameterOrder: ifaceStrs(m["parameterOrder"]),
|
||||
RequiredScopes: ifaceStrs(m["requiredScopes"]),
|
||||
Parameters: mapToFields(m["parameters"]),
|
||||
RequestBody: mapToFields(m["requestBody"]),
|
||||
ResponseBody: mapToFields(m["responseBody"]),
|
||||
}
|
||||
}
|
||||
|
||||
func boolFromMap(m map[string]interface{}, k string) bool {
|
||||
b, _ := m[k].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func MapToResources(v interface{}) []metaschema.Resource {
|
||||
rm, _ := v.(map[string]interface{})
|
||||
if len(rm) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Resource, 0, len(rm))
|
||||
for _, rk := range sortedMapKeys(rm) {
|
||||
res, _ := rm[rk].(map[string]interface{})
|
||||
mm, _ := res["methods"].(map[string]interface{})
|
||||
methods := make([]metaschema.Method, 0, len(mm))
|
||||
for _, mk := range sortedMapKeys(mm) {
|
||||
methodMap, _ := mm[mk].(map[string]interface{})
|
||||
methods = append(methods, MapToMethod(mk, methodMap))
|
||||
}
|
||||
out = append(out, metaschema.Resource{Name: rk, Methods: methods})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MapToService converts a JSON-shaped service spec (with embedded "resources")
|
||||
// into the typed form.
|
||||
func MapToService(spec map[string]interface{}) metaschema.Service {
|
||||
return metaschema.Service{
|
||||
Name: GetStrFromMap(spec, "name"), Version: GetStrFromMap(spec, "version"),
|
||||
Title: GetStrFromMap(spec, "title"), Description: GetStrFromMap(spec, "description"),
|
||||
ServicePath: GetStrFromMap(spec, "servicePath"), Resources: MapToResources(spec["resources"]),
|
||||
}
|
||||
}
|
||||
|
||||
// --- remote JSON (wire) -> typed ---
|
||||
|
||||
type wireRegistry struct {
|
||||
Version string `json:"version"`
|
||||
Services []wireService `json:"services"`
|
||||
}
|
||||
|
||||
type wireService struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ServicePath string `json:"servicePath"`
|
||||
Resources map[string]wireResource `json:"resources"`
|
||||
}
|
||||
|
||||
type wireResource struct {
|
||||
Methods map[string]wireMethod `json:"methods"`
|
||||
}
|
||||
|
||||
type wireMethod struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
HTTPMethod string `json:"httpMethod"`
|
||||
Description string `json:"description"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"docUrl"`
|
||||
Danger bool `json:"danger"`
|
||||
Scopes []string `json:"scopes"`
|
||||
AccessTokens []string `json:"accessTokens"`
|
||||
ParameterOrder []string `json:"parameterOrder"`
|
||||
RequiredScopes []string `json:"requiredScopes"`
|
||||
Parameters map[string]wireField `json:"parameters"`
|
||||
RequestBody map[string]wireField `json:"requestBody"`
|
||||
ResponseBody map[string]wireField `json:"responseBody"`
|
||||
}
|
||||
|
||||
type wireField struct {
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default"`
|
||||
Example string `json:"example"`
|
||||
EnumName string `json:"enumName"`
|
||||
Min string `json:"min"`
|
||||
Max string `json:"max"`
|
||||
Ref string `json:"ref"`
|
||||
Required bool `json:"required"`
|
||||
Options []metaschema.Option `json:"options"`
|
||||
Enum []string `json:"enum"`
|
||||
Annotations []string `json:"annotations"`
|
||||
Properties map[string]wireField `json:"properties"`
|
||||
}
|
||||
|
||||
func sortedFieldKeys(m map[string]wireField) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func wireFields(m map[string]wireField) []metaschema.Field {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Field, 0, len(m))
|
||||
for _, name := range sortedFieldKeys(m) {
|
||||
wf := m[name]
|
||||
out = append(out, metaschema.Field{
|
||||
Name: name, Type: wf.Type, Location: wf.Location, Description: wf.Description,
|
||||
Default: wf.Default, Example: wf.Example, EnumName: wf.EnumName,
|
||||
Min: wf.Min, Max: wf.Max, Ref: wf.Ref, Required: wf.Required,
|
||||
Options: wf.Options, Enum: wf.Enum, Annotations: wf.Annotations,
|
||||
Properties: wireFields(wf.Properties),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wireToService(ws wireService) metaschema.Service {
|
||||
resKeys := make([]string, 0, len(ws.Resources))
|
||||
for k := range ws.Resources {
|
||||
resKeys = append(resKeys, k)
|
||||
}
|
||||
sort.Strings(resKeys)
|
||||
resources := make([]metaschema.Resource, 0, len(resKeys))
|
||||
for _, rk := range resKeys {
|
||||
wr := ws.Resources[rk]
|
||||
methKeys := make([]string, 0, len(wr.Methods))
|
||||
for k := range wr.Methods {
|
||||
methKeys = append(methKeys, k)
|
||||
}
|
||||
sort.Strings(methKeys)
|
||||
methods := make([]metaschema.Method, 0, len(methKeys))
|
||||
for _, mk := range methKeys {
|
||||
wm := wr.Methods[mk]
|
||||
methods = append(methods, metaschema.Method{
|
||||
Name: mk, ID: wm.ID, Path: wm.Path, HTTPMethod: wm.HTTPMethod,
|
||||
Description: wm.Description, Risk: wm.Risk, DocURL: wm.DocURL, Danger: wm.Danger,
|
||||
Scopes: wm.Scopes, AccessTokens: wm.AccessTokens,
|
||||
ParameterOrder: wm.ParameterOrder, RequiredScopes: wm.RequiredScopes,
|
||||
Parameters: wireFields(wm.Parameters), RequestBody: wireFields(wm.RequestBody),
|
||||
ResponseBody: wireFields(wm.ResponseBody),
|
||||
})
|
||||
}
|
||||
resources = append(resources, metaschema.Resource{Name: rk, Methods: methods})
|
||||
}
|
||||
return metaschema.Service{
|
||||
Name: ws.Name, Version: ws.Version, Title: ws.Title,
|
||||
Description: ws.Description, ServicePath: ws.ServicePath, Resources: resources,
|
||||
}
|
||||
}
|
||||
@@ -4,290 +4,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// MethodKeyOrder records the natural meta_data.json key order for one method's
|
||||
// parameters / requestBody / responseBody. Nested object key orders are stored
|
||||
// under NestedKeys, keyed by dotted path from the method root
|
||||
// (e.g. "responseBody.items.properties").
|
||||
type MethodKeyOrder struct {
|
||||
Parameters []string
|
||||
RequestBody []string
|
||||
ResponseBody []string
|
||||
NestedKeys map[string][]string
|
||||
}
|
||||
|
||||
var (
|
||||
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
|
||||
keyOrderInitOnce sync.Once
|
||||
)
|
||||
|
||||
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
|
||||
// or nil if the method is not in the embedded data (e.g. remote-cached).
|
||||
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
|
||||
keyOrderInitOnce.Do(buildKeyOrderIndex)
|
||||
if keyOrderIndex == nil {
|
||||
return nil
|
||||
}
|
||||
dotted := dottedPath(service, resourcePath, method)
|
||||
return keyOrderIndex[dotted]
|
||||
}
|
||||
|
||||
func dottedPath(service string, resourcePath []string, method string) string {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(service)
|
||||
for _, r := range resourcePath {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(r)
|
||||
}
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(method)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
|
||||
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
|
||||
// and recording each map's key insertion order via json.Decoder.Token().
|
||||
func buildKeyOrderIndex() {
|
||||
raw := registry.EmbeddedMetaJSON()
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
keyOrderIndex = make(map[string]*MethodKeyOrder)
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
// Top-level: { "services": [...], "version": "..." }
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
if key != "services" {
|
||||
skipValue(dec)
|
||||
continue
|
||||
}
|
||||
if !expectDelim(dec, '[') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
parseService(dec)
|
||||
}
|
||||
// closing ]
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
}
|
||||
|
||||
// parseService consumes one service object inside services[].
|
||||
// meta_data.json may emit "resources" before "name", so we first capture both
|
||||
// raw fields, then walk resources with the resolved service name.
|
||||
func parseService(dec *json.Decoder) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
var serviceName string
|
||||
var resourcesRaw json.RawMessage
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "name":
|
||||
tok, _ := dec.Token()
|
||||
if s, ok := tok.(string); ok {
|
||||
serviceName = s
|
||||
}
|
||||
case "resources":
|
||||
if err := dec.Decode(&resourcesRaw); err != nil {
|
||||
skipValue(dec)
|
||||
}
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token() // closing }
|
||||
if serviceName != "" && len(resourcesRaw) > 0 {
|
||||
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
|
||||
parseResources(subDec, serviceName, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// parseResources walks a resources map (resName -> resource object).
|
||||
// resourcePath is the accumulated path of parent resources (for nested resources).
|
||||
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
resName, _ := readKey(dec)
|
||||
parseResourceObj(dec, service, append(resourcePath, resName))
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
|
||||
// recurse into nested resources via "resources" key if present.
|
||||
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "methods":
|
||||
parseMethods(dec, service, resourcePath)
|
||||
case "resources":
|
||||
parseResources(dec, service, resourcePath)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethods consumes the methods map (methodName -> method object).
|
||||
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
methodName, _ := readKey(dec)
|
||||
mko := parseMethod(dec)
|
||||
dotted := dottedPath(service, resourcePath, methodName)
|
||||
keyOrderIndex[dotted] = mko
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethod consumes one method object and records key orders.
|
||||
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
|
||||
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
|
||||
if !expectDelim(dec, '{') {
|
||||
return mko
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "parameters":
|
||||
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
|
||||
case "requestBody":
|
||||
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
|
||||
case "responseBody":
|
||||
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
return mko
|
||||
}
|
||||
|
||||
// recordObjectKeysRecursive consumes an object and records the top-level key
|
||||
// order. It also recurses into each child's "properties" submap, recording
|
||||
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
|
||||
// in order.
|
||||
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
|
||||
if !expectDelim(dec, '{') {
|
||||
return nil
|
||||
}
|
||||
var order []string
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
order = append(order, key)
|
||||
// Each child value is itself an object; we want its nested "properties" order if present.
|
||||
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
if prefix != "" && len(order) > 0 {
|
||||
nestedKeys[prefix] = order
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
|
||||
// if it contains "properties": {...}, recursively records that submap's order.
|
||||
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok || delim != '{' {
|
||||
// Not an object — skip the rest of the value
|
||||
skipValueAfterToken(dec, tok)
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
fieldKey, _ := readKey(dec)
|
||||
if fieldKey == "properties" {
|
||||
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
|
||||
} else {
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// --- json.Decoder helpers ---
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) bool {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
return ok && delim == want
|
||||
}
|
||||
|
||||
func readKey(dec *json.Decoder) (string, error) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, _ := tok.(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// skipValue consumes the next complete value (scalar, object, or array).
|
||||
func skipValue(dec *json.Decoder) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
skipValueAfterToken(dec, tok)
|
||||
}
|
||||
|
||||
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// We started inside a container of type `delim` ({ or [) and must eat
|
||||
// tokens until that container closes, tracking nested containers of any
|
||||
// kind. depth counts how many open containers we are currently inside.
|
||||
_ = delim
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if d, ok := t.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coerceLiteral converts a meta_data literal (default / enum / example) to
|
||||
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
||||
// meta_data stores every literal as a string, so without coercion an
|
||||
@@ -501,10 +225,6 @@ func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedP
|
||||
return op, required
|
||||
}
|
||||
|
||||
// currentMethodOrder is the per-method key-order context used by orderedKeys.
|
||||
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
|
||||
var currentMethodOrder *MethodKeyOrder
|
||||
|
||||
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
|
||||
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
|
||||
// or carries no populated subfields.
|
||||
@@ -611,8 +331,6 @@ func buildMeta(method map[string]interface{}) *Meta {
|
||||
// The params / data wrapping mirrors the CLI's actual flag layout:
|
||||
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
||||
// can pluck inputSchema.properties.params and pass it verbatim to --params.
|
||||
//
|
||||
// Caller must set currentMethodOrder for property-order preservation.
|
||||
func buildInputSchema(method map[string]interface{}) *InputSchema {
|
||||
is := &InputSchema{
|
||||
Type: "object",
|
||||
@@ -738,27 +456,11 @@ func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
||||
return os
|
||||
}
|
||||
|
||||
// assembleMu serializes AssembleEnvelope calls so that the package-level
|
||||
// currentMethodOrder pointer is safe for concurrent callers.
|
||||
var assembleMu sync.Mutex
|
||||
|
||||
// AssembleEnvelope is the main entry point: takes a service / resource path /
|
||||
// method name plus its meta_data spec, and produces a fully assembled MCP
|
||||
// envelope. Output is fully determined by inputs (same arguments → same
|
||||
// envelope), but assembly briefly publishes the per-method key-order context
|
||||
// through the package-level currentMethodOrder so orderedKeys can reach it
|
||||
// without threading it through every helper. assembleMu serializes that
|
||||
// publish, which is why concurrent callers are still safe — they queue
|
||||
// rather than run in parallel.
|
||||
//
|
||||
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
|
||||
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
|
||||
// envelope).
|
||||
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
|
||||
assembleMu.Lock()
|
||||
defer assembleMu.Unlock()
|
||||
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
name := serviceName
|
||||
for _, r := range resourcePath {
|
||||
name += " " + r
|
||||
@@ -836,35 +538,10 @@ func walkMethods(resources map[string]interface{}, parentPath []string,
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys returns the keys of raw in their meta_data natural order if
|
||||
// the current per-method key-order context has them recorded; otherwise
|
||||
// alphabetical fallback.
|
||||
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
|
||||
if currentMethodOrder != nil && nestedPath != "" {
|
||||
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
|
||||
// Filter to keys that actually exist in raw (defensive)
|
||||
out := make([]string, 0, len(order))
|
||||
seen := make(map[string]bool)
|
||||
for _, k := range order {
|
||||
if _, ok := raw[k]; ok {
|
||||
out = append(out, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
// Append any keys present in raw but missing from order (defensive),
|
||||
// alphabetically for determinism.
|
||||
var extra []string
|
||||
for k := range raw {
|
||||
if !seen[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
out = append(out, extra...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
// Fallback: alphabetical
|
||||
// orderedKeys returns the keys of raw in alphabetical order. Field display
|
||||
// order is not preserved: the schema envelope is consumed as a JSON Schema (MCP
|
||||
// tool spec), where object property order carries no meaning.
|
||||
func orderedKeys(raw map[string]interface{}, _ string) []string {
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
)
|
||||
|
||||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||||
// We only assert key-set membership, not absolute order — the upstream
|
||||
// meta_data API does not guarantee a stable JSON key sequence across
|
||||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||||
}
|
||||
wantParams := map[string]bool{
|
||||
"message_id": true, "reaction_type": true, "page_token": true,
|
||||
"page_size": true, "user_id_type": true,
|
||||
}
|
||||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||||
}
|
||||
for _, k := range order.Parameters {
|
||||
if !wantParams[k] {
|
||||
t.Errorf("unexpected parameter key %q", k)
|
||||
}
|
||||
}
|
||||
// im.reactions.list 是 GET,没有 requestBody
|
||||
if len(order.RequestBody) != 0 {
|
||||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.images.create, got nil")
|
||||
}
|
||||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||||
}
|
||||
for _, k := range order.RequestBody {
|
||||
if !wantBody[k] {
|
||||
t.Errorf("unexpected requestBody key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||||
if order != nil {
|
||||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -313,16 +260,15 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||||
params.Properties.Order, mko.Parameters)
|
||||
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
|
||||
// tool spec) where object property order carries no meaning.
|
||||
if !sort.StringsAreSorted(params.Properties.Order) {
|
||||
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -382,9 +328,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
@@ -413,9 +356,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
if _, ok := is.Properties.Map["yes"]; ok {
|
||||
@@ -425,9 +365,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
|
||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
os := buildOutputSchema(method)
|
||||
|
||||
@@ -613,6 +550,45 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMeta_AffordanceThroughTypedRegistry guards the static-registry path:
|
||||
// a method's affordance must survive metaschema.Method -> registry.MethodToMap
|
||||
// -> buildMeta, so `schema --format json` keeps emitting _meta.affordance after
|
||||
// the embedded-JSON-to-typed-registry migration. Without typed-side support the
|
||||
// overlay is silently stripped whenever meta_data.json carries affordance.
|
||||
func TestBuildMeta_AffordanceThroughTypedRegistry(t *testing.T) {
|
||||
mth := metaschema.Method{
|
||||
Name: "primary",
|
||||
Affordance: &metaschema.Affordance{
|
||||
UseWhen: []string{"用户想拿到自己默认日历的 ID"},
|
||||
DoNotUseWhen: []string{"已经知道某个具体日历的 ID"},
|
||||
Prerequisites: []string{"user 身份登录"},
|
||||
Examples: []metaschema.AffordanceExample{
|
||||
{Description: "取主日历", Command: "lark-cli calendar calendars primary"},
|
||||
},
|
||||
Related: []string{"calendars.list", "calendars.get"},
|
||||
},
|
||||
}
|
||||
method := registry.MethodToMap(mth)
|
||||
m := buildMeta(method)
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("affordance dropped through the typed registry (MethodToMap -> buildMeta)")
|
||||
}
|
||||
a := m.Affordance
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "用户想拿到自己默认日历的 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.DoNotUseWhen) != 1 || len(a.Prerequisites) != 1 {
|
||||
t.Errorf("DoNotUseWhen=%v Prerequisites=%v", a.DoNotUseWhen, a.Prerequisites)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "取主日历" ||
|
||||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 2 {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
@@ -634,7 +610,6 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||
method := map[string]interface{}{}
|
||||
currentMethodOrder = nil
|
||||
os := buildOutputSchema(method)
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
|
||||
@@ -83,9 +83,13 @@ type AffordanceCase struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
||||
// It is used wherever JSON output must reflect meta_data.json's natural field
|
||||
// order rather than Go's default alphabetical map encoding.
|
||||
// OrderedProps is map[string]Property that emits its keys in Order on
|
||||
// MarshalJSON. Order is now populated alphabetically (see orderedKeys): the
|
||||
// schema envelope is an MCP tool spec / JSON Schema, where object property
|
||||
// order carries no meaning. The machinery that once preserved meta_data.json's
|
||||
// natural field order was removed with the static-registry migration; Order is
|
||||
// retained so MarshalJSON has one stable key sequence (and callers that leave
|
||||
// it empty fall back to alphabetical over Map).
|
||||
type OrderedProps struct {
|
||||
Order []string
|
||||
Map map[string]Property
|
||||
|
||||
@@ -15,14 +15,9 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
|
||||
@@ -16,14 +16,9 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
|
||||
@@ -27,11 +27,6 @@ import (
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
//
|
||||
// Files that do not import shortcuts/common are skipped: the legacy helpers
|
||||
// are methods on common.RuntimeContext, so a same-named method on another
|
||||
// receiver (for example the event domain's APIClient interface, whose
|
||||
// implementation classifies into typed errs.* errors) is not a legacy call.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
@@ -41,9 +36,6 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !importsPath(file, commonImportPath) {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
@@ -79,16 +71,3 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importsPath reports whether the file imports the given package path.
|
||||
func importsPath(file *ast.File, importPath string) bool {
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(imp.Path.Value, "`\"") == importPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -691,7 +691,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -813,8 +813,6 @@ func boom() error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -835,8 +833,6 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -857,8 +853,6 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -913,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -1012,7 +1006,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1082,23 +1076,3 @@ func boom() error {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
||||
// The event domain's APIClient interface has a same-named CallAPI method
|
||||
// whose implementation classifies into typed errs.* errors; without the
|
||||
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
||||
// helper and must not fire.
|
||||
src := `package vc
|
||||
|
||||
import "github.com/larksuite/cli/internal/event"
|
||||
|
||||
func boom(rt event.APIClient) error {
|
||||
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.49",
|
||||
"version": "1.0.48",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -6,6 +6,7 @@ OUT_DIR="$ROOT_DIR/.pkg-pr-new"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go).
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
|
||||
@@ -63,6 +63,19 @@ def fetch_remote(brand):
|
||||
return data
|
||||
|
||||
|
||||
def run_gen():
|
||||
"""Regenerate the static Go registry (metastatic/meta_data_gen.go) from
|
||||
meta_data.json. Run after every fetch so any caller that fetches also
|
||||
produces the sole build-time source of the embedded command tree — no build
|
||||
tag, no JSON embedded in the binary. Output is gitignored."""
|
||||
print("fetch-meta: generating static Go registry (metastatic/meta_data_gen.go)", file=sys.stderr)
|
||||
subprocess.run(
|
||||
["go", "run", "internal/registry/metastatic/gen.go"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
||||
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
|
||||
@@ -71,27 +84,29 @@ def main():
|
||||
help="force refresh from remote even if local file exists")
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.path.exists(OUT_PATH) and not args.force:
|
||||
if os.path.isfile(OUT_PATH):
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
if local.get("services"):
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
|
||||
return
|
||||
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
|
||||
else:
|
||||
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
|
||||
have_valid = False
|
||||
if os.path.isfile(OUT_PATH) and not args.force:
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
have_valid = bool(local.get("services"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
have_valid = False
|
||||
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
if have_valid:
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping fetch (use --force to re-fetch)", file=sys.stderr)
|
||||
else:
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
# Always (re)generate the static Go registry so every fetch also produces
|
||||
# the embedded command tree — the build-time replacement for the old
|
||||
# embedded meta_data.json.
|
||||
run_gen()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,8 +6,24 @@ package common
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ResolveOpenIDs expands the special identifier "me" to the current user's
|
||||
// open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName is
|
||||
// used in error messages to point the user at the offending CLI flag.
|
||||
//
|
||||
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
|
||||
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, output.ErrValidation("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveOpenIDsTyped expands the special identifier "me" to the current
|
||||
// user's open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName names
|
||||
|
||||
@@ -17,9 +17,9 @@ func resolveOpenIDsTestRuntime(userOpenID string) *RuntimeContext {
|
||||
return TestNewRuntimeContext(cmd, cfg)
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_Empty(t *testing.T) {
|
||||
func TestResolveOpenIDs_Empty(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", nil, rt)
|
||||
out, err := ResolveOpenIDs("--user-ids", nil, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -28,9 +28,21 @@ func TestResolveOpenIDsTyped_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
|
||||
func TestResolveOpenIDs_ExpandsMeAndDedups(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
|
||||
out, err := ResolveOpenIDs("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"ou_self", "ou_a"}
|
||||
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
|
||||
t.Fatalf("got %v, want %v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDs("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -40,11 +52,22 @@ func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_DedupIsCaseInsensitive(t *testing.T) {
|
||||
func TestResolveOpenIDs_MeWithoutLogin(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("")
|
||||
_, err := ResolveOpenIDs("--user-ids", []string{"me"}, rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--user-ids") {
|
||||
t.Fatalf("error should mention the offending flag name; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
// Same underlying open_id with three case variants — should collapse to
|
||||
// one entry, preserving the first-occurrence form.
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
|
||||
out, err := ResolveOpenIDs("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
@@ -40,6 +42,17 @@ func normalizeChatID(input string) (string, string) {
|
||||
return input, ""
|
||||
}
|
||||
|
||||
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
|
||||
//
|
||||
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
|
||||
func ValidateUserID(input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
|
||||
// param names the flag being validated (e.g. "--creator-ids") and is
|
||||
// recorded on the typed error.
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const contactFanoutRetryHint = "retry the command; if it persists, narrow --queries to a single term to isolate the failing input"
|
||||
|
||||
func contactInvalidResponseError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
func contactFanoutErrorSummary(err error) string {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Code >= 100 && p.Code < 600 {
|
||||
prefix := fmt.Sprintf("HTTP %d:", p.Code)
|
||||
body := strings.TrimSpace(strings.TrimPrefix(p.Message, prefix))
|
||||
msg := fmt.Sprintf("HTTP %d %s", p.Code, http.StatusText(p.Code))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, contactTruncateError(body, 200))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
if p.Code != 0 {
|
||||
return fmt.Sprintf("API %d: %s", p.Code, p.Message)
|
||||
}
|
||||
return p.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// contactFanoutAllFailedError builds the top-level error returned when every
|
||||
// fanout query fails. It mirrors the representative (first) failure's
|
||||
// classification — category, subtype, code, log_id, retryable, hint — so the
|
||||
// exit-code classifier still sees the real signal, while carrying the aggregate
|
||||
// message. The representative error is copied (never mutated) and kept as the
|
||||
// cause, so a single-query problem object is not rewritten into an aggregate one.
|
||||
func contactFanoutAllFailedError(err error, msg string) error {
|
||||
var (
|
||||
apiErr *errs.APIError
|
||||
netErr *errs.NetworkError
|
||||
intErr *errs.InternalError
|
||||
)
|
||||
switch {
|
||||
case errors.As(err, &apiErr):
|
||||
c := *apiErr
|
||||
c.Message = msg
|
||||
c.Cause = err
|
||||
return &c
|
||||
case errors.As(err, &netErr):
|
||||
c := *netErr
|
||||
c.Message = msg
|
||||
c.Cause = err
|
||||
return &c
|
||||
case errors.As(err, &intErr):
|
||||
c := *intErr
|
||||
c.Message = msg
|
||||
c.Cause = err
|
||||
return &c
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithHint(contactFanoutRetryHint).WithCause(err)
|
||||
}
|
||||
|
||||
func contactTruncateError(s string, maxRunes int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxRunes]) + "..."
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestContactFanoutErrorSummary_HTTPStatus(t *testing.T) {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkServer, `HTTP 503: {"reason":"upstream_unavailable"}`).
|
||||
WithCode(503).
|
||||
WithRetryable()
|
||||
|
||||
got := contactFanoutErrorSummary(err)
|
||||
if !strings.HasPrefix(got, "HTTP 503 Service Unavailable: ") {
|
||||
t.Fatalf("summary: got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "upstream_unavailable") {
|
||||
t.Fatalf("summary should include truncated body details, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactInvalidResponseError_TypedInternal(t *testing.T) {
|
||||
got := contactInvalidResponseError("decode contact response failed")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactFanoutAllFailedError_PreservesTypedProblem(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").
|
||||
WithCode(99991663).
|
||||
WithLogID("log-contact-1").
|
||||
WithRetryable()
|
||||
|
||||
got := contactFanoutAllFailedError(err, "all 2 queries failed; first: API 99991663: rate limit (query=\"alice\")")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
if p.Code != 99991663 || p.LogID != "log-contact-1" || !p.Retryable {
|
||||
t.Fatalf("problem metadata not preserved: %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Message, "all 2 queries failed") {
|
||||
t.Fatalf("problem message not decorated: %q", p.Message)
|
||||
}
|
||||
// The representative error must not be mutated: it stays a single-query
|
||||
// failure, while the aggregate is a distinct value carrying it as cause.
|
||||
if err.Message != "rate limit" {
|
||||
t.Fatalf("representative error message was mutated: %q", err.Message)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatalf("aggregate error should keep the representative failure as its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactFanoutAllFailedError_UntypedGetsActionableHint(t *testing.T) {
|
||||
got := contactFanoutAllFailedError(nil, "all 2 queries failed; first: internal error (query=\"alice\")")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "narrow --queries") {
|
||||
t.Fatalf("hint should guide recovery, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,7 @@ var ContactGetUser = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("user-id") == "" && runtime.IsBot() {
|
||||
return common.ValidationErrorf("bot identity cannot get current user info, specify --user-id").
|
||||
WithParam("--user-id")
|
||||
return common.FlagErrorf("bot identity cannot get current user info, specify --user-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -64,7 +63,7 @@ var ContactGetUser = common.Shortcut{
|
||||
|
||||
if userId == "" {
|
||||
// Current user
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/authen/v1/user_info", nil, nil)
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,7 +87,7 @@ var ContactGetUser = common.Shortcut{
|
||||
|
||||
if runtime.IsBot() {
|
||||
// Bot identity: GET /contact/v3/users/:user_id (full profile)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
|
||||
map[string]interface{}{"user_id_type": userIdType}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,7 +110,7 @@ var ContactGetUser = common.Shortcut{
|
||||
}
|
||||
|
||||
// User identity: POST /contact/v3/users/basic_batch (lightweight)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/contact/v3/users/basic_batch",
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch",
|
||||
map[string]interface{}{"user_id_type": userIdType},
|
||||
map[string]interface{}{"user_ids": []string{userId}})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestGetUser_BotCurrentUserValidationTyped(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validation.Param != "--user-id" {
|
||||
t.Fatalf("param: got %q, want --user-id", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_DryRunShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "current user",
|
||||
args: []string{"+get-user", "--dry-run", "--as", "user"},
|
||||
want: []string{"GET", "/authen/v1/user_info", "current_user"},
|
||||
},
|
||||
{
|
||||
name: "bot specific user",
|
||||
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "bot"},
|
||||
want: []string{"GET", "/contact/v3/users/ou_a", "ou_a", "open_id"},
|
||||
},
|
||||
{
|
||||
name: "user basic batch",
|
||||
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "user"},
|
||||
want: []string{"POST", "/contact/v3/users/basic_batch", "ou_a", "open_id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
if err := mountAndRun(t, ContactGetUser, tc.args, f, stdout); err != nil {
|
||||
t.Fatalf("dry-run: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range tc.want {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(want)) {
|
||||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_CurrentUserAPIFailureTyped(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/authen/v1/user_info",
|
||||
Body: map[string]interface{}{"code": 123456, "msg": "upstream rejected contact request"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected API error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Code != 123456 {
|
||||
t.Fatalf("code: got %d, want 123456", p.Code)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should stay empty on API failure, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_UserBasicBatchUsesTypedAPI(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/basic_batch?user_id_type=open_id",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"user_id": "ou_a", "name": "Alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--user-id", "ou_a", "--as", "user", "--format", "json"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stub.CapturedBody, []byte(`"ou_a"`)) {
|
||||
t.Fatalf("request body should include user id, got %s", string(stub.CapturedBody))
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"user"`)) {
|
||||
t.Fatalf("stdout should include user object, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -81,6 +80,12 @@ type searchUserAPIFilter struct {
|
||||
HasEnterpriseEmail bool `json:"has_enterprise_email,omitempty"`
|
||||
}
|
||||
|
||||
type searchUserAPIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *searchUserAPIData `json:"data"`
|
||||
}
|
||||
|
||||
type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
@@ -211,17 +216,19 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respData, err := decodeSearchUserAPIData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(apiResp.StatusCode, http.StatusText(apiResp.StatusCode), string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response failed", err.Error())
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return output.ErrAPI(resp.Code, resp.Msg, string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
@@ -238,20 +245,6 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeSearchUserAPIData(data map[string]interface{}) (*searchUserAPIData, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, contactInvalidResponseError("marshal search user response data failed").
|
||||
WithCause(err)
|
||||
}
|
||||
var out searchUserAPIData
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, contactInvalidResponseError("decode search user response data failed").
|
||||
WithCause(err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func isHumanReadableFormat(format string) bool {
|
||||
return format == "pretty" || format == "table"
|
||||
}
|
||||
@@ -380,74 +373,52 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
|
||||
|
||||
func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
if !hasAnySearchInput(runtime) {
|
||||
return common.ValidationErrorf(
|
||||
return common.FlagErrorf(
|
||||
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
).WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--user-ids", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--has-chatted", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--has-enterprise-email", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--exclude-external-users", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--left-organization", Reason: "required; specify at least one search input"},
|
||||
)
|
||||
}
|
||||
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
if queriesRaw != "" {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return common.ValidationErrorf("--query and --queries are mutually exclusive").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
|
||||
)
|
||||
return common.FlagErrorf("--query and --queries are mutually exclusive")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return common.ValidationErrorf("--user-ids and --queries are mutually exclusive").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--user-ids", Reason: "mutually exclusive with --queries"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --user-ids"},
|
||||
)
|
||||
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
|
||||
WithParam("--queries")
|
||||
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
|
||||
WithParam("--queries")
|
||||
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars).
|
||||
WithParam("--queries")
|
||||
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars).
|
||||
WithParam("--query")
|
||||
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
|
||||
}
|
||||
}
|
||||
|
||||
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
|
||||
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
|
||||
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return common.ValidationErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw).
|
||||
WithParam("--user-ids")
|
||||
return common.FlagErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw)
|
||||
}
|
||||
if len(ids) > maxSearchUserUserIDs {
|
||||
return common.ValidationErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs).
|
||||
WithParam("--user-ids")
|
||||
return common.FlagErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserIDTyped("--user-ids", id); err != nil {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -458,16 +429,15 @@ func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
// silent wrong-result bugs.
|
||||
for _, bf := range searchUserBoolFilters {
|
||||
if runtime.Cmd.Flags().Changed(bf.Flag) && !runtime.Bool(bf.Flag) {
|
||||
return common.ValidationErrorf(
|
||||
return common.FlagErrorf(
|
||||
"--%s: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
|
||||
bf.Flag,
|
||||
).WithParam("--" + bf.Flag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if n := runtime.Int("page-size"); n < 1 || n > maxSearchUserPageSize {
|
||||
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize).
|
||||
WithParam("--page-size")
|
||||
return common.FlagErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -503,7 +473,7 @@ func buildSearchUserBody(runtime *common.RuntimeContext) (*searchUserAPIRequest,
|
||||
hasFilter := false
|
||||
|
||||
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
|
||||
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
|
||||
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -46,7 +47,7 @@ type fanoutResult struct {
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
@@ -66,7 +67,7 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
// request; in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
|
||||
body := &searchUserAPIRequest{Query: query}
|
||||
@@ -81,29 +82,38 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
body := strings.TrimSpace(string(apiResp.RawBody))
|
||||
const maxBody = 200
|
||||
if len(body) > maxBody {
|
||||
body = body[:maxBody] + "..."
|
||||
}
|
||||
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, body)
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: msg,
|
||||
ErrCode: apiResp.StatusCode}
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
|
||||
}
|
||||
respData, err := decodeSearchUserAPIData(data)
|
||||
if err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
if resp.Code != 0 {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
|
||||
ErrCode: resp.Code}
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
|
||||
}
|
||||
|
||||
type fanoutUser struct {
|
||||
searchUser
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
@@ -136,7 +146,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErr error
|
||||
var firstErrCode int
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
@@ -148,7 +158,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErr = r.Err
|
||||
firstErrCode = r.ErrCode
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -159,7 +169,18 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
return nil, contactFanoutAllFailedError(firstErr, msg)
|
||||
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
|
||||
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
|
||||
// means success in the Lark protocol, so don't pretend it's an API error
|
||||
// when we have nothing structured to report.
|
||||
if firstErrCode != 0 {
|
||||
return nil, output.ErrAPI(firstErrCode, msg, "")
|
||||
}
|
||||
// No structured API code — the failure was transport, parse, panic, or
|
||||
// cancellation. Suggest the actionable next step rather than shipping
|
||||
// an empty hint that would leave the calling agent with nothing to do.
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
|
||||
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -15,10 +16,10 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -253,16 +254,6 @@ func TestRowFromItem_CrossTenantEmptyEmailNoPanic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectUsers_NilData(t *testing.T) {
|
||||
users, hasMore := projectUsers(nil, "", core.BrandFeishu)
|
||||
if users == nil {
|
||||
t.Fatalf("users should be an empty slice, not nil")
|
||||
}
|
||||
if len(users) != 0 || hasMore {
|
||||
t.Fatalf("projectUsers(nil): got users=%v hasMore=%v", users, hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSearchUser_AllEmpty_Errors(t *testing.T) {
|
||||
cmd := newSearchUserTestCommand()
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
@@ -488,26 +479,6 @@ func TestBuildBody_UserIDsResolveAndDedup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBody_UserIDsMeWithoutLoginReturnsTypedError(t *testing.T) {
|
||||
cmd := newSearchUserTestCommand()
|
||||
_ = cmd.Flags().Set("user-ids", "me")
|
||||
cfg := searchUserDefaultConfig()
|
||||
cfg.UserOpenId = ""
|
||||
rt := common.TestNewRuntimeContext(cmd, cfg)
|
||||
|
||||
body, err := buildSearchUserBody(rt)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got body %+v", body)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSearchUser_PageSizeOutOfRange_Errors(t *testing.T) {
|
||||
for _, n := range []int{0, 31} {
|
||||
cmd := newSearchUserTestCommand()
|
||||
@@ -533,20 +504,6 @@ func TestValidateSearchUser_PageSizeBoundaries_OK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
|
||||
_, err := decodeSearchUserAPIData(map[string]interface{}{"bad": func() {}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected marshal failure")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it
|
||||
// with the given args. Mirrors the pattern used in other shortcut packages.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
@@ -1054,13 +1011,6 @@ func TestRunOneQuery_APINonZeroCode(t *testing.T) {
|
||||
if got.ErrMsg != "API 99991663: rate limited" {
|
||||
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
|
||||
}
|
||||
p, ok := errs.ProblemOf(got.Err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
|
||||
}
|
||||
if p.Code != 99991663 {
|
||||
t.Errorf("problem code: got %d, want 99991663", p.Code)
|
||||
}
|
||||
if got.Users != nil || got.HasMore {
|
||||
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
|
||||
}
|
||||
@@ -1082,15 +1032,8 @@ func TestRunOneQuery_HTTPNon200(t *testing.T) {
|
||||
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
|
||||
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
|
||||
}
|
||||
p, ok := errs.ProblemOf(got.Err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
|
||||
}
|
||||
if p.Code != 503 {
|
||||
t.Errorf("problem code: got %d, want 503", p.Code)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("problem category: got %q, want %q", p.Category, errs.CategoryNetwork)
|
||||
if got.ErrCode != 503 {
|
||||
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,16 +1080,6 @@ func TestRunOneQuery_TransportError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutErrorResult_NilErrorIsSuccess(t *testing.T) {
|
||||
got := fanoutErrorResult(4, "alice", nil)
|
||||
if got.Index != 4 || got.Query != "alice" {
|
||||
t.Fatalf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if got.ErrMsg != "" || got.Err != nil {
|
||||
t.Fatalf("nil error should produce a success result, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
|
||||
@@ -1203,7 +1136,7 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
|
||||
}
|
||||
|
||||
// When all queries fail with no structured Lark API code (transport, parse,
|
||||
// panic, ctx-canceled), the returned typed error must carry an actionable
|
||||
// panic, ctx-canceled), the returned ExitError must carry an actionable
|
||||
// hint so the calling agent has a next step to try instead of giving up.
|
||||
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
@@ -1214,38 +1147,28 @@ func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryInternal)
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected Detail, got nil")
|
||||
}
|
||||
if p.Hint == "" {
|
||||
if exitErr.Detail.Hint == "" {
|
||||
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", p.Hint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Codes from the first failure must propagate through typed problem fields so
|
||||
// the CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// Codes from the first failure must propagate through output.ErrAPI so the
|
||||
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// instead of 0, which would mean "success" in the Lark protocol.
|
||||
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{
|
||||
Index: 0,
|
||||
Query: "alice",
|
||||
ErrMsg: "API 99991663: rate limit",
|
||||
Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663),
|
||||
},
|
||||
{
|
||||
Index: 1,
|
||||
Query: "bob",
|
||||
ErrMsg: "HTTP 500",
|
||||
Err: errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500").WithCode(500),
|
||||
},
|
||||
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
@@ -1254,16 +1177,6 @@ func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Errorf("error should contain first ErrMsg; got %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Code != 99991663 {
|
||||
t.Errorf("problem code: got %d, want 99991663", p.Code)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("problem subtype: got %q, want %q", p.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
|
||||
@@ -1307,37 +1220,6 @@ func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyFanoutUserRows(t *testing.T) {
|
||||
rows := prettyFanoutUserRows([]fanoutUser{
|
||||
{
|
||||
searchUser: searchUser{
|
||||
OpenID: "ou_a",
|
||||
LocalizedName: "Alice",
|
||||
Department: strings.Repeat("d", 80),
|
||||
EnterpriseEmail: "alice@example.com",
|
||||
HasChatted: true,
|
||||
ChatRecencyHint: "Contacted yesterday",
|
||||
},
|
||||
MatchedQuery: "alice",
|
||||
},
|
||||
})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows: got %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
for _, key := range []string{"matched_query", "localized_name", "department", "enterprise_email", "has_chatted", "chat_recency_hint", "open_id"} {
|
||||
if _, ok := row[key]; !ok {
|
||||
t.Fatalf("row missing key %q: %+v", key, row)
|
||||
}
|
||||
}
|
||||
if row["matched_query"] != "alice" || row["open_id"] != "ou_a" {
|
||||
t.Fatalf("row identity fields: %+v", row)
|
||||
}
|
||||
if len(row["department"].(string)) >= 80 {
|
||||
t.Fatalf("department should be truncated for table display, got %q", row["department"])
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
|
||||
// are no longer accepted. cobra must reject the unknown flag at parse time —
|
||||
// no stub is registered because the command should never reach the API.
|
||||
|
||||
@@ -24,11 +24,11 @@ var validCommandsV2 = map[string]bool{
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ var DriveAddComment = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
|
||||
|
||||
@@ -39,296 +39,230 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
return err
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
return err
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -488,72 +488,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -34,160 +34,128 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
func eventValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func eventValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return eventValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
// eventValidationParamErrorWithCause appends ": <err>" to the formatted
|
||||
// message and preserves err as the unwrap cause.
|
||||
func eventValidationParamErrorWithCause(err error, param, format string, args ...any) *errs.ValidationError {
|
||||
return eventValidationParamError(param, format+": %s", append(args, err)...).WithCause(err)
|
||||
}
|
||||
|
||||
// eventFileIOError appends ": <err>" to the formatted message and preserves
|
||||
// err as the unwrap cause.
|
||||
func eventFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format+": %s", append(args, err)...).WithCause(err)
|
||||
}
|
||||
|
||||
// eventNetworkError appends ": <err>" to the formatted message and preserves
|
||||
// err as the unwrap cause.
|
||||
func eventNetworkError(err error, format string, args ...any) *errs.NetworkError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format+": %s", append(args, err)...).WithCause(err)
|
||||
}
|
||||
@@ -63,13 +63,13 @@ func NewEventPipeline(
|
||||
func (p *EventPipeline) EnsureDirs() error {
|
||||
if p.config.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(p.config.OutputDir, 0700); err != nil {
|
||||
return eventFileIOError(err, "create output dir")
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
}
|
||||
if p.config.Router != nil {
|
||||
for _, route := range p.config.Router.routes {
|
||||
if err := vfs.MkdirAll(route.dir, 0700); err != nil {
|
||||
return eventFileIOError(err, "create route dir %s", route.dir)
|
||||
return fmt.Errorf("create route dir %s: %w", route.dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,13 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// chdirTemp changes cwd to a fresh temp dir for the test duration.
|
||||
@@ -51,87 +44,6 @@ func makeRawEvent(eventType string, eventJSON string) *RawEvent {
|
||||
}
|
||||
}
|
||||
|
||||
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, param string) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(%T) = false, error: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
if param != "" {
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error %T is not *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventTypedErrorHelpers(t *testing.T) {
|
||||
cause := errors.New("cause")
|
||||
|
||||
validation := eventValidationError("bad input")
|
||||
requireProblem(t, validation, errs.CategoryValidation, errs.SubtypeInvalidArgument, "")
|
||||
|
||||
paramErr := eventValidationParamErrorWithCause(cause, "--flag", "bad %s value", "flag")
|
||||
requireProblem(t, paramErr, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--flag")
|
||||
if got := paramErr.Error(); got != "bad flag value: cause" {
|
||||
t.Fatalf("message = %q, want %q", got, "bad flag value: cause")
|
||||
}
|
||||
if !errors.Is(paramErr, cause) {
|
||||
t.Fatal("validation error should preserve its cause")
|
||||
}
|
||||
|
||||
fileErr := eventFileIOError(cause, "write failed")
|
||||
requireProblem(t, fileErr, errs.CategoryInternal, errs.SubtypeFileIO, "")
|
||||
if got := fileErr.Error(); got != "write failed: cause" {
|
||||
t.Fatalf("message = %q, want %q", got, "write failed: cause")
|
||||
}
|
||||
if !errors.Is(fileErr, cause) {
|
||||
t.Fatal("file_io error should preserve its cause")
|
||||
}
|
||||
|
||||
networkErr := eventNetworkError(cause, "websocket failed")
|
||||
requireProblem(t, networkErr, errs.CategoryNetwork, errs.SubtypeNetworkTransport, "")
|
||||
if got := networkErr.Error(); got != "websocket failed: cause" {
|
||||
t.Fatalf("message = %q, want %q", got, "websocket failed: cause")
|
||||
}
|
||||
if !errors.Is(networkErr, cause) {
|
||||
t.Fatal("network error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func newSubscribeTestRuntime(t *testing.T) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "+subscribe"}
|
||||
cmd.Flags().String("event-types", "", "")
|
||||
cmd.Flags().String("filter", "", "")
|
||||
cmd.Flags().Bool("json", false, "")
|
||||
cmd.Flags().Bool("compact", false, "")
|
||||
cmd.Flags().String("output-dir", "", "")
|
||||
cmd.Flags().Bool("quiet", false, "")
|
||||
cmd.Flags().StringArray("route", nil, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
|
||||
return &common.RuntimeContext{
|
||||
Cmd: cmd,
|
||||
Config: &core.CliConfig{
|
||||
AppID: "cli_event_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: cmdutil.NewIOStreams(strings.NewReader(""), &out, &errOut),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
func TestRegistryLookup(t *testing.T) {
|
||||
@@ -151,11 +63,9 @@ func TestRegistryDuplicateReturnsError(t *testing.T) {
|
||||
if err := r.Register(&ImMessageProcessor{}); err != nil {
|
||||
t.Fatalf("first register should succeed: %v", err)
|
||||
}
|
||||
err := r.Register(&ImMessageProcessor{})
|
||||
if err == nil {
|
||||
if err := r.Register(&ImMessageProcessor{}); err == nil {
|
||||
t.Error("expected error on duplicate registration")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeUnknown, "")
|
||||
}
|
||||
|
||||
// --- Filters ---
|
||||
@@ -196,54 +106,6 @@ func TestRegexFilter_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsUnsafeOutputDir(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
if err := rt.Cmd.Flags().Set("output-dir", "/tmp/events"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EventSubscribe.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output-dir error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--output-dir")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("unsafe output-dir error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsInvalidFilter(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.Cmd.Flags().Set("filter", "[invalid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EventSubscribe.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid filter error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--filter")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("invalid filter error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsInvalidRoute(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.Cmd.Flags().Set("route", "no-equals-sign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EventSubscribe.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid route error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestFilterChain(t *testing.T) {
|
||||
etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1")
|
||||
rf, _ := NewRegexFilter("im\\..*")
|
||||
@@ -477,106 +339,6 @@ func TestPipeline_OutputDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsHeldLock(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
lock, err := lockfile.ForSubscribe("cli_event_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lock.TryLock(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = lock.Unlock() })
|
||||
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
execErr := EventSubscribe.Execute(context.Background(), rt)
|
||||
if execErr == nil {
|
||||
t.Fatal("expected lock-held error")
|
||||
}
|
||||
requireProblem(t, execErr, errs.CategoryValidation, errs.SubtypeFailedPrecondition, "")
|
||||
if !errors.Is(execErr, lockfile.ErrHeld) {
|
||||
t.Error("lock-held error should preserve lockfile.ErrHeld for errors.Is")
|
||||
}
|
||||
p, _ := errs.ProblemOf(execErr)
|
||||
if p.Hint == "" {
|
||||
t.Error("lock-held error should carry a recovery hint")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(execErr, &ve) && ve.Param != "" {
|
||||
t.Errorf("lock contention names no offending flag; param = %q, want empty", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeDryRunEchoesFlags(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
for flag, value := range map[string]string{
|
||||
"event-types": "im.message.receive_v1",
|
||||
"filter": "^im\\.",
|
||||
"output-dir": "events_out",
|
||||
} {
|
||||
if err := rt.Cmd.Flags().Set(flag, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := rt.Cmd.Flags().Set("route", "^im\\.message=dir:./messages"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d := EventSubscribe.DryRun(context.Background(), rt)
|
||||
if d == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
payload, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"command":"event +subscribe"`,
|
||||
`"app_id":"cli_event_test"`,
|
||||
`"event_types":"im.message.receive_v1"`,
|
||||
`"output_dir":"events_out"`,
|
||||
} {
|
||||
if !strings.Contains(string(payload), want) {
|
||||
t.Errorf("dry-run payload missing %s\ngot: %s", want, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_EnsureDirsRouteDirFileIOError(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("blocked", []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := ParseRoutes([]string{`^im\.=dir:./blocked/child`})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRoutes: %v", err)
|
||||
}
|
||||
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
|
||||
PipelineConfig{Mode: TransformCompact, Router: router}, io.Discard, io.Discard)
|
||||
err = p.EnsureDirs()
|
||||
if err == nil {
|
||||
t.Fatal("expected file_io error for route dir blocked by a file")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
|
||||
}
|
||||
|
||||
func TestPipeline_EnsureDirsFileIOError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "not-a-dir")
|
||||
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
|
||||
PipelineConfig{Mode: TransformCompact, OutputDir: filepath.Join(path, "child")}, io.Discard, io.Discard)
|
||||
err := p.EnsureDirs()
|
||||
if err == nil {
|
||||
t.Fatal("expected file_io error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("file_io error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pipeline: JsonFlag ---
|
||||
|
||||
func TestPipeline_JsonFlag(t *testing.T) {
|
||||
@@ -846,7 +608,6 @@ func TestParseRoutes_MissingEquals(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for missing =")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_InvalidRegex(t *testing.T) {
|
||||
@@ -854,10 +615,6 @@ func TestParseRoutes_InvalidRegex(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid regex")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("invalid regex error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoutes_MissingPrefix(t *testing.T) {
|
||||
@@ -865,7 +622,6 @@ func TestParseRoutes_MissingPrefix(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for missing dir: prefix")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
if !strings.Contains(err.Error(), "dir:") {
|
||||
t.Errorf("error should mention dir: prefix, got: %v", err)
|
||||
}
|
||||
@@ -876,7 +632,6 @@ func TestParseRoutes_EmptyPath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
|
||||
@@ -884,7 +639,6 @@ func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path in route")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_RejectsTraversal(t *testing.T) {
|
||||
@@ -892,7 +646,6 @@ func TestParseRoutes_RejectsTraversal(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal in route")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_PathSafety(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package event
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
import "fmt"
|
||||
|
||||
// ProcessorRegistry manages event_type → EventProcessor mappings.
|
||||
type ProcessorRegistry struct {
|
||||
@@ -23,7 +23,7 @@ func NewProcessorRegistry(fallback EventProcessor) *ProcessorRegistry {
|
||||
func (r *ProcessorRegistry) Register(p EventProcessor) error {
|
||||
et := p.EventType()
|
||||
if _, exists := r.processors[et]; exists {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "duplicate event processor for: %s", et)
|
||||
return fmt.Errorf("duplicate event processor for: %s", et)
|
||||
}
|
||||
r.processors[et] = p
|
||||
return nil
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -33,27 +34,27 @@ func ParseRoutes(specs []string) (*EventRouter, error) {
|
||||
for _, spec := range specs {
|
||||
parts := strings.SplitN(spec, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, eventValidationParamError("--route", "invalid --route %q: expected format regex=dir:./path", spec)
|
||||
return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec)
|
||||
}
|
||||
pattern := parts[0]
|
||||
target := parts[1]
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid regex in --route %q", spec)
|
||||
return nil, fmt.Errorf("invalid regex in route %q: %w", spec, err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "dir:") {
|
||||
return nil, eventValidationParamError("--route", "invalid --route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
|
||||
return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
|
||||
}
|
||||
dir := strings.TrimPrefix(target, "dir:")
|
||||
if dir == "" {
|
||||
return nil, eventValidationParamError("--route", "invalid --route %q: directory path is empty", spec)
|
||||
return nil, fmt.Errorf("invalid route %q: directory path is empty", spec)
|
||||
}
|
||||
|
||||
safeDir, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid --route %q", spec)
|
||||
return nil, fmt.Errorf("invalid route %q: %w", spec, err)
|
||||
}
|
||||
|
||||
routes = append(routes, Route{pattern: re, dir: safeDir})
|
||||
|
||||
@@ -6,7 +6,6 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -146,7 +144,7 @@ var EventSubscribe = common.Shortcut{
|
||||
if outputDir != "" {
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return eventValidationParamErrorWithCause(err, "--output-dir", "unsafe --output-dir")
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
@@ -164,18 +162,15 @@ var EventSubscribe = common.Shortcut{
|
||||
if !forceFlag {
|
||||
lock, err := lockfile.ForSubscribe(runtime.Config.AppID)
|
||||
if err != nil {
|
||||
return eventFileIOError(err, "failed to create event subscriber lock")
|
||||
return fmt.Errorf("failed to create lock: %w", err)
|
||||
}
|
||||
if err := lock.TryLock(); err != nil {
|
||||
if errors.Is(err, lockfile.ErrHeld) {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event +subscribe instance is already running for app %s\n"+
|
||||
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
|
||||
" Use --force to bypass this check.",
|
||||
runtime.Config.AppID,
|
||||
).WithHint("stop the existing subscriber for this app, or rerun with --force if you accept split event delivery").WithCause(err)
|
||||
}
|
||||
return eventFileIOError(err, "failed to acquire event subscriber lock")
|
||||
return output.ErrValidation(
|
||||
"another event +subscribe instance is already running for app %s\n"+
|
||||
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
|
||||
" Use --force to bypass this check.",
|
||||
runtime.Config.AppID,
|
||||
)
|
||||
}
|
||||
defer lock.Unlock()
|
||||
}
|
||||
@@ -184,7 +179,7 @@ var EventSubscribe = common.Shortcut{
|
||||
eventTypeFilter := NewEventTypeFilter(eventTypesStr)
|
||||
regexFilter, err := NewRegexFilter(filterStr)
|
||||
if err != nil {
|
||||
return eventValidationParamErrorWithCause(err, "--filter", "invalid --filter regex %q", filterStr)
|
||||
return output.ErrValidation("invalid --filter regex: %s", filterStr)
|
||||
}
|
||||
var filterList []EventFilter
|
||||
if eventTypeFilter != nil {
|
||||
@@ -198,7 +193,7 @@ var EventSubscribe = common.Shortcut{
|
||||
// --- Parse route ---
|
||||
router, err := ParseRoutes(routeSpecs)
|
||||
if err != nil {
|
||||
return err
|
||||
return output.ErrValidation("invalid --route: %v", err)
|
||||
}
|
||||
|
||||
// --- Build pipeline ---
|
||||
@@ -297,7 +292,7 @@ var EventSubscribe = common.Shortcut{
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return eventNetworkError(err, "WebSocket connection failed")
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -97,11 +97,13 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
// Brand only — never decrypt the app secret at registration time (avoids a
|
||||
// keychain read on every invocation). ConfigBrand may be nil in tests that
|
||||
// pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
if f != nil && f.ConfigBrand != nil {
|
||||
if b, ok := f.ConfigBrand(); ok {
|
||||
brand = b
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{Brand: brand}, nil
|
||||
},
|
||||
// Registration reads the brand via ConfigBrand (no secret decryption).
|
||||
ConfigBrand: func() (core.LarkBrand, bool) {
|
||||
return brand, true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,18 +177,6 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
|
||||
@@ -152,12 +152,6 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("csv", "", "")
|
||||
cmd.ParseFlags(nil)
|
||||
cmd.Flags().Set("csv", csvVal)
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
|
||||
// only when it names a real file (a forgotten @), while leaving genuine inline
|
||||
// content alone — including the case the old name-shape heuristic got wrong:
|
||||
// prose that merely ends in or mentions a filename.
|
||||
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
if err == nil {
|
||||
t.Fatal("expected guard error when --csv names an existing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
|
||||
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
|
||||
}
|
||||
|
||||
// Content that is not a real file must pass through unchanged.
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // prose ending in a filename — not a real file
|
||||
"remember to update data.csv", // mentions the real file but isn't its name
|
||||
"a,b\n1,2", // multi-cell CSV
|
||||
"hello world",
|
||||
"nope.csv", // path-shaped but no such file
|
||||
"",
|
||||
} {
|
||||
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
|
||||
t.Errorf("content %q must pass through, got: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"desc": "Insert position; appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -413,86 +413,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -510,34 +430,23 @@
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -604,32 +513,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-import": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "file",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Local file path (.xlsx / .xls / .csv)"
|
||||
},
|
||||
{
|
||||
"name": "folder-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; imported to the cloud drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -1329,65 +1212,19 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by id); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by name); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet current region"
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"name": "rows-json",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2012,7 +1849,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -2043,43 +1880,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token to write into (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -1730,12 +1730,11 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1788,7 +1787,11 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2766,12 +2769,11 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2824,7 +2826,11 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6243,457 +6249,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,17 +364,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -384,8 +381,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -395,13 +392,14 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -422,8 +420,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
@@ -308,6 +308,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
@@ -319,7 +320,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -765,7 +766,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -792,16 +793,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -848,16 +839,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -914,35 +895,13 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -957,14 +916,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
|
||||
@@ -65,7 +65,6 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
"styles": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
|
||||
@@ -32,6 +32,4 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -49,12 +49,6 @@ type objectCRUDSpec struct {
|
||||
// right nesting level.
|
||||
enhanceCreateInput func(rt flagView, input map[string]interface{})
|
||||
enhanceUpdateInput func(rt flagView, input map[string]interface{})
|
||||
// validateCreateInput, when set, runs after enhanceCreateInput to
|
||||
// enforce *cross-flag, create-only* constraints JSON Schema can't
|
||||
// express (e.g. pivot rejects --target-position vs --range when
|
||||
// both carry non-default values — they map to the same wire field
|
||||
// and conflicting values are ambiguous). Mirrors validateUpdateInput.
|
||||
validateCreateInput func(rt flagView, input map[string]interface{}) error
|
||||
// validateUpdateInput, when set, runs after enhanceUpdateInput to
|
||||
// enforce *cross-field, update-only* constraints JSON Schema can't
|
||||
// express (e.g. sparkline requires properties.sparklines[i] to
|
||||
@@ -196,11 +190,6 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
if spec.enhanceCreateInput != nil {
|
||||
spec.enhanceCreateInput(runtime, input)
|
||||
}
|
||||
if spec.validateCreateInput != nil {
|
||||
if err := spec.validateCreateInput(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -392,6 +381,9 @@ var pivotSpec = objectCRUDSpec{
|
||||
},
|
||||
createWarn: pivotPlacementWarn,
|
||||
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
input["target_position"] = v
|
||||
}
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return
|
||||
@@ -399,26 +391,10 @@ var pivotSpec = objectCRUDSpec{
|
||||
if v := strings.TrimSpace(rt.Str("source")); v != "" {
|
||||
props["source"] = v
|
||||
}
|
||||
// --target-position 与 --range 都映射到 properties.range;
|
||||
// --target-position 优先,未给(或为默认值 A1)时回落到 --range。
|
||||
// 互斥校验在 validateCreateInput 里做。
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
props["range"] = v
|
||||
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
props["range"] = v
|
||||
}
|
||||
},
|
||||
// --target-position 与 --range 落到同一 wire 字段(properties.range),
|
||||
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
|
||||
// 的处理方式,CLI 端直接拒绝(优于静默丢弃其一)。
|
||||
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
|
||||
pos := strings.TrimSpace(rt.Str("target-position"))
|
||||
rng := strings.TrimSpace(rt.Str("range"))
|
||||
if pos != "" && pos != "A1" && rng != "" {
|
||||
return common.FlagErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var PivotCreate = newObjectCreateShortcut(pivotSpec)
|
||||
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
|
||||
|
||||
@@ -137,24 +137,25 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
// covered separately in the +pivot-create empty-selector / mutex
|
||||
// tests below.
|
||||
{
|
||||
name: "+pivot-create with placement / source / target-position flags",
|
||||
name: "+pivot-create with placement / source / range flags",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--range", "F1",
|
||||
"--target-position", "B5",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"target_position": "B5",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
// --target-position 映射到 properties.range。
|
||||
"range": "B5",
|
||||
"range": "F1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -506,55 +507,6 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
|
||||
// and --range cannot both be set" guardrail on +pivot-create. They map to
|
||||
// the same wire field (properties.range), so two non-default values are
|
||||
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
|
||||
// --target-sheet-name mutex). --target-position=A1 is the documented default
|
||||
// and is treated as "not set" — pairing it with --range still works.
|
||||
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("both non-default values rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "B5",
|
||||
"--range", "F1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject --target-position with --range; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
if !strings.Contains(combined, "--target-position") || !strings.Contains(combined, "--range") {
|
||||
t.Errorf("expected error to quote both --target-position and --range; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "A1",
|
||||
"--range", "F1",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if got, _ := props["range"].(string); got != "F1" {
|
||||
t.Errorf("properties.range = %q, want %q", got, "F1")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_SchemaValidates exercises the schema-driven
|
||||
// validator wired into objectCreateInput. The pivot create schema
|
||||
// doesn't constrain rows/columns/values to be present (the backend
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -162,7 +164,12 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -212,6 +219,141 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
@@ -235,6 +377,89 @@ func a1EndRow(rng string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
|
||||
@@ -63,6 +63,20 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -165,3 +179,113 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
|
||||
// --output-path, +workbook-export delegates to the shared drive export core
|
||||
// with OutputDir="" so it creates + polls the export task and returns the ready
|
||||
// file token without writing a local file (downloaded=false).
|
||||
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_export"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"job_status": float64(0),
|
||||
"file_token": "ftk_xlsx",
|
||||
"file_name": "report.xlsx",
|
||||
"file_size": float64(2048),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
|
||||
}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("export-only execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if env.Data["ready"] != true {
|
||||
t.Errorf("ready = %v, want true", env.Data["ready"])
|
||||
}
|
||||
if env.Data["downloaded"] != false {
|
||||
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
|
||||
}
|
||||
if env.Data["file_token"] != "ftk_xlsx" {
|
||||
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
|
||||
}
|
||||
if env.Data["doc_type"] != "sheet" {
|
||||
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// chdirTemp switches into a fresh temp dir for the duration of the test and
|
||||
// restores the original cwd afterwards. +workbook-import is the first sheets
|
||||
// shortcut that stat()s a real local file, so these tests need a working dir.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
|
||||
// shared drive import core and hard-codes the import target type to "sheet".
|
||||
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
|
||||
|
||||
var createBody map[string]interface{}
|
||||
for _, c := range calls {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
|
||||
createBody, _ = cm["body"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if createBody == nil {
|
||||
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
|
||||
}
|
||||
if createBody["type"] != "sheet" {
|
||||
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
|
||||
}
|
||||
if createBody["file_extension"] != "xlsx" {
|
||||
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
|
||||
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
|
||||
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
|
||||
// front and the error surfaces through the normal envelope/err path.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
|
||||
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
|
||||
// flow against stubs and asserts the resulting URL is a /sheets/ link.
|
||||
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_sheet"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"token": "shtcn_imported",
|
||||
"type": "sheet",
|
||||
"job_status": float64(0),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("import execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("execute output has no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
|
||||
t.Errorf("imported url = %q, want a /sheets/ link", url)
|
||||
}
|
||||
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
|
||||
t.Errorf("token = %q, want shtcn_imported", tok)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -141,28 +140,6 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
|
||||
"tab_color": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "show_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -306,7 +283,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
|
||||
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
|
||||
@@ -318,11 +295,12 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with values → 2-step plan", func(t *testing.T) {
|
||||
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95],["bob",88]]`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
@@ -334,88 +312,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
body, _ := fill["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B3" {
|
||||
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 2 {
|
||||
t.Fatalf("cells rows = %#v, want 2", input["cells"])
|
||||
}
|
||||
headerRow, _ := cells[0].([]interface{})
|
||||
firstHeader, _ := headerRow[0].(map[string]interface{})
|
||||
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
|
||||
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
|
||||
t.Errorf("first header style = %#v, want bold + background", firstStyle)
|
||||
}
|
||||
secondHeader, _ := headerRow[1].(map[string]interface{})
|
||||
if secondHeader["border_styles"] == nil {
|
||||
t.Errorf("second header missing border_styles: %#v", secondHeader)
|
||||
}
|
||||
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
|
||||
if secondStyle["number_format"] != "0" {
|
||||
t.Errorf("second header number_format = %#v, want 0", secondStyle)
|
||||
}
|
||||
dataRow, _ := cells[1].([]interface{})
|
||||
firstData, _ := dataRow[0].(map[string]interface{})
|
||||
if _, ok := firstData["cell_styles"]; ok {
|
||||
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
|
||||
}
|
||||
secondData, _ := dataRow[1].(map[string]interface{})
|
||||
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
|
||||
if secondDataStyle["font_color"] != "#0f7b0f" {
|
||||
t.Errorf("second data style = %#v, want font color", secondDataStyle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a","b"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
|
||||
b1, _ := row0[1].(map[string]interface{})
|
||||
b1s, _ := b1["cell_styles"].(map[string]interface{})
|
||||
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
|
||||
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
|
||||
}
|
||||
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
|
||||
a1, _ := row0[0].(map[string]interface{})
|
||||
a1s, _ := a1["cell_styles"].(map[string]interface{})
|
||||
if a1s["font_color"] != nil {
|
||||
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
|
||||
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -428,18 +325,8 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
|
||||
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
|
||||
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
|
||||
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
|
||||
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -452,21 +339,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
|
||||
// shared drive export core: a single create-task POST (poll + download are
|
||||
// described inline rather than as separate api entries).
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("url = %v", create["url"])
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
@@ -474,18 +361,22 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
|
||||
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
|
||||
"--output-path", "/tmp/out.csv",
|
||||
})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
|
||||
if body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export missing sub_id: %#v", body)
|
||||
}
|
||||
dl := calls[2].(map[string]interface{})
|
||||
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
|
||||
t.Errorf("download url = %v", dl["url"])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user