mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
50 Commits
fix/skills
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e691781eb | ||
|
|
c8de4e3692 | ||
|
|
a72331d007 | ||
|
|
9950a00da4 | ||
|
|
cf3c5f13eb | ||
|
|
b1e58d1340 | ||
|
|
0a17ddc45d | ||
|
|
773b93cb10 | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
82a983888b | ||
|
|
9847b16d1a | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
bed30c4ecb | ||
|
|
a7be567066 | ||
|
|
e96acad2c5 | ||
|
|
7ac8a7d30e | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
31523b7f50 | ||
|
|
8f5504c51c | ||
|
|
02a37029c2 | ||
|
|
d0a896ce91 | ||
|
|
556d7e3a77 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
f18a082a4f | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
b8c5176483 | ||
|
|
82937a0a37 | ||
|
|
1cafb94a62 | ||
|
|
0b33daa136 | ||
|
|
5a61b97ac3 | ||
|
|
e01f2dfdd5 | ||
|
|
45f807459e | ||
|
|
8906e87fb1 | ||
|
|
d5a53d921d | ||
|
|
0ff7f0407e | ||
|
|
6e067f2180 |
@@ -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/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
|
||||
- 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/)
|
||||
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/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
|
||||
- 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/)
|
||||
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/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
|
||||
- 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/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
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.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -2,6 +2,46 @@
|
||||
|
||||
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
|
||||
@@ -1026,6 +1066,7 @@ 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
|
||||
|
||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// 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).
|
||||
// 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.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ 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 returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// 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 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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,6 +16,7 @@ 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"
|
||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
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),
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -260,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
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),
|
||||
)
|
||||
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))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -300,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
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)),
|
||||
)
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -328,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
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 "", 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 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")
|
||||
@@ -351,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -370,3 +381,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
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) {
|
||||
@@ -73,6 +78,7 @@ 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 {
|
||||
@@ -90,6 +96,77 @@ 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
|
||||
@@ -130,6 +207,7 @@ 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,19 +89,17 @@ 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)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
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")
|
||||
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)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +143,19 @@ 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 exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
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.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
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)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.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,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
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)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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,6 +11,7 @@ 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"
|
||||
@@ -39,12 +40,14 @@ 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, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -129,3 +130,38 @@ 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,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ 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, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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,12 +6,11 @@ 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"
|
||||
)
|
||||
|
||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.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,7 +16,8 @@ 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, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -24,11 +25,15 @@ 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, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
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)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -58,6 +59,16 @@ 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
|
||||
@@ -70,6 +81,9 @@ 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
|
||||
|
||||
18
internal/errclass/codemeta_minutes.go
Normal file
18
internal/errclass/codemeta_minutes.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is stable across minutes endpoints are registered;
|
||||
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
|
||||
// Command-specific messages, hints, and subtypes are layered on top via
|
||||
// per-command enrichment.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var minutesCodeMeta = map[int]CodeMeta{
|
||||
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
|
||||
@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
|
||||
if got, ok := LookupCodeMeta(2091001); ok {
|
||||
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
|
||||
19
internal/errclass/codemeta_vc.go
Normal file
19
internal/errclass/codemeta_vc.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes (e.g. 124002 "recording still generating", which has no
|
||||
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
|
||||
// per-command enrichment for a retry hint.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var vcCodeMeta = map[int]CodeMeta{
|
||||
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
|
||||
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(vcCodeMeta, "vc") }
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -80,7 +83,8 @@ 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 fmt.Errorf("handshake failed: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +138,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +160,10 @@ 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 fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
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: 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 for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), 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 nil
|
||||
}
|
||||
@@ -213,7 +227,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText() string {
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@ 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, fmt.Errorf("invalid jq expression: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -20,6 +23,16 @@ 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,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ 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"
|
||||
@@ -51,10 +52,9 @@ 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, 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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,8 +65,10 @@ 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, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
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)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -88,7 +90,9 @@ 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, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
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)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
99
internal/event/consume/startup_guard_test.go
Normal file
99
internal/event/consume/startup_guard_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/validate_params_test.go
Normal file
64
internal/event/consume/validate_params_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,19 @@ 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/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,19 @@ 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/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ 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
|
||||
@@ -36,6 +41,9 @@ 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)
|
||||
@@ -71,3 +79,16 @@ 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/contact/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -813,6 +813,8 @@ 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
|
||||
@@ -833,6 +835,8 @@ 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
|
||||
@@ -853,6 +857,8 @@ 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
|
||||
@@ -907,7 +913,7 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -1006,7 +1012,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1076,3 +1082,23 @@ 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.48",
|
||||
"version": "1.0.49",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
|
||||
body["with_url"] = true
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Code != 99991668 {
|
||||
t.Fatalf("code = %d, want 99991668", p.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,24 +6,8 @@ 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 TestResolveOpenIDs_Empty(t *testing.T) {
|
||||
func TestResolveOpenIDsTyped_Empty(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDs("--user-ids", nil, rt)
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", nil, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -28,21 +28,9 @@ func TestResolveOpenIDs_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_ExpandsMeAndDedups(t *testing.T) {
|
||||
func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
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)
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -52,22 +40,11 @@ func TestResolveOpenIDs_MeIsCaseInsensitive(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) {
|
||||
func TestResolveOpenIDsTyped_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 := ResolveOpenIDs("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -106,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
return MutuallyExclusiveTyped(rt, flags...)
|
||||
}
|
||||
|
||||
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
//
|
||||
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
|
||||
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
if s == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
|
||||
}
|
||||
if n < minVal || n > maxVal {
|
||||
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
|
||||
@@ -5,8 +5,6 @@ package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
@@ -42,17 +40,6 @@ 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.
|
||||
|
||||
78
shortcuts/contact/contact_errors.go
Normal file
78
shortcuts/contact/contact_errors.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// 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]) + "..."
|
||||
}
|
||||
81
shortcuts/contact/contact_errors_test.go
Normal file
81
shortcuts/contact/contact_errors_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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,7 +28,8 @@ var ContactGetUser = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("user-id") == "" && runtime.IsBot() {
|
||||
return common.FlagErrorf("bot identity cannot get current user info, specify --user-id")
|
||||
return common.ValidationErrorf("bot identity cannot get current user info, specify --user-id").
|
||||
WithParam("--user-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -63,7 +64,7 @@ var ContactGetUser = common.Shortcut{
|
||||
|
||||
if userId == "" {
|
||||
// Current user
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/authen/v1/user_info", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +88,7 @@ var ContactGetUser = common.Shortcut{
|
||||
|
||||
if runtime.IsBot() {
|
||||
// Bot identity: GET /contact/v3/users/:user_id (full profile)
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
|
||||
map[string]interface{}{"user_id_type": userIdType}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -110,7 +111,7 @@ var ContactGetUser = common.Shortcut{
|
||||
}
|
||||
|
||||
// User identity: POST /contact/v3/users/basic_batch (lightweight)
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch",
|
||||
data, err := runtime.CallAPITyped("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 {
|
||||
|
||||
125
shortcuts/contact/contact_get_user_test.go
Normal file
125
shortcuts/contact/contact_get_user_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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,6 +15,7 @@ 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"
|
||||
@@ -80,12 +81,6 @@ 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"`
|
||||
@@ -216,19 +211,17 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(apiResp.StatusCode, http.StatusText(apiResp.StatusCode), string(apiResp.RawBody))
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respData, err := decodeSearchUserAPIData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
@@ -245,6 +238,20 @@ 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"
|
||||
}
|
||||
@@ -373,52 +380,74 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
|
||||
|
||||
func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
if !hasAnySearchInput(runtime) {
|
||||
return common.FlagErrorf(
|
||||
return common.ValidationErrorf(
|
||||
"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.FlagErrorf("--query and --queries are mutually exclusive")
|
||||
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"},
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
|
||||
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"},
|
||||
)
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
|
||||
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
|
||||
WithParam("--queries")
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
|
||||
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
|
||||
WithParam("--queries")
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
|
||||
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars).
|
||||
WithParam("--queries")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
|
||||
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars).
|
||||
WithParam("--query")
|
||||
}
|
||||
}
|
||||
|
||||
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
|
||||
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
|
||||
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return common.FlagErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw)
|
||||
return common.ValidationErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw).
|
||||
WithParam("--user-ids")
|
||||
}
|
||||
if len(ids) > maxSearchUserUserIDs {
|
||||
return common.FlagErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs)
|
||||
return common.ValidationErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs).
|
||||
WithParam("--user-ids")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--user-ids", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -429,15 +458,16 @@ 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.FlagErrorf(
|
||||
return common.ValidationErrorf(
|
||||
"--%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.FlagErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize)
|
||||
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize).
|
||||
WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -473,7 +503,7 @@ func buildSearchUserBody(runtime *common.RuntimeContext) (*searchUserAPIRequest,
|
||||
hasFilter := false
|
||||
|
||||
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
|
||||
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
|
||||
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -47,7 +46,7 @@ type fanoutResult struct {
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
ErrMsg string // empty = success
|
||||
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
@@ -67,7 +66,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 fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
return fanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
body := &searchUserAPIRequest{Query: query}
|
||||
@@ -82,38 +81,29 @@ 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 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}
|
||||
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)}
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
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}
|
||||
respData, err := decodeSearchUserAPIData(data)
|
||||
if err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
users, hasMore := projectUsers(respData, 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"`
|
||||
@@ -146,7 +136,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErrCode int
|
||||
var firstErr error
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
@@ -158,7 +148,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErrCode = r.ErrCode
|
||||
firstErr = r.Err
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -169,18 +159,7 @@ 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)
|
||||
// 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 nil, contactFanoutAllFailedError(firstErr, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -16,10 +15,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"
|
||||
)
|
||||
@@ -254,6 +253,16 @@ 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())
|
||||
@@ -479,6 +488,26 @@ 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()
|
||||
@@ -504,6 +533,20 @@ 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 {
|
||||
@@ -1011,6 +1054,13 @@ 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)
|
||||
}
|
||||
@@ -1032,8 +1082,15 @@ 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)
|
||||
}
|
||||
if got.ErrCode != 503 {
|
||||
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,6 +1137,16 @@ 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},
|
||||
@@ -1136,7 +1203,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 ExitError must carry an actionable
|
||||
// panic, ctx-canceled), the returned typed error 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{
|
||||
@@ -1147,28 +1214,38 @@ func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected Detail, got nil")
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if p.Hint == "" {
|
||||
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
// 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", ErrCode: 99991663},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
|
||||
{
|
||||
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),
|
||||
},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
@@ -1177,6 +1254,16 @@ 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) {
|
||||
@@ -1220,6 +1307,37 @@ 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.
|
||||
|
||||
@@ -5,35 +5,13 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
|
||||
// v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||
func v1CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title", Hidden: true},
|
||||
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
|
||||
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
|
||||
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
|
||||
|
||||
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
||||
func useV2Create(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
return runtime.Str("content") != "" ||
|
||||
runtime.Str("parent-token") != "" ||
|
||||
runtime.Str("parent-position") != ""
|
||||
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
|
||||
}
|
||||
|
||||
var DocsCreate = common.Shortcut{
|
||||
@@ -43,213 +21,25 @@ var DocsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
PostMount: installDocsShortcutHelp("+create"),
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
docsAPIVersionCompatFlag(),
|
||||
},
|
||||
v1CreateFlags(),
|
||||
v2CreateFlags(),
|
||||
v1CreateFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if useV2Create(runtime) {
|
||||
return validateCreateV2(ctx, runtime)
|
||||
}
|
||||
return validateCreateV1(ctx, runtime)
|
||||
return validateCreateV2(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if useV2Create(runtime) {
|
||||
return dryRunCreateV2(ctx, runtime)
|
||||
}
|
||||
return dryRunCreateV1(ctx, runtime)
|
||||
return dryRunCreateV2(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if useV2Create(runtime) {
|
||||
return executeCreateV2(ctx, runtime)
|
||||
}
|
||||
return executeCreateV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
|
||||
return executeCreateV2(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--markdown is required")
|
||||
}
|
||||
count := 0
|
||||
if runtime.Str("folder-token") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-node") != "" {
|
||||
count++
|
||||
}
|
||||
if runtime.Str("wiki-space") != "" {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildCreateArgsV1(runtime)
|
||||
d := common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: create-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "create-doc").Set("args", args)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+create")
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
args := buildCreateArgsV1(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
augmentCreateResultV1(runtime, result)
|
||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
md := runtime.Str("markdown")
|
||||
args := map[string]interface{}{
|
||||
"markdown": md,
|
||||
}
|
||||
if v := runtime.Str("title"); v != "" {
|
||||
args["title"] = v
|
||||
}
|
||||
if v := runtime.Str("folder-token"); v != "" {
|
||||
args["folder_token"] = v
|
||||
}
|
||||
if v := runtime.Str("wiki-node"); v != "" {
|
||||
args["wiki_node"] = v
|
||||
}
|
||||
if v := runtime.Str("wiki-space"); v != "" {
|
||||
args["wiki_space"] = v
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
type docsPermissionTarget struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
target := selectPermissionTarget(result)
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
fallbackDocURLV1(runtime, result)
|
||||
}
|
||||
|
||||
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
|
||||
// response did not include one but did include a doc_id. This protects against
|
||||
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
|
||||
// drops structured fields.
|
||||
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
|
||||
return
|
||||
}
|
||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
||||
if docID == "" {
|
||||
return
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
|
||||
result["doc_url"] = u
|
||||
}
|
||||
}
|
||||
|
||||
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
||||
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
||||
return ref
|
||||
}
|
||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
||||
if docID != "" {
|
||||
return docsPermissionTarget{Token: docID, Type: "docx"}
|
||||
}
|
||||
return docsPermissionTarget{}
|
||||
}
|
||||
|
||||
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
||||
if strings.TrimSpace(docURL) == "" {
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
ref, err := parseDocumentRef(docURL)
|
||||
if err != nil {
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
switch ref.Kind {
|
||||
case "wiki":
|
||||
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
|
||||
case "doc", "docx":
|
||||
return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true
|
||||
default:
|
||||
return docsPermissionTarget{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
|
||||
// whiteboard creation markdown is detected.
|
||||
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
return
|
||||
}
|
||||
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
||||
}
|
||||
|
||||
func isWhiteboardCreateMarkdown(markdown string) bool {
|
||||
lower := strings.ToLower(markdown)
|
||||
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "<whiteboard") &&
|
||||
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
||||
}
|
||||
|
||||
func normalizeBoardTokens(raw interface{}) []string {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return []string{}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
tokens := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
tokens = append(tokens, s)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
case string:
|
||||
if v == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{v}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ──
|
||||
|
||||
// concatFlags combines multiple flag slices into one.
|
||||
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
||||
var out []common.Flag
|
||||
@@ -258,15 +48,3 @@ func concatFlags(slices ...[]common.Flag) []common.Flag {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
|
||||
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
|
||||
m := make(map[string]string, len(v1)+len(v2))
|
||||
for _, f := range v1 {
|
||||
m[f.Name] = "v1"
|
||||
}
|
||||
for _, f := range v2 {
|
||||
m[f.Name] = "v2"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>项目计划</title><h1>目标</h1>",
|
||||
"--as", "bot",
|
||||
})
|
||||
@@ -249,148 +248,63 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 (MCP) tests ──
|
||||
|
||||
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"member": map[string]interface{}{
|
||||
"member_id": "ou_current_user",
|
||||
"member_type": "openid",
|
||||
"perm": "full_access",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantGranted {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://example.feishu.cn/wiki/wikcn_new_node",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
permStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
}
|
||||
reg.Register(permStub)
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--markdown", "## 内容",
|
||||
"--wiki-space", "my_library",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("failed to parse permission request body: %v", err)
|
||||
}
|
||||
if body["perm_type"] != "container" {
|
||||
t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
// "doc_url" deliberately omitted to exercise the fallback.
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v1",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--content", "<title>项目计划</title>",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
|
||||
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got, want := doc["document_id"], "doxcn_new_doc"; got != want {
|
||||
t.Fatalf("document.document_id = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
|
||||
func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v1",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected legacy v1 flags to be rejected")
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
|
||||
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
||||
for _, want := range []string{
|
||||
"docs +create is v2-only",
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --title, --markdown are no longer supported",
|
||||
"--title -> put the title in --content",
|
||||
"--markdown -> use --content with --doc-format markdown",
|
||||
"lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"follow the latest format rules",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"lark-cli docs +create --help",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,24 +335,6 @@ func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface
|
||||
})
|
||||
}
|
||||
|
||||
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
|
||||
payload, _ := json.Marshal(result)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/mcp",
|
||||
Body: map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": string(payload),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -13,14 +13,17 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
|
||||
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||
}
|
||||
}
|
||||
|
||||
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("content") == "" {
|
||||
return common.FlagErrorf("--content is required")
|
||||
}
|
||||
|
||||
@@ -5,40 +5,13 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
|
||||
// v1FetchFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||
func v1FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "offset", Desc: "pagination offset", Hidden: true},
|
||||
{Name: "limit", Desc: "pagination limit", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
|
||||
|
||||
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by the
|
||||
// presence of any v2-only flag on the command line — we check pflag.Changed
|
||||
// rather than the value so that explicitly typing `--detail simple` (equal
|
||||
// to the default) still routes to v2.
|
||||
func useV2Fetch(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
for _, name := range []string{"detail", "doc-format", "scope", "revision-id", "start-block-id", "end-block-id", "keyword", "context-before", "context-after", "max-depth"} {
|
||||
if runtime.Changed(name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return docsLegacyFlagDefinitions(docsFetchLegacyFlags())
|
||||
}
|
||||
|
||||
var DocsFetch = common.Shortcut{
|
||||
@@ -49,88 +22,22 @@ var DocsFetch = common.Shortcut{
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Tips: docsVersionSelectionTips,
|
||||
PostMount: installDocsShortcutHelp("+fetch"),
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
docsAPIVersionCompatFlag(),
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
},
|
||||
v1FetchFlags(),
|
||||
v2FetchFlags(),
|
||||
v1FetchFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if useV2Fetch(runtime) {
|
||||
return validateFetchV2(ctx, runtime)
|
||||
}
|
||||
return nil
|
||||
return validateFetchV2(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if useV2Fetch(runtime) {
|
||||
return dryRunFetchV2(ctx, runtime)
|
||||
}
|
||||
return dryRunFetchV1(ctx, runtime)
|
||||
return dryRunFetchV2(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if useV2Fetch(runtime) {
|
||||
return executeFetchV2(ctx, runtime)
|
||||
}
|
||||
return executeFetchV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
|
||||
return executeFetchV2(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildFetchArgsV1(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: fetch-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "fetch-doc").Set("args", args)
|
||||
}
|
||||
|
||||
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+fetch")
|
||||
args := buildFetchArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
result["markdown"] = fixExportedMarkdown(md)
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
if title, ok := result["title"].(string); ok && title != "" {
|
||||
fmt.Fprintf(w, "# %s\n\n", title)
|
||||
}
|
||||
if md, ok := result["markdown"].(string); ok {
|
||||
fmt.Fprintln(w, md)
|
||||
}
|
||||
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
|
||||
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
if v := runtime.Str("offset"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["offset"] = n
|
||||
}
|
||||
if v := runtime.Str("limit"); v != "" {
|
||||
n, _ := strconv.Atoi(v)
|
||||
args["limit"] = n
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -16,16 +16,16 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
||||
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
||||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
|
||||
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ func v2FetchFlags() []common.Flag {
|
||||
// --dry-run so that invalid input fails with a structured exit code (2) and
|
||||
// JSON envelope instead of slipping through dry-run as a "success".
|
||||
func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+fetch", docsFetchLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return common.FlagErrorf("invalid --doc: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -58,6 +59,82 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", nil)
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
if len(dry.API) != 1 {
|
||||
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||
}
|
||||
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want {
|
||||
t.Fatalf("dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := dry.API[0].Body["format"], "xml"; got != want {
|
||||
t.Fatalf("dry-run format = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "v1", nil)
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
if len(dry.API) != 1 {
|
||||
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||
}
|
||||
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want {
|
||||
t.Fatalf("dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "legacy offset",
|
||||
setFlags: map[string]string{"offset": "10"},
|
||||
want: []string{
|
||||
"docs +fetch is v2-only",
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --offset are no longer supported",
|
||||
"--offset -> use --scope outline/range/keyword/section",
|
||||
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"lark-cli docs +fetch --help",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateFetchV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected v2-only validation error")
|
||||
}
|
||||
for _, want := range tt.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
@@ -73,6 +150,37 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("api-version", "", "")
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
cmd.Flags().String("offset", "", "")
|
||||
cmd.Flags().String("limit", "", "")
|
||||
if apiVersion != "" {
|
||||
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
|
||||
t.Fatalf("set api-version: %v", err)
|
||||
}
|
||||
}
|
||||
for name, value := range setFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
|
||||
@@ -5,57 +5,13 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var validModesV1 = map[string]bool{
|
||||
"append": true,
|
||||
"overwrite": true,
|
||||
"replace_range": true,
|
||||
"replace_all": true,
|
||||
"insert_before": true,
|
||||
"insert_after": true,
|
||||
"delete_range": true,
|
||||
}
|
||||
|
||||
var needsSelectionV1 = map[string]bool{
|
||||
"replace_range": true,
|
||||
"replace_all": true,
|
||||
"insert_before": true,
|
||||
"insert_after": true,
|
||||
"delete_range": true,
|
||||
}
|
||||
|
||||
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
|
||||
// v1UpdateFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||
func v1UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
|
||||
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
|
||||
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
|
||||
{Name: "new-title", Desc: "also update document title", Hidden: true},
|
||||
}
|
||||
}
|
||||
|
||||
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
|
||||
|
||||
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
|
||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
||||
func useV2Update(runtime *common.RuntimeContext) bool {
|
||||
if runtime.Str("api-version") == "v2" {
|
||||
return true
|
||||
}
|
||||
return runtime.Str("command") != "" ||
|
||||
runtime.Str("content") != "" ||
|
||||
runtime.Str("pattern") != "" ||
|
||||
runtime.Str("block-id") != "" ||
|
||||
runtime.Str("src-block-ids") != ""
|
||||
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
|
||||
}
|
||||
|
||||
var DocsUpdate = common.Shortcut{
|
||||
@@ -65,225 +21,22 @@ var DocsUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
PostMount: installDocsShortcutHelp("+update"),
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
docsAPIVersionCompatFlag(),
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
},
|
||||
v1UpdateFlags(),
|
||||
v2UpdateFlags(),
|
||||
v1UpdateFlags(),
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if useV2Update(runtime) {
|
||||
return validateUpdateV2(ctx, runtime)
|
||||
}
|
||||
return validateUpdateV1(ctx, runtime)
|
||||
return validateUpdateV2(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if useV2Update(runtime) {
|
||||
return dryRunUpdateV2(ctx, runtime)
|
||||
}
|
||||
return dryRunUpdateV1(ctx, runtime)
|
||||
return dryRunUpdateV2(ctx, runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if useV2Update(runtime) {
|
||||
return executeUpdateV2(ctx, runtime)
|
||||
}
|
||||
return executeUpdateV1(ctx, runtime)
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
|
||||
return executeUpdateV2(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
// ── V1 (MCP) implementation ──
|
||||
|
||||
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
mode := runtime.Str("mode")
|
||||
if mode == "" {
|
||||
return common.FlagErrorf("--mode is required")
|
||||
}
|
||||
if !validModesV1[mode] {
|
||||
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
|
||||
}
|
||||
|
||||
if mode != "delete_range" && runtime.Str("markdown") == "" {
|
||||
return common.FlagErrorf("--%s mode requires --markdown", mode)
|
||||
}
|
||||
|
||||
selEllipsis := runtime.Str("selection-with-ellipsis")
|
||||
selTitle := runtime.Str("selection-by-title")
|
||||
if selEllipsis != "" && selTitle != "" {
|
||||
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
|
||||
}
|
||||
|
||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf(selectionRequiredMessageV1(mode))
|
||||
}
|
||||
if err := validateSelectionByTitleV1(selTitle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectionRequiredMessageV1(mode string) string {
|
||||
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
if mode == "replace_all" {
|
||||
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func validateSelectionByTitleV1(title string) error {
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(title)
|
||||
if strings.Contains(trimmed, "\n") || strings.Contains(trimmed, "\r") {
|
||||
return common.FlagErrorf("--selection-by-title must be a single heading line (for example: '## Section')")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("--selection-by-title must include markdown heading prefix '#'. Example: --selection-by-title '## Section'")
|
||||
}
|
||||
|
||||
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
||||
Desc("MCP tool: update-doc").
|
||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
|
||||
Set("mcp_tool", "update-doc").Set("args", args)
|
||||
}
|
||||
|
||||
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+update")
|
||||
|
||||
// Static semantic checks run before the MCP call so users see
|
||||
// warnings even if the subsequent request fails. They never block
|
||||
// execution — the update still proceeds.
|
||||
for _, w := range docsUpdateWarnings(runtime.Str("mode"), runtime.Str("markdown")) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
|
||||
// Overwrite replaces the entire document, silently discarding any
|
||||
// whiteboard or file-attachment blocks that cannot be re-created from
|
||||
// Markdown. Pre-fetch the current content and warn when such blocks
|
||||
// are present so the caller can take a backup before proceeding.
|
||||
if runtime.Str("mode") == "overwrite" {
|
||||
if w := warnOverwriteResourceBlocks(runtime); w != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
"mode": runtime.Str("mode"),
|
||||
}
|
||||
if v := runtime.Str("markdown"); v != "" {
|
||||
args["markdown"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
||||
args["selection_with_ellipsis"] = v
|
||||
}
|
||||
if v := runtime.Str("selection-by-title"); v != "" {
|
||||
args["selection_by_title"] = v
|
||||
}
|
||||
if v := runtime.Str("new-title"); v != "" {
|
||||
args["new_title"] = v
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
|
||||
// (followed by whitespace, > or /) to avoid false positives on tag names like
|
||||
// <file-view> or prose that merely mentions the word "whiteboard".
|
||||
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
|
||||
|
||||
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
|
||||
// non-empty warning string when the document contains whiteboard or file
|
||||
// attachment blocks that would be permanently deleted by an overwrite. Returns
|
||||
// an empty string (no warning) when the document is clean or the fetch fails
|
||||
// (we never block the overwrite on a best-effort check).
|
||||
//
|
||||
// This function is not unit-tested because it depends on an external MCP call
|
||||
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
|
||||
// which has full table-driven coverage.
|
||||
//
|
||||
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
|
||||
// call, even when the document has no resource blocks. The cost is intentional:
|
||||
// the guard is best-effort and silent on failure, so the latency is bounded and
|
||||
// the trade-off is acceptable to avoid silent data loss.
|
||||
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// skip_task_detail reduces response payload by omitting per-block task
|
||||
// metadata, making the pre-fetch faster and cheaper.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
// Fetch failed — silently skip the guard rather than blocking overwrite.
|
||||
return ""
|
||||
}
|
||||
md, _ := result["markdown"].(string)
|
||||
return checkOverwriteResourceBlocks(md)
|
||||
}
|
||||
|
||||
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
|
||||
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
|
||||
// warning string listing the counts if any are found, empty string otherwise.
|
||||
func checkOverwriteResourceBlocks(markdown string) string {
|
||||
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
|
||||
whiteboards, files := 0, 0
|
||||
for _, m := range matches {
|
||||
switch m[1] {
|
||||
case "whiteboard":
|
||||
whiteboards++
|
||||
case "file":
|
||||
files++
|
||||
}
|
||||
}
|
||||
var found []string
|
||||
if whiteboards == 1 {
|
||||
found = append(found, "1 whiteboard block")
|
||||
} else if whiteboards > 1 {
|
||||
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
|
||||
}
|
||||
if files == 1 {
|
||||
found = append(found, "1 file attachment block")
|
||||
} else if files > 1 {
|
||||
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"the document contains %s that cannot be reconstructed from Markdown; "+
|
||||
"overwrite will permanently delete them. "+
|
||||
"Consider fetching a backup with `docs +fetch` before overwriting.",
|
||||
strings.Join(found, " and "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// docsUpdateWarnings returns a list of human-readable warnings for a
|
||||
// `docs +update` invocation based on static analysis of the mode and
|
||||
// Markdown payload. The warnings describe CLI/MCP contract edges that
|
||||
// commonly surprise users; the update is still executed — callers
|
||||
// decide whether to stop at a warning.
|
||||
//
|
||||
// Both checks ignore fenced code blocks (```…``` and ~~~…~~~, with up
|
||||
// to 3 leading spaces per CommonMark §4.5), inline code spans, and
|
||||
// backslash-escaped emphasis markers so that literal Markdown content
|
||||
// embedded in code samples or escaped prose does not produce false
|
||||
// positives.
|
||||
//
|
||||
// Warnings emitted (current):
|
||||
//
|
||||
// 1. replace_* modes do not split blocks. A Markdown payload containing
|
||||
// a blank line (\n\n) in prose implies the caller expects multiple
|
||||
// paragraphs, but replace_range / replace_all only swap in-block
|
||||
// text. The resulting block will contain the blank line as literal
|
||||
// text and appear as a single paragraph in the UI.
|
||||
//
|
||||
// 2. Lark does not round-trip bold+italic. Six shapes are detected:
|
||||
// ***text*** ___text___
|
||||
// **_text_** __*text*__
|
||||
// _**text**_ *__text__*
|
||||
// Lark stores only one of the two emphases (usually italic), silently
|
||||
// dropping the other. The user wanted both; they will get one.
|
||||
func docsUpdateWarnings(mode, markdown string) []string {
|
||||
var warnings []string
|
||||
if w := checkDocsUpdateReplaceMultilineMarkdown(mode, markdown); w != "" {
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
if w := checkDocsUpdateBoldItalic(markdown); w != "" {
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
// checkDocsUpdateReplaceMultilineMarkdown flags markdown that contains a
|
||||
// blank-line paragraph break outside fenced code blocks under a replace_*
|
||||
// mode. Blank lines inside code fences are literal content and don't
|
||||
// imply paragraph semantics, so they are deliberately ignored.
|
||||
func checkDocsUpdateReplaceMultilineMarkdown(mode, markdown string) string {
|
||||
if mode != "replace_range" && mode != "replace_all" {
|
||||
return ""
|
||||
}
|
||||
// A CR/LF-robust check: both "\n\n" and "\r\n\r\n" count as paragraph
|
||||
// separators. We normalize line endings once before detection.
|
||||
normalized := strings.ReplaceAll(markdown, "\r\n", "\n")
|
||||
if !proseHasBlankLine(normalized) {
|
||||
return ""
|
||||
}
|
||||
return "--mode=" + mode + " does not split a block into multiple paragraphs; " +
|
||||
"the blank line in --markdown will render as literal text. " +
|
||||
"For multiple paragraphs, use --mode=delete_range followed by --mode=insert_before."
|
||||
}
|
||||
|
||||
// combinedEmphasisPatterns holds the six documented combined-emphasis shapes
|
||||
// that Lark downgrades to a single emphasis. Each entry pairs a regex with a
|
||||
// short shape label for the warning message. The two forms per shape (with
|
||||
// and without `[^…]*?`) are there because the lazy quantifier needs at least
|
||||
// one non-delimiter character to match; single-rune payloads (e.g. `***X***`)
|
||||
// take the second alternation.
|
||||
var combinedEmphasisPatterns = []struct {
|
||||
shape string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
// Bold+italic with a single delimiter char.
|
||||
{"***text***", regexp.MustCompile(`\*\*\*\S[^*]*?\S\*\*\*|\*\*\*\S\*\*\*`)},
|
||||
{"___text___", regexp.MustCompile(`___\S[^_]*?\S___|___\S___`)},
|
||||
|
||||
// Bold wrapping italic (asterisk outside).
|
||||
{"**_text_**", regexp.MustCompile(`\*\*_\S[^_*]*?\S_\*\*|\*\*_\S_\*\*`)},
|
||||
{"__*text*__", regexp.MustCompile(`__\*\S[^_*]*?\S\*__|__\*\S\*__`)},
|
||||
|
||||
// Italic wrapping bold (asterisk inside).
|
||||
{"_**text**_", regexp.MustCompile(`_\*\*\S[^_*]*?\S\*\*_|_\*\*\S\*\*_`)},
|
||||
{"*__text__*", regexp.MustCompile(`\*__\S[^_*]*?\S__\*|\*__\S__\*`)},
|
||||
}
|
||||
|
||||
// checkDocsUpdateBoldItalic flags Markdown emphases that attempt to
|
||||
// combine bold and italic in a way Lark cannot represent. Fenced code
|
||||
// blocks, inline code spans, and backslash-escaped emphasis markers are
|
||||
// stripped first so that literal markdown examples ("here is a
|
||||
// `***keyword***` to flag") do not trigger the warning.
|
||||
func checkDocsUpdateBoldItalic(markdown string) string {
|
||||
if markdown == "" {
|
||||
return ""
|
||||
}
|
||||
sanitized := stripEscapedEmphasisMarkers(stripMarkdownCodeRegions(markdown))
|
||||
for _, p := range combinedEmphasisPatterns {
|
||||
if p.re.MatchString(sanitized) {
|
||||
return "Lark does not support combined bold+italic markers " +
|
||||
"(e.g. ***text***, ___text___, **_text_**, _**text**_, __*text*__, *__text__*); " +
|
||||
"the emphasis will be downgraded to either bold or italic. " +
|
||||
"Split into two separate emphases or drop one of them."
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// proseHasBlankLine reports whether markdown contains a blank line outside
|
||||
// of fenced code blocks. Blank lines inside ```...``` or ~~~...~~~ fences
|
||||
// are code content, not paragraph separators, and must not trip the
|
||||
// "replace_* cannot split paragraphs" warning.
|
||||
//
|
||||
// A blank line counts only when it sits between two non-blank boundaries
|
||||
// (other prose, or a fence open/close). A trailing empty line at EOF is
|
||||
// not treated as "\n\n".
|
||||
func proseHasBlankLine(markdown string) bool {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
inFence := false
|
||||
var fenceMarker string
|
||||
for i, line := range lines {
|
||||
if inFence {
|
||||
if isCodeFenceClose(line, fenceMarker) {
|
||||
inFence = false
|
||||
fenceMarker = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
||||
inFence = true
|
||||
fenceMarker = marker
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line) == "" && i > 0 && i+1 < len(lines) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stripMarkdownCodeRegions returns markdown with fenced code blocks blanked
|
||||
// out and inline code spans replaced by whitespace of equivalent length.
|
||||
// Byte offsets outside the masked regions are preserved, so follow-on
|
||||
// regex matches still point at real prose positions.
|
||||
func stripMarkdownCodeRegions(markdown string) string {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
inFence := false
|
||||
var fenceMarker string
|
||||
for i, line := range lines {
|
||||
if inFence {
|
||||
if isCodeFenceClose(line, fenceMarker) {
|
||||
inFence = false
|
||||
fenceMarker = ""
|
||||
}
|
||||
lines[i] = ""
|
||||
continue
|
||||
}
|
||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
||||
inFence = true
|
||||
fenceMarker = marker
|
||||
lines[i] = ""
|
||||
continue
|
||||
}
|
||||
lines[i] = maskInlineCodeSpans(line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// maskInlineCodeSpans replaces the byte ranges of any inline code spans in
|
||||
// line with space characters of equal length. Uses scanInlineCodeSpans from
|
||||
// markdown_fix.go, which implements the CommonMark §6.1 matching-backtick-run
|
||||
// rule (so “ `a`b` “ is a single span).
|
||||
func maskInlineCodeSpans(line string) string {
|
||||
spans := scanInlineCodeSpans(line)
|
||||
if len(spans) == 0 {
|
||||
return line
|
||||
}
|
||||
var sb strings.Builder
|
||||
pos := 0
|
||||
for _, loc := range spans {
|
||||
sb.WriteString(line[pos:loc[0]])
|
||||
sb.WriteString(strings.Repeat(" ", loc[1]-loc[0]))
|
||||
pos = loc[1]
|
||||
}
|
||||
sb.WriteString(line[pos:])
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// stripEscapedEmphasisMarkers removes backslash-escaped '*' and '_' so the
|
||||
// bold/italic regexes don't treat literal sequences like `\***text***` as
|
||||
// real combined emphasis. CommonMark renders "\*" as a literal "*" with no
|
||||
// emphasis semantics; dropping the escape + its target from the detection
|
||||
// input keeps the heuristic aligned with what the renderer actually does.
|
||||
//
|
||||
// Known limitation: a doubled backslash escape ("\\" followed by a real
|
||||
// emphasis marker, e.g. `\\***text***`) renders as a literal backslash
|
||||
// followed by genuine combined emphasis, but this strip is not a proper
|
||||
// parser and will instead consume the second backslash as the opener for
|
||||
// another escape. That hides the real emphasis from the check, producing
|
||||
// a false negative. Practical impact is small (this shape is rare in the
|
||||
// kind of AI-Agent prompts we target) and the alternative — a full
|
||||
// CommonMark escape parser — is not worth the code surface here.
|
||||
func stripEscapedEmphasisMarkers(s string) string {
|
||||
s = strings.ReplaceAll(s, `\*`, "")
|
||||
s = strings.ReplaceAll(s, `\_`, "")
|
||||
return s
|
||||
}
|
||||
|
||||
// codeFenceOpenMarker returns the fence marker (e.g. "```" or "~~~~") if
|
||||
// line opens a fenced code block, otherwise "". Applies CommonMark §4.5
|
||||
// rules: up to 3 leading spaces are tolerated; 4+ leading spaces (or any
|
||||
// leading tab, which expands to 4 columns) make the line an indented code
|
||||
// block rather than a fence.
|
||||
func codeFenceOpenMarker(line string) string {
|
||||
body, ok := fenceIndentOK(line)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(body, "```"):
|
||||
return leadingRun(body, '`')
|
||||
case strings.HasPrefix(body, "~~~"):
|
||||
return leadingRun(body, '~')
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isCodeFenceClose reports whether line closes a fence opened with marker.
|
||||
// Per CommonMark §4.5 the closer must use the same fence character, be at
|
||||
// least as long as the opener, sit within 0..3 leading spaces, and carry
|
||||
// no info-string text.
|
||||
func isCodeFenceClose(line, marker string) bool {
|
||||
if marker == "" {
|
||||
return false
|
||||
}
|
||||
body, ok := fenceIndentOK(line)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
fenceChar := marker[0]
|
||||
run := leadingRun(body, fenceChar)
|
||||
if len(run) < len(marker) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(body[len(run):]) == ""
|
||||
}
|
||||
|
||||
// fenceIndentOK returns (bodyWithoutLeadingSpaces, true) when line has
|
||||
// 0..3 leading spaces and no leading tab — i.e. the indentation is
|
||||
// permissible for a CommonMark fence. Returns ("", false) otherwise
|
||||
// (4+ leading spaces or any tab), meaning the line must be treated as
|
||||
// indented code block content rather than a fence boundary.
|
||||
func fenceIndentOK(line string) (string, bool) {
|
||||
for i := 0; i < len(line) && i < 4; i++ {
|
||||
switch line[i] {
|
||||
case ' ':
|
||||
continue
|
||||
case '\t':
|
||||
return "", false
|
||||
default:
|
||||
return line[i:], true
|
||||
}
|
||||
}
|
||||
// Reached index 4 without hitting a non-space character: too indented.
|
||||
if len(line) >= 4 {
|
||||
return "", false
|
||||
}
|
||||
// Line shorter than 4 chars and all spaces — still valid (empty content).
|
||||
return "", true
|
||||
}
|
||||
|
||||
// leadingRun returns the longest prefix of s made up of the byte c.
|
||||
func leadingRun(s string, c byte) string {
|
||||
i := 0
|
||||
for i < len(s) && s[i] == c {
|
||||
i++
|
||||
}
|
||||
return s[:i]
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckDocsUpdateReplaceMultilineMarkdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
markdown string
|
||||
wantHint bool
|
||||
}{
|
||||
{
|
||||
name: "replace_range with blank line emits hint",
|
||||
mode: "replace_range",
|
||||
markdown: "new paragraph\n\nsecond paragraph",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "replace_all with blank line emits hint",
|
||||
mode: "replace_all",
|
||||
markdown: "first\n\nsecond",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "replace_range single paragraph is fine",
|
||||
mode: "replace_range",
|
||||
markdown: "just a single paragraph of text",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "single newline is not a paragraph break",
|
||||
mode: "replace_range",
|
||||
markdown: "line one\nline two",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "crlf paragraph break is also detected",
|
||||
mode: "replace_range",
|
||||
markdown: "first\r\n\r\nsecond",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "other modes are not flagged",
|
||||
mode: "insert_before",
|
||||
markdown: "first\n\nsecond",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "append mode is not flagged",
|
||||
mode: "append",
|
||||
markdown: "first\n\nsecond",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "empty markdown is fine",
|
||||
mode: "replace_range",
|
||||
markdown: "",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// The check must ignore blank lines inside fenced code; otherwise
|
||||
// a user replacing one block with a legitimate code sample that
|
||||
// contains blank lines would see a spurious warning.
|
||||
name: "blank line inside backtick fenced code is not flagged",
|
||||
mode: "replace_range",
|
||||
markdown: "```\nline1\n\nline2\n```",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "blank line inside tilde fenced code is not flagged",
|
||||
mode: "replace_range",
|
||||
markdown: "~~~\ncode line one\n\ncode line two\n~~~",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Mixed prose + fenced code: any blank line in prose still wins,
|
||||
// even if the fenced content also contains blanks.
|
||||
name: "blank line in prose outside fence still flags even when fence has blanks",
|
||||
mode: "replace_range",
|
||||
markdown: "first paragraph\n\nsecond paragraph\n\n```\ncode\n\nmore\n```",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
// Fenced code with no blank lines inside must not trip on the
|
||||
// fence markers themselves.
|
||||
name: "fenced code with no blank lines does not flag",
|
||||
mode: "replace_range",
|
||||
markdown: "prose before\n```go\nfmt.Println(\"hi\")\n```\nprose after",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// CommonMark §4.5: the closing fence must be ≥ opening fence length.
|
||||
// A 4-backtick close for a 3-backtick open is a legitimate way to
|
||||
// embed triple-backticks in a code sample; the check must see the
|
||||
// fence as properly closed and not treat the rest of the document
|
||||
// as still-inside-fence.
|
||||
name: "longer close marker closes fence correctly",
|
||||
mode: "replace_range",
|
||||
markdown: "```\nsome code\n````\n\nprose paragraph after",
|
||||
wantHint: true, // the blank line AFTER the fence is real prose
|
||||
},
|
||||
{
|
||||
name: "longer close marker still hides blank line inside fence",
|
||||
mode: "replace_range",
|
||||
markdown: "```\nbefore\n\nafter\n````",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// 4+ leading spaces make the line an indented code block, not a
|
||||
// fence open. The "fence"-looking line is code content; the
|
||||
// surrounding blank must still be detected.
|
||||
name: "four-space indented fence-like line is not a fence open",
|
||||
mode: "replace_range",
|
||||
markdown: "first paragraph\n\n ```\n code\n ```",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
// A tab in the leading whitespace is always ≥4 columns and thus
|
||||
// forces indented-code-block semantics.
|
||||
name: "tab-indented fence-like line is not a fence open",
|
||||
mode: "replace_range",
|
||||
markdown: "first paragraph\n\n\t```\n\tcode\n\t```",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
// 3 leading spaces is still within the fence-tolerance window.
|
||||
name: "three-space indented fence is still a fence",
|
||||
mode: "replace_range",
|
||||
markdown: " ```\ncode\n\nmore\n ```",
|
||||
wantHint: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := checkDocsUpdateReplaceMultilineMarkdown(tt.mode, tt.markdown)
|
||||
hasHint := got != ""
|
||||
if hasHint != tt.wantHint {
|
||||
t.Fatalf("checkDocsUpdateReplaceMultilineMarkdown(%q, %q) = %q, wantHint=%v",
|
||||
tt.mode, tt.markdown, got, tt.wantHint)
|
||||
}
|
||||
if tt.wantHint && (!strings.Contains(got, "delete_range") || !strings.Contains(got, "insert_before")) {
|
||||
t.Errorf("hint should suggest delete_range/insert_before remediation, got: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDocsUpdateBoldItalic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHint bool
|
||||
}{
|
||||
{
|
||||
name: "triple asterisks flagged",
|
||||
input: "a ***key insight*** here",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "triple asterisks single char flagged",
|
||||
input: "a ***X*** here",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "bold wrapping underscore italic flagged",
|
||||
input: "note: **_important_** detail",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "underscore wrapping double asterisk flagged",
|
||||
input: "note: _**important**_ detail",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "plain bold is fine",
|
||||
input: "this is **bold** text",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "plain italic is fine",
|
||||
input: "this is *italic* or _italic_ text",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "horizontal rule is not flagged",
|
||||
input: "paragraph\n\n---\n\nnext",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "bold followed by italic with space is not flagged",
|
||||
input: "**bold** and *italic*",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "empty input is fine",
|
||||
input: "",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// The emphasis check must not fire on literal Markdown samples
|
||||
// inside a fenced code block — the canonical use case is docs
|
||||
// authors pasting tutorials that demonstrate these exact patterns.
|
||||
name: "triple asterisks inside backtick fenced code is not flagged",
|
||||
input: "example:\n```\nthe shape ***keyword*** downgrades\n```",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "underscore-bold inside fenced code is not flagged",
|
||||
input: "example:\n```markdown\nuse **_strong italic_** carefully\n```",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "bold-underscore inside fenced code is not flagged",
|
||||
input: "example:\n~~~\n_**outside-underscore**_ is a bad shape\n~~~",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "triple asterisks inside inline code span is not flagged",
|
||||
input: "the literal `***text***` marker is just a sample",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "underscore-bold inside inline code is not flagged",
|
||||
input: "the shape `**_italic_**` would downgrade, but only if it were real",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "escaped triple asterisks rendered as literal text is not flagged",
|
||||
input: `the literal \***text*** with escaped opener`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "escaped bold inside underscore-italic is not flagged",
|
||||
input: `shape \*\*_text_\*\* is literal, not emphasis`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Real emphasis outside the code span must still be detected —
|
||||
// the strip step must not over-sanitize.
|
||||
name: "real triple asterisks outside inline code still flags",
|
||||
input: "real ***strong*** and literal `***keyword***` — the first one counts",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "real triple asterisks outside fenced code still flags",
|
||||
input: "real ***strong***\n\n```\nliteral ***keyword*** in code\n```",
|
||||
wantHint: true,
|
||||
},
|
||||
// --- Triple-underscore combined emphasis: ___text___ ---
|
||||
{
|
||||
name: "triple underscores flagged",
|
||||
input: "a ___key insight___ here",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "triple underscores single char flagged",
|
||||
input: "a ___X___ here",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "triple underscores inside fenced code not flagged",
|
||||
input: "sample:\n```\nuse ___keyword___ carefully\n```",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "triple underscores inside inline code not flagged",
|
||||
input: "the literal `___phrase___` marker",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "escaped triple underscores not flagged",
|
||||
input: `literal \___phrase___ with escaped opener`,
|
||||
wantHint: false,
|
||||
},
|
||||
// --- Underscore-bold wrapping asterisk-italic: __*text*__ ---
|
||||
{
|
||||
name: "underscore-bold wrapping asterisk-italic flagged",
|
||||
input: "note: __*important*__ text",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "underscore-bold wrapping asterisk-italic inside fenced code not flagged",
|
||||
input: "```\nnote: __*important*__ sample\n```",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "underscore-bold wrapping asterisk-italic inside inline code not flagged",
|
||||
input: "literal `__*important*__` marker",
|
||||
wantHint: false,
|
||||
},
|
||||
// --- Asterisk-italic wrapping underscore-bold: *__text__* ---
|
||||
{
|
||||
name: "asterisk-italic wrapping underscore-bold flagged",
|
||||
input: "note: *__phrase__* text",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
name: "asterisk-italic wrapping underscore-bold inside fenced code not flagged",
|
||||
input: "```md\nnote: *__phrase__* sample\n```",
|
||||
wantHint: false,
|
||||
},
|
||||
// --- Positive tests: real emphasis in prose coexisting with fake in code ---
|
||||
{
|
||||
// Underscore-variant in prose must still fire when an asterisk
|
||||
// variant appears inside a code span — verifies the strip does
|
||||
// not over-sanitize across the six regex alternatives.
|
||||
name: "real triple underscores outside inline code still flag when asterisk variant is in code",
|
||||
input: "real ___strong___ and literal `***shape***` in code",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
// Longer close fence closes properly; real ***emphasis*** after
|
||||
// the fence must fire.
|
||||
name: "real emphasis after a fence closed by longer marker still flags",
|
||||
input: "```\nliteral ***phrase*** in code\n````\n\nand then real ***phrase*** after",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
// 4-space indented "```" is an indented code block, not a fence
|
||||
// open. The fence helper should refuse it; emphasis outside the
|
||||
// (non-existent) fence must still be detected.
|
||||
name: "four-space indented fence-like line does not open a fence for the emphasis check",
|
||||
input: "prose\n\n ```\n not a fence\n ```\n\nreal ***strong*** here",
|
||||
wantHint: true,
|
||||
},
|
||||
{
|
||||
// 3-space indented fence is valid per CommonMark. Emphasis inside
|
||||
// must be sanitized away, so the check must not fire.
|
||||
name: "three-space indented fence still hides triple-asterisk inside",
|
||||
input: " ```\n literal ***text*** inside\n ```",
|
||||
wantHint: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := checkDocsUpdateBoldItalic(tt.input)
|
||||
hasHint := got != ""
|
||||
if hasHint != tt.wantHint {
|
||||
t.Fatalf("checkDocsUpdateBoldItalic(%q) = %q, wantHint=%v", tt.input, got, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateWarningsAggregates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Both flags trigger: replace_range with blank line AND triple-asterisk.
|
||||
warnings := docsUpdateWarnings("replace_range", "***opening***\n\nsecond paragraph")
|
||||
if len(warnings) != 2 {
|
||||
t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateWarningsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Clean markdown in a non-replace mode produces zero warnings.
|
||||
warnings := docsUpdateWarnings("insert_before", "plain paragraph text")
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got: %v", warnings)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ── V2 tests ──
|
||||
@@ -31,199 +34,102 @@ func TestValidCommandsV2(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 tests ──
|
||||
func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) {
|
||||
for _, apiVersion := range []string{"v1", "v2"} {
|
||||
t.Run(apiVersion, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newUpdateShortcutTestRuntime(t, apiVersion, nil)
|
||||
if err := validateUpdateV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateUpdateV2() error = %v", err)
|
||||
}
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_all")
|
||||
for _, needle := range []string{
|
||||
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
|
||||
"replace the entire document body",
|
||||
"--mode overwrite",
|
||||
} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Fatalf("message missing %q: %s", needle, msg)
|
||||
}
|
||||
dry := decodeDocDryRun(t, DocsUpdate.DryRun(context.Background(), runtime))
|
||||
if len(dry.API) != 1 {
|
||||
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||
}
|
||||
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnUpdateDryRun"; got != want {
|
||||
t.Fatalf("dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := dry.API[0].Body["command"], "block_insert_after"; got != want {
|
||||
t.Fatalf("dry-run command = %#v, want %q", got, want)
|
||||
}
|
||||
if got, want := dry.API[0].Body["block_id"], "-1"; got != want {
|
||||
t.Fatalf("dry-run block_id = %#v, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_range")
|
||||
if strings.Contains(msg, "--mode overwrite") {
|
||||
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
|
||||
t.Fatalf("unexpected message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
t.Fatalf("expected blank whiteboard markdown to be treated as whiteboard creation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mermaid code block", func(t *testing.T) {
|
||||
markdown := "```mermaid\ngraph TD\nA-->B\n```"
|
||||
if !isWhiteboardCreateMarkdown(markdown) {
|
||||
t.Fatalf("expected mermaid markdown to be treated as whiteboard creation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain markdown", func(t *testing.T) {
|
||||
markdown := "## plain text"
|
||||
if isWhiteboardCreateMarkdown(markdown) {
|
||||
t.Fatalf("did not expect plain markdown to be treated as whiteboard creation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckOverwriteResourceBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
wantWarn bool
|
||||
wantSubs []string
|
||||
setFlags map[string]string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty markdown is clean",
|
||||
markdown: "",
|
||||
wantWarn: false,
|
||||
},
|
||||
{
|
||||
name: "plain prose is clean",
|
||||
markdown: "## Heading\n\nsome text",
|
||||
wantWarn: false,
|
||||
},
|
||||
{
|
||||
name: "single whiteboard triggers warning",
|
||||
markdown: `<whiteboard token="abc123"/>`,
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 whiteboard block", "overwrite"},
|
||||
},
|
||||
{
|
||||
name: "multiple whiteboards counted",
|
||||
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"2 whiteboard blocks"},
|
||||
},
|
||||
{
|
||||
name: "single file attachment triggers warning",
|
||||
markdown: `<file token="tok" name="report.pdf"/>`,
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 file attachment block"},
|
||||
},
|
||||
{
|
||||
name: "multiple file attachments counted",
|
||||
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"3 file attachment blocks"},
|
||||
},
|
||||
{
|
||||
name: "whiteboard and file together both counted",
|
||||
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
|
||||
name: "legacy mode",
|
||||
setFlags: map[string]string{"mode": "overwrite"},
|
||||
want: []string{
|
||||
"docs +update is v2-only",
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --mode are no longer supported",
|
||||
"--mode -> use --command",
|
||||
"lark-cli skills read lark-doc references/lark-doc-update.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"follow the latest format rules",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"lark-cli docs +update --help",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := checkOverwriteResourceBlocks(tt.markdown)
|
||||
if (got != "") != tt.wantWarn {
|
||||
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateUpdateV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected v2-only validation error")
|
||||
}
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("expected warning to contain %q, got: %s", sub, got)
|
||||
for _, want := range tt.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "+update"}
|
||||
cmd.Flags().String("api-version", "", "")
|
||||
cmd.Flags().String("doc", "doxcnUpdateDryRun", "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
cmd.Flags().String("mode", "", "")
|
||||
cmd.Flags().String("markdown", "", "")
|
||||
cmd.Flags().String("selection-with-ellipsis", "", "")
|
||||
cmd.Flags().String("selection-by-title", "", "")
|
||||
cmd.Flags().String("new-title", "", "")
|
||||
if apiVersion != "" {
|
||||
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
|
||||
t.Fatalf("set api-version: %v", err)
|
||||
}
|
||||
|
||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
|
||||
got, ok := result["board_tokens"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"])
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty board_tokens, got %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalizes board_tokens to string slice", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"board_tokens": []interface{}{"board_1", "board_2"},
|
||||
}
|
||||
|
||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
||||
|
||||
want := []string{"board_1", "board_2"}
|
||||
got, ok := result["board_tokens"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"])
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("board_tokens mismatch: got %#v want %#v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaves non whiteboard response unchanged", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
}
|
||||
|
||||
normalizeWhiteboardResult(result, "## plain text")
|
||||
|
||||
if _, ok := result["board_tokens"]; ok {
|
||||
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateSelectionByTitleV1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
wantErr bool
|
||||
errSub string
|
||||
}{
|
||||
{name: "empty title is valid", title: "", wantErr: false},
|
||||
{name: "single heading is valid", title: "## Section", wantErr: false},
|
||||
{name: "h1 heading is valid", title: "# Top", wantErr: false},
|
||||
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
|
||||
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
|
||||
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
|
||||
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
|
||||
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateSelectionByTitleV1(tt.title)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
|
||||
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
|
||||
}
|
||||
})
|
||||
for name, value := range setFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ 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: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "regex pattern for str_replace", Hidden: true},
|
||||
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
|
||||
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
|
||||
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{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: "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: "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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ func validCommandsV2Keys() []string {
|
||||
}
|
||||
|
||||
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return common.FlagErrorf("invalid --doc: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// fixExportedMarkdown applies post-processing to Lark-exported Markdown to
|
||||
// improve round-trip fidelity on re-import:
|
||||
//
|
||||
// 1. fixBoldSpacing: removes trailing whitespace before closing ** / *,
|
||||
// and strips redundant ** from ATX headings. Applied only outside fenced
|
||||
// code blocks, and skips inline code spans.
|
||||
//
|
||||
// 2. normalizeNestedListIndentation: rewrites space-pair-indented nested list
|
||||
// markers to tab-indented markers. This avoids nested ordered list items
|
||||
// being flattened or interpreted as plain text/code on re-import.
|
||||
//
|
||||
// 3. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
|
||||
// follows a non-empty line, preventing it from being parsed as a Setext H2.
|
||||
// Applied only outside fenced code blocks.
|
||||
//
|
||||
// 4. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
|
||||
// consecutive blockquote content lines so create-doc preserves line breaks.
|
||||
// Applied only outside fenced code blocks.
|
||||
//
|
||||
// 5. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
|
||||
// lines at the top level and inside content containers (callout,
|
||||
// quote-container, lark-td). Code fences are left untouched, and
|
||||
// consecutive list items / continuations are not separated.
|
||||
//
|
||||
// 6. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
|
||||
// actual Unicode emoji characters that create-doc understands. Applied only
|
||||
// outside fenced code blocks.
|
||||
func fixExportedMarkdown(md string) string {
|
||||
md = applyOutsideCodeFences(md, fixBoldSpacing)
|
||||
md = applyOutsideCodeFences(md, normalizeNestedListIndentation)
|
||||
md = applyOutsideCodeFences(md, fixSetextAmbiguity)
|
||||
md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks)
|
||||
md = fixTopLevelSoftbreaks(md)
|
||||
md = applyOutsideCodeFences(md, fixCalloutEmoji)
|
||||
// Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line),
|
||||
// but only outside fenced code blocks to preserve intentional blank lines in code.
|
||||
md = applyOutsideCodeFences(md, func(s string) string {
|
||||
for strings.Contains(s, "\n\n\n") {
|
||||
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
|
||||
}
|
||||
return s
|
||||
})
|
||||
md = strings.TrimRight(md, "\n") + "\n"
|
||||
return md
|
||||
}
|
||||
|
||||
// applyOutsideCodeFences applies fn only to content outside fenced code blocks.
|
||||
// Lines inside fenced code blocks (``` ... ```) are passed through unchanged,
|
||||
// preventing transforms from corrupting literal code content.
|
||||
func applyOutsideCodeFences(md string, fn func(string) string) string {
|
||||
lines := strings.Split(md, "\n")
|
||||
var out []string
|
||||
var chunk []string
|
||||
inCode := false
|
||||
|
||||
flush := func() {
|
||||
if len(chunk) == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, strings.Split(fn(strings.Join(chunk, "\n")), "\n")...)
|
||||
chunk = chunk[:0]
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
if !inCode {
|
||||
flush()
|
||||
inCode = true
|
||||
} else if trimmed == "```" {
|
||||
inCode = false
|
||||
}
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
if inCode {
|
||||
out = append(out, line)
|
||||
} else {
|
||||
chunk = append(chunk, line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// fixBlockquoteHardBreaks inserts a blank blockquote line (">") between
|
||||
// consecutive blockquote content lines. This forces each line into its own
|
||||
// paragraph within the blockquote, so MCP create-doc preserves line breaks
|
||||
// instead of collapsing them into a single paragraph.
|
||||
//
|
||||
// Before: "> line1\n> line2" → After: "> line1\n>\n> line2"
|
||||
func fixBlockquoteHardBreaks(md string) string {
|
||||
lines := strings.Split(md, "\n")
|
||||
out := make([]string, 0, len(lines)*2)
|
||||
for i, line := range lines {
|
||||
out = append(out, line)
|
||||
if strings.HasPrefix(line, "> ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "> ") {
|
||||
out = append(out, ">")
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// fixBoldSpacing normalizes emphasis markers exported by Lark while preserving
|
||||
// inline code spans:
|
||||
//
|
||||
// 1. Removes leading whitespace after opening ** and * delimiters:
|
||||
// "** text**" → "**text**", "* text*" → "*text*"
|
||||
//
|
||||
// 2. Removes trailing whitespace before closing ** and * delimiters:
|
||||
// "**text **" → "**text**", "*text *" → "*text*"
|
||||
//
|
||||
// 3. Removes redundant bold around an entire ATX heading:
|
||||
// "# **text**" → "# text"
|
||||
//
|
||||
// The bold and italic spacing fixes only run on non-code segments so literal
|
||||
// code content is left unchanged.
|
||||
var (
|
||||
// headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings
|
||||
// that contain multiple disjoint bold spans such as "# **foo** and **bar**".
|
||||
headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`)
|
||||
)
|
||||
|
||||
func fixBoldSpacing(md string) string {
|
||||
lines := strings.Split(md, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = fixBoldSpacingLine(line)
|
||||
}
|
||||
md = strings.Join(lines, "\n")
|
||||
md = headingBoldRe.ReplaceAllString(md, "$1 $2")
|
||||
return md
|
||||
}
|
||||
|
||||
// atxHeadingRe matches ATX heading lines (# ... through ###### ...).
|
||||
var atxHeadingRe = regexp.MustCompile(`^#{1,6}\s`)
|
||||
|
||||
// scanInlineCodeSpans returns the byte ranges [start, end) of all inline code
|
||||
// spans in line. It handles multi-backtick delimiters (e.g. “ `foo` “) by
|
||||
// finding the opening run of N backticks and searching for the next identical
|
||||
// run to close the span, per CommonMark spec §6.1.
|
||||
func scanInlineCodeSpans(line string) [][2]int {
|
||||
var spans [][2]int
|
||||
i := 0
|
||||
for i < len(line) {
|
||||
if line[i] != '`' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Count the opening backtick run.
|
||||
start := i
|
||||
for i < len(line) && line[i] == '`' {
|
||||
i++
|
||||
}
|
||||
delim := line[start:i] // e.g. "`" or "``" or "```"
|
||||
// Search for the closing run of the same length.
|
||||
j := i
|
||||
for j <= len(line)-len(delim) {
|
||||
if line[j] == '`' {
|
||||
k := j
|
||||
for k < len(line) && line[k] == '`' {
|
||||
k++
|
||||
}
|
||||
if k-j == len(delim) {
|
||||
spans = append(spans, [2]int{start, k})
|
||||
i = k
|
||||
break
|
||||
}
|
||||
j = k // skip this backtick run and keep searching
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
}
|
||||
// No closing delimiter found — not a code span, continue.
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line,
|
||||
// skipping content inside inline code spans to avoid corrupting literal code.
|
||||
// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing
|
||||
// handles them separately, keeping heading-only normalization isolated from the
|
||||
// inline emphasis spacing scanner below.
|
||||
func fixBoldSpacingLine(line string) string {
|
||||
if atxHeadingRe.MatchString(line) {
|
||||
return line
|
||||
}
|
||||
spans := scanInlineCodeSpans(line)
|
||||
if len(spans) == 0 {
|
||||
return fixEmphasisSpacingSegment(line)
|
||||
}
|
||||
var sb strings.Builder
|
||||
pos := 0
|
||||
for _, loc := range spans {
|
||||
// Process the non-code segment before this inline code span.
|
||||
seg := line[pos:loc[0]]
|
||||
sb.WriteString(fixEmphasisSpacingSegment(seg))
|
||||
// Preserve inline code span as-is.
|
||||
sb.WriteString(line[loc[0]:loc[1]])
|
||||
pos = loc[1]
|
||||
}
|
||||
// Remaining non-code segment after the last code span.
|
||||
sb.WriteString(fixEmphasisSpacingSegment(line[pos:]))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// fixEmphasisSpacingSegment trims only the whitespace immediately inside simple
|
||||
// *...* and **...** spans. It deliberately ignores runs of 3+ asterisks and
|
||||
// any candidate whose payload contains another asterisk so nested emphasis-like
|
||||
// text remains untouched. When both inner sides contain whitespace, single-rune
|
||||
// payloads are preserved as literal text (for example "* x *" and "** x **").
|
||||
func fixEmphasisSpacingSegment(seg string) string {
|
||||
if !strings.Contains(seg, "*") {
|
||||
return seg
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
pos := 0
|
||||
for pos < len(seg) {
|
||||
openStart, openEnd, ok := nextAsteriskRun(seg, pos)
|
||||
if !ok {
|
||||
sb.WriteString(seg[pos:])
|
||||
break
|
||||
}
|
||||
|
||||
sb.WriteString(seg[pos:openStart])
|
||||
|
||||
markerLen := openEnd - openStart
|
||||
if markerLen != 1 && markerLen != 2 {
|
||||
sb.WriteString(seg[openStart:openEnd])
|
||||
pos = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd)
|
||||
if !ok || closeEnd-closeStart != markerLen {
|
||||
sb.WriteString(seg[openStart:openEnd])
|
||||
pos = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
payload := seg[openEnd:closeStart]
|
||||
normalized, shouldNormalize := normalizeEmphasisPayload(payload)
|
||||
if !shouldNormalize {
|
||||
sb.WriteString(seg[openStart:closeEnd])
|
||||
pos = closeEnd
|
||||
continue
|
||||
}
|
||||
|
||||
marker := seg[openStart:openEnd]
|
||||
sb.WriteString(marker)
|
||||
sb.WriteString(normalized)
|
||||
sb.WriteString(marker)
|
||||
pos = closeEnd
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) {
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] != '*' {
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(s) && s[j] == '*' {
|
||||
j++
|
||||
}
|
||||
return i, j, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func normalizeEmphasisPayload(payload string) (string, bool) {
|
||||
trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace)
|
||||
trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace)
|
||||
if trimmed == "" {
|
||||
return payload, false
|
||||
}
|
||||
|
||||
hasLeadingSpace := len(trimmedLeft) != len(payload)
|
||||
hasTrailingSpace := len(trimmed) != len(trimmedLeft)
|
||||
if !hasLeadingSpace && !hasTrailingSpace {
|
||||
return payload, true
|
||||
}
|
||||
|
||||
if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 {
|
||||
return payload, false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`)
|
||||
|
||||
func fixSetextAmbiguity(md string) string {
|
||||
return setextRe.ReplaceAllString(md, "$1\n\n$2")
|
||||
}
|
||||
|
||||
// calloutTypeColors maps the semantic type= shorthand to a recommended
|
||||
// [background-color, border-color] pair for Feishu callout blocks.
|
||||
// Used only for hint messages — the Markdown itself is never rewritten.
|
||||
var calloutTypeColors = map[string][2]string{
|
||||
"warning": {"light-yellow", "yellow"},
|
||||
"caution": {"light-orange", "orange"},
|
||||
"note": {"light-blue", "blue"},
|
||||
"info": {"light-blue", "blue"},
|
||||
"tip": {"light-green", "green"},
|
||||
"success": {"light-green", "green"},
|
||||
"check": {"light-green", "green"},
|
||||
"error": {"light-red", "red"},
|
||||
"danger": {"light-red", "red"},
|
||||
"important": {"light-purple", "purple"},
|
||||
}
|
||||
|
||||
// calloutOpenTagRe matches a <callout …> opening tag.
|
||||
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
|
||||
|
||||
// calloutTypeAttrRe extracts the value of a type= attribute (single or
|
||||
// double quoted) from a callout opening tag's attribute string. The
|
||||
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
|
||||
// word/non-word boundary, and `-` is a non-word character, so
|
||||
// `\btype=` would also match the suffix of `data-type=` and yield a
|
||||
// bogus type lookup. Anchoring on start-of-string-or-whitespace
|
||||
// requires a real attribute separator before the name.
|
||||
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
|
||||
|
||||
// calloutBackgroundColorAttrRe matches a background-color= attribute
|
||||
// name with optional whitespace around the equals sign, so forms like
|
||||
// `background-color="..."` and `background-color = "..."` are both
|
||||
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
|
||||
// reason: `data-background-color="..."` must not look like a present
|
||||
// background-color and silently suppress the hint.
|
||||
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
|
||||
|
||||
// WarnCalloutType scans md for callout tags that carry a type= attribute but
|
||||
// no background-color= attribute, then writes a hint line to w for each one
|
||||
// suggesting the explicit Feishu color attributes to use instead.
|
||||
//
|
||||
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
|
||||
// are documentation samples, not real callouts the user wants Feishu to
|
||||
// render. Fence detection uses the shared codeFenceOpenMarker /
|
||||
// isCodeFenceClose helpers so both backtick and tilde fences are handled
|
||||
// (matching CommonMark §4.5).
|
||||
//
|
||||
// The Markdown is not modified — the caller is responsible for acting on
|
||||
// the hints or ignoring them. This keeps the create/update path
|
||||
// transparent: user input reaches create-doc exactly as written.
|
||||
func WarnCalloutType(md string, w io.Writer) {
|
||||
fenceMarker := ""
|
||||
for _, line := range strings.Split(md, "\n") {
|
||||
if fenceMarker != "" {
|
||||
// Inside a fenced block — skip everything until the matching
|
||||
// closer. Code samples that show literal <callout type=...>
|
||||
// must not produce a phantom hint.
|
||||
if isCodeFenceClose(line, fenceMarker) {
|
||||
fenceMarker = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
||||
fenceMarker = marker
|
||||
continue
|
||||
}
|
||||
scanCalloutTagsForWarning(line, w)
|
||||
}
|
||||
}
|
||||
|
||||
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
|
||||
// tag in s that lacks an explicit background-color= attribute. Pulled out
|
||||
// of WarnCalloutType so the line walker only handles fence state and the
|
||||
// per-tag scan is its own readable unit.
|
||||
//
|
||||
// The previous implementation routed the tag iteration through
|
||||
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
|
||||
// returned the original tag and threw the rebuilt string away — using a
|
||||
// rewrite primitive purely for its iteration side-effect, plus a second
|
||||
// regex execution to recover the capture groups inside the callback.
|
||||
// FindAllStringSubmatch hands us both the iteration and the groups in one
|
||||
// pass, no allocation thrown away.
|
||||
func scanCalloutTagsForWarning(s string, w io.Writer) {
|
||||
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
|
||||
attrs := m[1]
|
||||
// Skip tags that already carry an explicit background-color.
|
||||
if calloutBackgroundColorAttrRe.MatchString(attrs) {
|
||||
continue
|
||||
}
|
||||
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
|
||||
if len(parts) < 3 {
|
||||
continue // no type= attribute
|
||||
}
|
||||
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
|
||||
typeName := parts[1]
|
||||
if typeName == "" {
|
||||
typeName = parts[2]
|
||||
}
|
||||
colors, ok := calloutTypeColors[typeName]
|
||||
if !ok {
|
||||
continue // unknown type — no hint to give
|
||||
}
|
||||
fmt.Fprintf(w,
|
||||
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
|
||||
typeName, colors[0], colors[1])
|
||||
}
|
||||
}
|
||||
|
||||
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
|
||||
// Unicode emoji characters that create-doc accepts.
|
||||
var calloutEmojiAliases = map[string]string{
|
||||
"warning": "⚠️",
|
||||
"note": "📝",
|
||||
"tip": "💡",
|
||||
"info": "ℹ️",
|
||||
"check": "✅",
|
||||
"success": "✅",
|
||||
"error": "❌",
|
||||
"danger": "🚨",
|
||||
"important": "❗",
|
||||
"caution": "⚠️",
|
||||
"question": "❓",
|
||||
"forbidden": "🚫",
|
||||
"fire": "🔥",
|
||||
"star": "⭐",
|
||||
"pin": "📌",
|
||||
"clock": "🕐",
|
||||
"gift": "🎁",
|
||||
"eyes": "👀",
|
||||
"bulb": "💡",
|
||||
"memo": "📝",
|
||||
"link": "🔗",
|
||||
"key": "🔑",
|
||||
"lock": "🔒",
|
||||
"thumbsup": "👍",
|
||||
"thumbsdown": "👎",
|
||||
"rocket": "🚀",
|
||||
"construction": "🚧",
|
||||
}
|
||||
|
||||
// calloutEmojiRe matches emoji="<name>" in callout opening tags.
|
||||
var calloutEmojiRe = regexp.MustCompile(`(<callout[^>]*\bemoji=")([^"]+)(")`)
|
||||
|
||||
// fixCalloutEmoji replaces named emoji aliases in callout tags with actual
|
||||
// Unicode emoji characters. fetch-doc sometimes emits emoji="warning" instead
|
||||
// of emoji="⚠️"; create-doc only accepts Unicode emoji.
|
||||
func fixCalloutEmoji(md string) string {
|
||||
return calloutEmojiRe.ReplaceAllStringFunc(md, func(match string) string {
|
||||
parts := calloutEmojiRe.FindStringSubmatch(match)
|
||||
if len(parts) != 4 {
|
||||
return match
|
||||
}
|
||||
name := parts[2]
|
||||
if emoji, ok := calloutEmojiAliases[name]; ok {
|
||||
return parts[1] + emoji + parts[3]
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// isTableStructuralTag returns true for lark-table tags that are structural
|
||||
// (table/tr/td open/close) and should not themselves trigger blank-line insertion.
|
||||
func isTableStructuralTag(s string) bool {
|
||||
return strings.HasPrefix(s, "<lark-t") ||
|
||||
strings.HasPrefix(s, "</lark-t")
|
||||
}
|
||||
|
||||
// contentContainers lists block tags whose interior should have blank lines
|
||||
// inserted between adjacent content lines (same treatment as lark-td).
|
||||
var contentContainers = [][2]string{
|
||||
{"<lark-td>", "</lark-td>"},
|
||||
{"<callout", "</callout>"},
|
||||
{"<quote-container>", "</quote-container>"},
|
||||
}
|
||||
|
||||
// listItemRe matches unordered and ordered list item markers, including
|
||||
// indented (nested) items.
|
||||
var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `)
|
||||
|
||||
// nestedListIndentRe matches nested list item markers indented with pairs of
|
||||
// spaces. We rewrite those space pairs to tabs because some downstream
|
||||
// round-trip paths treat multi-space indented ordered items as flat items or
|
||||
// literal text, while tab indentation remains nested and avoids 4-space code
|
||||
// block ambiguity.
|
||||
var nestedListIndentRe = regexp.MustCompile(`^( {2,})([-*+]|\d+[.)]) `)
|
||||
|
||||
func normalizeNestedListIndentation(md string) string {
|
||||
lines := strings.Split(md, "\n")
|
||||
for i, line := range lines {
|
||||
matches := nestedListIndentRe.FindStringSubmatch(line)
|
||||
if len(matches) != 3 {
|
||||
continue
|
||||
}
|
||||
if !hasPreviousNonBlankListItem(lines, i) {
|
||||
continue
|
||||
}
|
||||
indent := matches[1]
|
||||
if len(indent)%2 != 0 {
|
||||
continue
|
||||
}
|
||||
tabs := strings.Repeat("\t", len(indent)/2)
|
||||
lines[i] = tabs + line[len(indent):]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func hasPreviousNonBlankListItem(lines []string, index int) bool {
|
||||
for i := index - 1; i >= 0; i-- {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
return listItemRe.MatchString(lines[i])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isListItemOrContinuation returns true for lines that are part of a list:
|
||||
// either a list item marker line or an indented continuation of a list item.
|
||||
// This is used to prevent blank lines being inserted between tight list lines,
|
||||
// which would turn a tight list into a loose list and change rendering.
|
||||
func isListItemOrContinuation(line string) bool {
|
||||
if listItemRe.MatchString(line) {
|
||||
return true
|
||||
}
|
||||
// Continuation lines are indented by at least 2 spaces or 1 tab.
|
||||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||||
}
|
||||
|
||||
// fixTopLevelSoftbreaks ensures that adjacent non-empty content lines are
|
||||
// separated by a blank line in the following contexts:
|
||||
// 1. Top level (depth == 0): every Lark block becomes its own Markdown paragraph.
|
||||
// 2. Inside content containers (<lark-td>, <callout>, <quote-container>):
|
||||
// multi-line content is preserved as separate paragraphs.
|
||||
//
|
||||
// Structural table tags (<lark-table>, <lark-tr>, <lark-td> and their closing
|
||||
// counterparts) never trigger blank-line insertion themselves. Fenced code
|
||||
// blocks (``` ... ```) are left completely untouched. Consecutive list items
|
||||
// and list continuations are not separated (to preserve tight lists).
|
||||
func fixTopLevelSoftbreaks(md string) string {
|
||||
lines := strings.Split(md, "\n")
|
||||
out := make([]string, 0, len(lines)*2)
|
||||
|
||||
inCodeBlock := false
|
||||
// containerDepth > 0 means we are inside a content container.
|
||||
containerDepth := 0
|
||||
// tableDepth tracks <lark-table> nesting (outer structure, not content).
|
||||
tableDepth := 0
|
||||
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// --- Track fenced code blocks — skip all processing inside. ---
|
||||
// Any ``` line opens a block; only plain ``` (no language id) closes it.
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
if inCodeBlock {
|
||||
if trimmed == "```" {
|
||||
inCodeBlock = false
|
||||
}
|
||||
} else {
|
||||
inCodeBlock = true
|
||||
}
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if !inCodeBlock {
|
||||
// --- Track content containers. ---
|
||||
for _, cc := range contentContainers {
|
||||
if strings.HasPrefix(trimmed, cc[0]) {
|
||||
containerDepth++
|
||||
}
|
||||
if strings.Contains(trimmed, cc[1]) {
|
||||
containerDepth--
|
||||
if containerDepth < 0 {
|
||||
containerDepth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Track table structure (outer, non-content). ---
|
||||
if strings.HasPrefix(trimmed, "<lark-table") {
|
||||
tableDepth++
|
||||
}
|
||||
if strings.Contains(trimmed, "</lark-table>") {
|
||||
tableDepth--
|
||||
if tableDepth < 0 {
|
||||
tableDepth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Decide whether to insert a blank line before this line. ---
|
||||
if !inCodeBlock && trimmed != "" && i > 0 {
|
||||
// Skip structural table tags — they are not content lines.
|
||||
isStructural := isTableStructuralTag(trimmed)
|
||||
|
||||
// Don't split consecutive blockquote lines ("> ...") — they form
|
||||
// one continuous blockquote in the original document.
|
||||
isBlockquote := strings.HasPrefix(trimmed, "> ") || trimmed == ">"
|
||||
|
||||
// Only closing container tags suppress blank-line insertion.
|
||||
// Opening container tags may still receive a blank line before them
|
||||
// (e.g. two consecutive <callout> blocks need a blank between them).
|
||||
isContainerTag := false
|
||||
for _, cc := range contentContainers {
|
||||
closingTag := "</" + cc[0][1:]
|
||||
if strings.HasPrefix(trimmed, closingTag) {
|
||||
isContainerTag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Insert blank line when:
|
||||
// - at top level (tableDepth == 0, containerDepth == 0), OR
|
||||
// - inside a content container (containerDepth > 0, not in outer table)
|
||||
// AND this line is actual content (not structural/blockquote/container-tag).
|
||||
inContent := tableDepth == 0 || containerDepth > 0
|
||||
if !isStructural && !isBlockquote && !isContainerTag && inContent {
|
||||
// Don't split consecutive list items / continuations — inserting a
|
||||
// blank line between them turns a tight list into a loose list.
|
||||
isListRelated := isListItemOrContinuation(line)
|
||||
prevIsListRelated := len(out) > 0 && isListItemOrContinuation(out[len(out)-1])
|
||||
if !(isListRelated && prevIsListRelated) {
|
||||
prev := ""
|
||||
if len(out) > 0 {
|
||||
prev = strings.TrimSpace(out[len(out)-1])
|
||||
}
|
||||
if prev != "" && !isTableStructuralTag(prev) {
|
||||
out = append(out, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, line)
|
||||
}
|
||||
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFixExportedMarkdownIdempotent asserts the core promise of the exported
|
||||
// markdown pipeline: applying the fixes twice produces the same result as
|
||||
// applying them once. Round-trip formatting relies on this invariant, so any
|
||||
// transform that keeps rewriting its own output would break fetch → edit →
|
||||
// update → fetch stability.
|
||||
func TestFixExportedMarkdownIdempotent(t *testing.T) {
|
||||
fixtures := map[string]string{
|
||||
"kitchen sink": strings.Join([]string{
|
||||
"# **Title**",
|
||||
"paragraph one",
|
||||
"paragraph two",
|
||||
"**bold ** and * italic*",
|
||||
"",
|
||||
"> q1",
|
||||
"> q2",
|
||||
"",
|
||||
"1. parent",
|
||||
" 1. child",
|
||||
" 1. grandchild",
|
||||
"",
|
||||
"<callout emoji=\"warning\">",
|
||||
"callout body line 1",
|
||||
"callout body line 2",
|
||||
"</callout>",
|
||||
"",
|
||||
"some text",
|
||||
"---",
|
||||
"",
|
||||
"```go",
|
||||
"// code content with markdown-like shapes must survive as-is",
|
||||
"**foo **",
|
||||
"* hello*",
|
||||
" 1. nested",
|
||||
"> q",
|
||||
"---",
|
||||
"```",
|
||||
"",
|
||||
}, "\n"),
|
||||
|
||||
"cjk content": strings.Join([]string{
|
||||
"# **测试标题**",
|
||||
"段落一",
|
||||
"段落二",
|
||||
"**有用性 ** and * 关键 *",
|
||||
"",
|
||||
"1. 父项",
|
||||
" 1. 子项",
|
||||
"",
|
||||
}, "\n"),
|
||||
|
||||
"nested containers": strings.Join([]string{
|
||||
"<callout emoji=\"info\">",
|
||||
"line a",
|
||||
"line b",
|
||||
"</callout>",
|
||||
"",
|
||||
"<quote-container>",
|
||||
"quoted 1",
|
||||
"quoted 2",
|
||||
"</quote-container>",
|
||||
"",
|
||||
}, "\n"),
|
||||
}
|
||||
|
||||
for name, fixture := range fixtures {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
once := fixExportedMarkdown(fixture)
|
||||
twice := fixExportedMarkdown(once)
|
||||
if once != twice {
|
||||
t.Errorf("fixExportedMarkdown is not idempotent for %q\nfirst pass:\n%s\nsecond pass:\n%s",
|
||||
name, once, twice)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixExportedMarkdownPreservesFencedCodeByteForByte packs a fenced code
|
||||
// block with content that every individual transform in the pipeline would
|
||||
// normally rewrite, and asserts the fence content comes out byte-for-byte
|
||||
// identical. This is the pipeline's strongest invariant — users' code samples
|
||||
// must never be silently modified by a formatting pass.
|
||||
func TestFixExportedMarkdownPreservesFencedCodeByteForByte(t *testing.T) {
|
||||
// Every line below is something at least one transform would touch if it
|
||||
// appeared outside a fence. None of it must change.
|
||||
dangerous := strings.Join([]string{
|
||||
"**foo **", // fixBoldSpacing — trailing space bold
|
||||
"* hello*", // fixBoldSpacing — leading space italic
|
||||
"# **heading**", // fixBoldSpacing — redundant heading bold
|
||||
"para1", // fixTopLevelSoftbreaks — adjacent paragraphs
|
||||
"para2",
|
||||
"> q1", // fixBlockquoteHardBreaks — blockquote pair
|
||||
"> q2",
|
||||
"some text", // fixSetextAmbiguity — text before ---
|
||||
"---",
|
||||
" 1. nested", // normalizeNestedListIndentation
|
||||
`<callout emoji="warning">`, // fixCalloutEmoji — emoji alias
|
||||
}, "\n")
|
||||
|
||||
// Wrap the dangerous content in a triple-backtick fence and surround with
|
||||
// content so the pipeline has adjacent regions to potentially touch.
|
||||
input := "before\n\n```\n" + dangerous + "\n```\n\nafter\n"
|
||||
|
||||
got := fixExportedMarkdown(input)
|
||||
|
||||
// Extract the fence content from the output and compare to the input fence
|
||||
// content byte-for-byte.
|
||||
gotFence, ok := extractFirstFenceContent(got)
|
||||
if !ok {
|
||||
t.Fatalf("fixExportedMarkdown output lost its fenced code block:\n%s", got)
|
||||
}
|
||||
if gotFence != dangerous {
|
||||
t.Errorf("fenced code content was modified\nwant (bytes): %q\ngot (bytes): %q",
|
||||
dangerous, gotFence)
|
||||
}
|
||||
}
|
||||
|
||||
// extractFirstFenceContent returns the inner text of the first triple-backtick
|
||||
// fenced code block it finds, or ("", false) if none is present.
|
||||
func extractFirstFenceContent(md string) (string, bool) {
|
||||
const fence = "```"
|
||||
open := strings.Index(md, fence)
|
||||
if open < 0 {
|
||||
return "", false
|
||||
}
|
||||
// Skip the fence marker and its info-string line.
|
||||
rest := md[open+len(fence):]
|
||||
lineEnd := strings.Index(rest, "\n")
|
||||
if lineEnd < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest = rest[lineEnd+1:]
|
||||
close := strings.Index(rest, "\n"+fence)
|
||||
if close < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:close], true
|
||||
}
|
||||
|
||||
// TestFixExportedMarkdownPreservesCRLF feeds CRLF-terminated markdown (Windows
|
||||
// line endings) through the pipeline and asserts that line endings are
|
||||
// preserved AND the emphasis/heading transforms still apply — neither
|
||||
// silently-LF-normalized nor passed through unchanged.
|
||||
func TestFixExportedMarkdownPreservesCRLF(t *testing.T) {
|
||||
lf := "# **Title**\nparagraph one\nparagraph two\n**bold **\n"
|
||||
crlf := strings.ReplaceAll(lf, "\n", "\r\n")
|
||||
|
||||
got := fixExportedMarkdown(crlf)
|
||||
|
||||
// Transforms must still fire: heading bold stripped, trailing-space bold trimmed.
|
||||
if strings.Contains(got, "**Title**") {
|
||||
t.Errorf("heading bold not stripped on CRLF input:\n%q", got)
|
||||
}
|
||||
if strings.Contains(got, "**bold **") {
|
||||
t.Errorf("trailing-space bold not fixed on CRLF input:\n%q", got)
|
||||
}
|
||||
// CRLF line endings must survive — we don't want to silently normalize a
|
||||
// Windows author's document to LF.
|
||||
if !strings.Contains(got, "\r\n") {
|
||||
t.Errorf("CRLF line endings were normalized away:\n%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFixExportedMarkdownTransformInteractions covers shapes where more than
|
||||
// one transform fires on the same input. Each transform is individually tested
|
||||
// elsewhere; these cases guard against composition regressions.
|
||||
func TestFixExportedMarkdownTransformInteractions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantContains []string // substrings that must be present after fixes
|
||||
wantAbsent []string // substrings that must be absent after fixes
|
||||
}{
|
||||
{
|
||||
name: "nested list item with trailing-space bold",
|
||||
input: "1. parent\n 1. **child **\n",
|
||||
wantContains: []string{
|
||||
"\t1.", // nested indent converted to tab
|
||||
"**child**", // trailing space trimmed
|
||||
},
|
||||
wantAbsent: []string{
|
||||
" 1.", // original two-space indent gone
|
||||
"**child **", // original trailing space gone
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paragraph followed by list",
|
||||
input: "paragraph\n- item a\n- item b\n",
|
||||
wantContains: []string{
|
||||
"paragraph\n\n- item a", // blank line inserted at text-to-list transition
|
||||
},
|
||||
wantAbsent: []string{
|
||||
"\n\n\n", // no triple newline
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "callout containing list with emphasis",
|
||||
input: "<callout emoji=\"info\">\n- **item **\n- another\n</callout>\n",
|
||||
wantContains: []string{
|
||||
"**item**", // trailing-space bold fixed inside callout
|
||||
},
|
||||
wantAbsent: []string{
|
||||
"**item **",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "heading followed by paragraph with bold",
|
||||
input: "# **Title**\nbody **text **\n",
|
||||
wantContains: []string{
|
||||
"# Title", // heading bold stripped
|
||||
"body **text**", // paragraph bold trimmed, not stripped
|
||||
},
|
||||
wantAbsent: []string{
|
||||
"# **Title**",
|
||||
"body **text **",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fixExportedMarkdown(tt.input)
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("want substring %q not found in output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tt.wantAbsent {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Errorf("unwanted substring %q still present in output:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeNestedListIndentationDocumentedSkips locks in the deliberate
|
||||
// "do nothing" branches of normalizeNestedListIndentation. Each case below is
|
||||
// a shape the function intentionally does not rewrite; if a future change to
|
||||
// the heuristic flips one of these, we want the regression to be visible in
|
||||
// the test diff rather than silently changing user documents.
|
||||
func TestNormalizeNestedListIndentationDocumentedSkips(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
// want is identical to input — we are asserting "no change".
|
||||
}{
|
||||
{
|
||||
name: "three-space indent (odd) under list item stays unchanged",
|
||||
input: "1. parent\n 1. child",
|
||||
},
|
||||
{
|
||||
name: "five-space indent (odd) under list item stays unchanged",
|
||||
input: "- parent\n - deep",
|
||||
},
|
||||
{
|
||||
name: "two-space indent without a parent list item stays unchanged",
|
||||
input: "plain paragraph\n - not nested",
|
||||
},
|
||||
{
|
||||
name: "blank-line-separated loose-list sibling stays unchanged",
|
||||
input: "1. a\n\n 1. b",
|
||||
},
|
||||
{
|
||||
name: "four-space indented code block under list item stays unchanged",
|
||||
input: "- parent\n\n 1. code sample",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeNestedListIndentation(tt.input)
|
||||
if got != tt.input {
|
||||
t.Errorf("normalizeNestedListIndentation unexpectedly rewrote documented-skip input\ninput: %q\ngot: %q", tt.input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFixBoldSpacing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "leading space after opening bold",
|
||||
input: "** hello**",
|
||||
want: "**hello**",
|
||||
},
|
||||
{
|
||||
name: "leading space after opening italic",
|
||||
input: "* hello*",
|
||||
want: "*hello*",
|
||||
},
|
||||
{
|
||||
name: "leading and trailing spaces inside bold are collapsed",
|
||||
input: "** hello **",
|
||||
want: "**hello**",
|
||||
},
|
||||
{
|
||||
name: "leading and trailing spaces inside italic are collapsed",
|
||||
input: "* hello *",
|
||||
want: "*hello*",
|
||||
},
|
||||
{
|
||||
name: "multiple spaced italic spans on one line are each collapsed",
|
||||
input: "* a* * b*",
|
||||
want: "*a* *b*",
|
||||
},
|
||||
{
|
||||
name: "ambiguous italic span stays literal",
|
||||
input: "2 * x * y",
|
||||
want: "2 * x * y",
|
||||
},
|
||||
{
|
||||
name: "ambiguous bold span stays literal",
|
||||
input: "2 ** x ** y",
|
||||
want: "2 ** x ** y",
|
||||
},
|
||||
{
|
||||
name: "single-rune italic with spaces on both sides stays literal",
|
||||
input: "* x *",
|
||||
want: "* x *",
|
||||
},
|
||||
{
|
||||
name: "single-rune bold with spaces on both sides stays literal",
|
||||
input: "** x **",
|
||||
want: "** x **",
|
||||
},
|
||||
{
|
||||
name: "triple-asterisk near miss stays literal",
|
||||
input: "*** hello**",
|
||||
want: "*** hello**",
|
||||
},
|
||||
{
|
||||
name: "trailing space before closing bold",
|
||||
input: "**hello **",
|
||||
want: "**hello**",
|
||||
},
|
||||
{
|
||||
name: "trailing space before closing italic",
|
||||
input: "*hello *",
|
||||
want: "*hello*",
|
||||
},
|
||||
{
|
||||
name: "redundant bold in h1",
|
||||
input: "# **Title**",
|
||||
want: "# Title",
|
||||
},
|
||||
{
|
||||
name: "redundant bold in h2",
|
||||
input: "## **Section**",
|
||||
want: "## Section",
|
||||
},
|
||||
{
|
||||
name: "no change needed for clean bold",
|
||||
input: "**bold**",
|
||||
want: "**bold**",
|
||||
},
|
||||
{
|
||||
name: "multiple lines processed independently",
|
||||
input: "**foo **\n**bar **",
|
||||
want: "**foo**\n**bar**",
|
||||
},
|
||||
{
|
||||
name: "inline code span not modified",
|
||||
input: "`**hello **`",
|
||||
want: "`**hello **`",
|
||||
},
|
||||
{
|
||||
name: "inline code preserved, bold outside fixed",
|
||||
input: "**foo ** and `**bar **`",
|
||||
want: "**foo** and `**bar **`",
|
||||
},
|
||||
{
|
||||
name: "inline code with spaced italic stays literal while outside span is fixed",
|
||||
input: "`* hello *` and * hello *",
|
||||
want: "`* hello *` and *hello*",
|
||||
},
|
||||
{
|
||||
name: "opening space inside text tag fixed",
|
||||
input: `<text color="red">** Helpful - 有用性:**</text>`,
|
||||
want: `<text color="red">**Helpful - 有用性:**</text>`,
|
||||
},
|
||||
{
|
||||
name: "double-backtick inline code not modified",
|
||||
input: "``**hello **`` and **world **",
|
||||
want: "``**hello **`` and **world**",
|
||||
},
|
||||
{
|
||||
name: "double-backtick span containing literal backtick not modified",
|
||||
input: "`` a`b `` and **bold **",
|
||||
want: "`` a`b `` and **bold**",
|
||||
},
|
||||
{
|
||||
name: "heading with multiple bold spans left unchanged",
|
||||
input: "# **foo** and **bar**",
|
||||
want: "# **foo** and **bar**",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fixBoldSpacing(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("fixBoldSpacing(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixSetextAmbiguity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "paragraph followed by ---",
|
||||
input: "some text\n---",
|
||||
want: "some text\n\n---",
|
||||
},
|
||||
{
|
||||
name: "blank line before --- already",
|
||||
input: "some text\n\n---",
|
||||
want: "some text\n\n---",
|
||||
},
|
||||
{
|
||||
name: "heading not affected",
|
||||
input: "# Heading\n---",
|
||||
want: "# Heading\n\n---",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fixSetextAmbiguity(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("fixSetextAmbiguity(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixBlockquoteHardBreaks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "two consecutive blockquote lines",
|
||||
input: "> line1\n> line2",
|
||||
want: "> line1\n>\n> line2",
|
||||
},
|
||||
{
|
||||
name: "three consecutive blockquote lines",
|
||||
input: "> a\n> b\n> c",
|
||||
want: "> a\n>\n> b\n>\n> c",
|
||||
},
|
||||
{
|
||||
name: "single blockquote line unchanged",
|
||||
input: "> only one",
|
||||
want: "> only one",
|
||||
},
|
||||
{
|
||||
name: "non-blockquote not affected",
|
||||
input: "line1\nline2",
|
||||
want: "line1\nline2",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fixBlockquoteHardBreaks(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("fixBlockquoteHardBreaks(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixTopLevelSoftbreaks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "adjacent top-level lines get blank line",
|
||||
input: "paragraph one\nparagraph two",
|
||||
want: "paragraph one\n\nparagraph two",
|
||||
},
|
||||
{
|
||||
name: "lines inside code block not modified",
|
||||
input: "```\nline1\nline2\n```",
|
||||
want: "```\nline1\nline2\n```",
|
||||
},
|
||||
{
|
||||
// callout is a content container: blank lines are inserted between inner lines.
|
||||
name: "lines inside callout get blank line between them",
|
||||
input: "<callout>\nline1\nline2\n</callout>",
|
||||
want: "<callout>\n\nline1\n\nline2\n</callout>",
|
||||
},
|
||||
{
|
||||
name: "lark-td cell content gets blank line",
|
||||
input: "<lark-td>\nline1\nline2\n</lark-td>",
|
||||
want: "<lark-td>\nline1\n\nline2\n</lark-td>",
|
||||
},
|
||||
{
|
||||
name: "structural lark-table tags not separated",
|
||||
input: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
|
||||
want: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
|
||||
},
|
||||
{
|
||||
name: "blockquote lines not split",
|
||||
input: "> line1\n> line2",
|
||||
want: "> line1\n> line2",
|
||||
},
|
||||
{
|
||||
name: "consecutive unordered list items not split",
|
||||
input: "- item a\n- item b\n- item c",
|
||||
want: "- item a\n- item b\n- item c",
|
||||
},
|
||||
{
|
||||
name: "consecutive ordered list items not split",
|
||||
input: "1. first\n2. second\n3. third",
|
||||
want: "1. first\n2. second\n3. third",
|
||||
},
|
||||
{
|
||||
name: "list continuation not split from item",
|
||||
input: "- item a\n continuation",
|
||||
want: "- item a\n continuation",
|
||||
},
|
||||
{
|
||||
name: "text to list transition gets blank line",
|
||||
input: "paragraph\n- list item",
|
||||
want: "paragraph\n\n- list item",
|
||||
},
|
||||
{
|
||||
name: "adjacent callout blocks get blank line between them",
|
||||
input: "<callout>\ncontent1\n</callout>\n<callout>\ncontent2\n</callout>",
|
||||
want: "<callout>\n\ncontent1\n</callout>\n\n<callout>\n\ncontent2\n</callout>",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fixTopLevelSoftbreaks(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("fixTopLevelSoftbreaks(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNestedListIndentation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nested ordered list uses tabs instead of space pairs",
|
||||
input: "1. parent\n 1. child\n 1. grandchild",
|
||||
want: "1. parent\n\t1. child\n\t\t1. grandchild",
|
||||
},
|
||||
{
|
||||
name: "nested mixed list markers use tabs instead of space pairs",
|
||||
input: "- parent\n - child\n 1. grandchild",
|
||||
want: "- parent\n\t- child\n\t\t1. grandchild",
|
||||
},
|
||||
{
|
||||
name: "top-level list unchanged",
|
||||
input: "1. parent\n2. sibling",
|
||||
want: "1. parent\n2. sibling",
|
||||
},
|
||||
{
|
||||
name: "indented top-level marker without parent list stays unchanged",
|
||||
input: "paragraph\n\n 1. item",
|
||||
want: "paragraph\n\n 1. item",
|
||||
},
|
||||
{
|
||||
name: "blank-line-separated loose-list sibling stays unchanged",
|
||||
input: "1. a\n\n 1. b",
|
||||
want: "1. a\n\n 1. b",
|
||||
},
|
||||
{
|
||||
name: "indented code block inside list item stays unchanged",
|
||||
input: "- parent\n\n 1. code",
|
||||
want: "- parent\n\n 1. code",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeNestedListIndentation(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeNestedListIndentation(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixExportedMarkdown(t *testing.T) {
|
||||
// End-to-end: all fixes applied together
|
||||
input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---"
|
||||
result := fixExportedMarkdown(input)
|
||||
|
||||
if strings.Contains(result, "# **Title**") {
|
||||
t.Error("expected heading bold to be stripped")
|
||||
}
|
||||
if !strings.Contains(result, "paragraph one\n\nparagraph two") {
|
||||
t.Error("expected blank line between top-level paragraphs")
|
||||
}
|
||||
if strings.Contains(result, "**bold **") {
|
||||
t.Error("expected trailing space in bold to be fixed")
|
||||
}
|
||||
if !strings.Contains(result, ">\n> q2") {
|
||||
t.Error("expected blockquote hard break inserted")
|
||||
}
|
||||
if strings.Contains(result, "some text\n---") {
|
||||
t.Error("expected blank line before --- to prevent setext heading")
|
||||
}
|
||||
// Should end with exactly one newline
|
||||
if !strings.HasSuffix(result, "\n") || strings.HasSuffix(result, "\n\n") {
|
||||
t.Errorf("expected result to end with exactly one newline, got %q", result[len(result)-5:])
|
||||
}
|
||||
// No triple newlines
|
||||
if strings.Contains(result, "\n\n\n") {
|
||||
t.Error("expected no triple newlines in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnCalloutType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHint bool // whether a hint line is expected
|
||||
hintContains string // substring the hint must contain
|
||||
}{
|
||||
{
|
||||
name: "warning type without background-color emits hint",
|
||||
input: `<callout type="warning" emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "info type without background-color emits hint",
|
||||
input: `<callout type="info" emoji="ℹ️">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-blue"`,
|
||||
},
|
||||
{
|
||||
name: "single-quoted type attribute emits hint",
|
||||
input: `<callout type='warning' emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "explicit background-color suppresses hint",
|
||||
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace around equals is tolerated in background-color",
|
||||
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "unknown type emits no hint",
|
||||
input: `<callout type="custom" emoji="🔥">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "no type attribute emits no hint",
|
||||
input: `<callout emoji="💡" background-color="light-green">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "non-callout tag emits no hint",
|
||||
input: `<div type="warning">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "hint includes border-color suggestion",
|
||||
input: `<callout type="error" emoji="❌">`,
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
{
|
||||
// Regression: the old `\btype=` regex matched the suffix of
|
||||
// `data-type=` because `-` is a non-word character, so a tag
|
||||
// carrying only data-attrs would silently get a bogus hint.
|
||||
// The (?:^|\s) anchor requires a real attribute separator.
|
||||
name: "data-type attribute does not trigger hint",
|
||||
input: `<callout data-type="warning" emoji="📝">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Symmetric guard for the background-color regex: a future
|
||||
// `data-background-color=` attribute must not be mistaken
|
||||
// for a present background-color and silently suppress the
|
||||
// hint that the real type= would otherwise produce.
|
||||
name: "data-background-color does not suppress hint",
|
||||
input: `<callout type="warning" data-background-color="anything">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
// Regression for the code-fence skip: a documentation sample
|
||||
// inside a ``` fence is NOT a real callout the user wants
|
||||
// rendered, so it must produce no stderr noise.
|
||||
name: "callout inside backtick fence emits no hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning" emoji="📝">` + "\n" +
|
||||
"```\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Same skip works for tilde fences (CommonMark §4.5 makes
|
||||
// `~~~` an equivalent fence character).
|
||||
name: "callout inside tilde fence emits no hint",
|
||||
input: "~~~markdown\n" +
|
||||
`<callout type="info" emoji="ℹ️">` + "\n" +
|
||||
"~~~\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Closing the fence must restore normal scanning: a real
|
||||
// callout that follows a documentation block still gets a
|
||||
// hint. Pins that fenceMarker is reset, not stuck.
|
||||
name: "callout after fence close still emits hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning">sample</callout>` + "\n" +
|
||||
"```\n" +
|
||||
`<callout type="error" emoji="❌">real</callout>` + "\n",
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
WarnCalloutType(tt.input, &buf)
|
||||
got := buf.String()
|
||||
if tt.wantHint {
|
||||
if got == "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
|
||||
return
|
||||
}
|
||||
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
|
||||
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
|
||||
}
|
||||
} else {
|
||||
if got != "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCalloutEmoji(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "warning alias replaced",
|
||||
input: `<callout emoji="warning" background-color="light-orange">`,
|
||||
want: `<callout emoji="⚠️" background-color="light-orange">`,
|
||||
},
|
||||
{
|
||||
name: "tip alias replaced",
|
||||
input: `<callout emoji="tip">`,
|
||||
want: `<callout emoji="💡">`,
|
||||
},
|
||||
{
|
||||
name: "actual emoji unchanged",
|
||||
input: `<callout emoji="⚠️">`,
|
||||
want: `<callout emoji="⚠️">`,
|
||||
},
|
||||
{
|
||||
name: "unknown alias unchanged",
|
||||
input: `<callout emoji="unicorn">`,
|
||||
want: `<callout emoji="unicorn">`,
|
||||
},
|
||||
{
|
||||
name: "non-callout tag unchanged",
|
||||
input: `<div emoji="warning">`,
|
||||
want: `<div emoji="warning">`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fixCalloutEmoji(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("fixCalloutEmoji(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOutsideCodeFences(t *testing.T) {
|
||||
// Transforms should not modify content inside fenced code blocks.
|
||||
input := "```md\n**x **\n> a\n> b\nline\n---\n```"
|
||||
|
||||
if got := applyOutsideCodeFences(input, fixBoldSpacing); got != input {
|
||||
t.Fatalf("fixBoldSpacing (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
|
||||
}
|
||||
if got := applyOutsideCodeFences(input, fixSetextAmbiguity); got != input {
|
||||
t.Fatalf("fixSetextAmbiguity (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
|
||||
}
|
||||
if got := applyOutsideCodeFences(input, fixBlockquoteHardBreaks); got != input {
|
||||
t.Fatalf("fixBlockquoteHardBreaks (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
|
||||
}
|
||||
|
||||
// Content outside the fence should still be transformed.
|
||||
mixed := "**foo ** before\n```\n**x **\n```\n**bar ** after"
|
||||
got := applyOutsideCodeFences(mixed, fixBoldSpacing)
|
||||
if strings.Contains(got, "**foo **") {
|
||||
t.Errorf("fixBoldSpacing did not fix bold before fence: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "**bar **") {
|
||||
t.Errorf("fixBoldSpacing did not fix bold after fence: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "```\n**x **\n```") {
|
||||
t.Errorf("fixBoldSpacing modified content inside fence: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixTopLevelSoftbreaksQuoteContainer(t *testing.T) {
|
||||
input := "<quote-container>\nline1\nline2\n</quote-container>"
|
||||
got := fixTopLevelSoftbreaks(input)
|
||||
// quote-container is a content container: blank lines inserted between inner lines.
|
||||
want := "<quote-container>\n\nline1\n\nline2\n</quote-container>"
|
||||
if got != want {
|
||||
t.Errorf("fixTopLevelSoftbreaks quote-container = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -9,28 +9,44 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsServiceHelpDefault = `Document and content operations.`
|
||||
|
||||
const docsServiceHelpV2 = `Document and content operations (v2).`
|
||||
const docsSkillReadCommand = "lark-cli skills read lark-doc"
|
||||
const docsXMLSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-xml.md"
|
||||
const docsMDSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-md.md"
|
||||
const docsContentSkillHelp = "AI agents MUST read " +
|
||||
docsXMLSkillReadCommand + " before writing any --content payload; " +
|
||||
"when using --doc-format markdown, also read " + docsMDSkillReadCommand + ". " +
|
||||
"Follow the latest rules there, and MUST NOT grep/open local SKILL.md files " +
|
||||
"to discover this guidance"
|
||||
|
||||
var docsVersionSelectionTips = []string{
|
||||
"Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
||||
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
|
||||
}
|
||||
|
||||
var docsV2VersionSelectionTips = []string{
|
||||
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
||||
}
|
||||
|
||||
func docsTipsForVersion(apiVersion string) []string {
|
||||
if apiVersion == "v2" {
|
||||
return docsV2VersionSelectionTips
|
||||
func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
switch strings.TrimPrefix(shortcut, "+") {
|
||||
case "create":
|
||||
return docsSkillReadCommand + " references/lark-doc-create.md"
|
||||
case "fetch":
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
}
|
||||
|
||||
func docsHelpCommandForShortcut(shortcut string) string {
|
||||
switch strings.TrimPrefix(shortcut, "+") {
|
||||
case "create":
|
||||
return "lark-cli docs +create --help"
|
||||
case "fetch":
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
return docsVersionSelectionTips
|
||||
}
|
||||
|
||||
// Shortcuts returns all docs shortcuts.
|
||||
@@ -48,45 +64,32 @@ func Shortcuts() []common.Shortcut {
|
||||
}
|
||||
|
||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
||||
// The shortcut-level help remains compatible with legacy v1 skills; this parent
|
||||
// help switches docs guidance to match the selected API version.
|
||||
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
serviceCmd := cmd
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
if cmd.Flags().Lookup("api-version") == nil {
|
||||
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
defaultHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != serviceCmd {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
apiVersion, _ := cmd.Flags().GetString("api-version")
|
||||
previousLong := cmd.Long
|
||||
if apiVersion == "v2" {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
|
||||
} else {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Long = previousLong
|
||||
}()
|
||||
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range docsTipsForVersion(apiVersion) {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
cmd.Long = docsHelpLong(docsServiceHelpDefault, docsSkillReadCommand)
|
||||
}
|
||||
|
||||
func installDocsShortcutHelp(command string) func(*cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
cmd.Long = docsHelpLong(cmd.Short, docsSkillReadCommandForShortcut(command))
|
||||
}
|
||||
}
|
||||
|
||||
func docsHelpLong(summary, skillReadCommand string) string {
|
||||
return strings.TrimSpace(fmt.Sprintf(`%s
|
||||
|
||||
Start here (required for AI agents):
|
||||
%s
|
||||
|
||||
AI agents MUST read the matching embedded skill before choosing flags
|
||||
or running docs commands. Do not skip this step, and do not infer
|
||||
workflows from --help alone. MUST NOT grep/open local SKILL.md files
|
||||
to discover this guidance; use %s so content stays version-matched
|
||||
with this CLI. Skills ship with the CLI and include docs workflows,
|
||||
selector/block-id usage, XML/Markdown formats, and copy-paste examples.
|
||||
|
||||
skills read lark-doc Docs workflow guide
|
||||
skills read lark-doc <path> Read a referenced docs skill file`, strings.TrimSpace(summary), skillReadCommand, skillReadCommand))
|
||||
}
|
||||
|
||||
103
shortcuts/doc/v2_only.go
Normal file
103
shortcuts/doc/v2_only.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsLegacyFlag struct {
|
||||
Name string
|
||||
Replacement string
|
||||
}
|
||||
|
||||
func docsAPIVersionCompatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "api-version",
|
||||
Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples",
|
||||
Default: "v2",
|
||||
}
|
||||
}
|
||||
|
||||
func docsCreateLegacyFlags() []docsLegacyFlag {
|
||||
return []docsLegacyFlag{
|
||||
{Name: "title", Replacement: "put the title in --content, for example <title>Title</title>"},
|
||||
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
|
||||
{Name: "folder-token", Replacement: "use --parent-token"},
|
||||
{Name: "wiki-node", Replacement: "use --parent-token"},
|
||||
{Name: "wiki-space", Replacement: "use --parent-position my_library or a concrete parent position"},
|
||||
}
|
||||
}
|
||||
|
||||
func docsFetchLegacyFlags() []docsLegacyFlag {
|
||||
return []docsLegacyFlag{
|
||||
{Name: "offset", Replacement: "use --scope outline/range/keyword/section for partial reads"},
|
||||
{Name: "limit", Replacement: "use --scope outline/range/keyword/section for partial reads"},
|
||||
}
|
||||
}
|
||||
|
||||
func docsUpdateLegacyFlags() []docsLegacyFlag {
|
||||
return []docsLegacyFlag{
|
||||
{Name: "mode", Replacement: "use --command"},
|
||||
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
|
||||
{Name: "selection-with-ellipsis", Replacement: "use --command str_replace with --pattern"},
|
||||
{Name: "selection-by-title", Replacement: "fetch block ids first, then use --command block_replace/block_insert_after with --block-id"},
|
||||
{Name: "new-title", Replacement: "update the title through XML content in --content"},
|
||||
}
|
||||
}
|
||||
|
||||
func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
|
||||
out := make([]common.Flag, 0, len(flags))
|
||||
for _, flag := range flags {
|
||||
out = append(out, common.Flag{
|
||||
Name: flag.Name,
|
||||
Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill",
|
||||
Hidden: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error {
|
||||
switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
|
||||
case "", "v1", "v2":
|
||||
default:
|
||||
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API")
|
||||
}
|
||||
|
||||
var used []string
|
||||
var replacements []string
|
||||
for _, flag := range legacyFlags {
|
||||
if !runtime.Changed(flag.Name) {
|
||||
continue
|
||||
}
|
||||
used = append(used, "--"+flag.Name)
|
||||
if flag.Replacement != "" {
|
||||
replacements = append(replacements, "--"+flag.Name+" -> "+flag.Replacement)
|
||||
}
|
||||
}
|
||||
if len(used) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
detail := "the old v1 interface has been shut down; legacy v1 flag(s) " + strings.Join(used, ", ") + " are no longer supported"
|
||||
if len(replacements) > 0 {
|
||||
detail += "; " + strings.Join(replacements, "; ")
|
||||
}
|
||||
return docsV2OnlyError(shortcut, detail)
|
||||
}
|
||||
|
||||
func docsV2OnlyError(shortcut, detail string) error {
|
||||
return common.FlagErrorf(
|
||||
"docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags",
|
||||
shortcut,
|
||||
detail,
|
||||
docsSkillReadCommandForShortcut(shortcut),
|
||||
docsXMLSkillReadCommand,
|
||||
docsMDSkillReadCommand,
|
||||
docsHelpCommandForShortcut(shortcut),
|
||||
)
|
||||
}
|
||||
86
shortcuts/doc/v2_only_test.go
Normal file
86
shortcuts/doc/v2_only_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) {
|
||||
for _, apiVersion := range []string{"", "v1", "v2"} {
|
||||
t.Run(apiVersion, func(t *testing.T) {
|
||||
runtime := docsV2OnlyTestRuntime(t, apiVersion, false)
|
||||
if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil {
|
||||
t.Fatalf("validateDocsV2Only(%q) error = %v, want nil", apiVersion, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) {
|
||||
runtime := docsV2OnlyTestRuntime(t, "v0", false)
|
||||
err := validateDocsV2Only(runtime, "+fetch", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown --api-version to be rejected")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"docs +fetch is v2-only",
|
||||
"--api-version is deprecated and only accepts v1 or v2",
|
||||
"both values execute the v2 API",
|
||||
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"lark-cli docs +fetch --help",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) {
|
||||
runtime := docsV2OnlyTestRuntime(t, "", true)
|
||||
err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected changed legacy flag to be rejected")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --mode are no longer supported",
|
||||
"--mode -> use --command",
|
||||
"lark-cli skills read lark-doc references/lark-doc-update.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"lark-cli docs +update --help",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func docsV2OnlyTestRuntime(t *testing.T, apiVersion string, legacyMode bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "+update"}
|
||||
cmd.Flags().String("api-version", "", "")
|
||||
cmd.Flags().String("mode", "", "")
|
||||
if apiVersion != "" {
|
||||
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
|
||||
t.Fatalf("set api-version: %v", err)
|
||||
}
|
||||
}
|
||||
if legacyMode {
|
||||
if err := cmd.Flags().Set("mode", "overwrite"); err != nil {
|
||||
t.Fatalf("set mode: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// installVersionedHelp sets a custom help function on cmd that shows only the
|
||||
// flags relevant to the selected --api-version. flagVersions maps flag name to
|
||||
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
|
||||
// always visible.
|
||||
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
|
||||
origHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
ver, _ := cmd.Flags().GetString("api-version")
|
||||
if ver == "" {
|
||||
ver = defaultVersion
|
||||
}
|
||||
// Show/hide flags based on the active version.
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if fv, ok := flagVersions[f.Name]; ok {
|
||||
f.Hidden = fv != ver
|
||||
}
|
||||
})
|
||||
cmdutil.SetTips(cmd, docsTipsForVersion(ver))
|
||||
origHelp(cmd, args)
|
||||
})
|
||||
}
|
||||
|
||||
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
|
||||
// path is used.
|
||||
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[deprecated] docs %s is using the v1 API. %s\n",
|
||||
shortcut, docsV2VersionSelectionTips[0])
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) {
|
||||
for _, shortcut := range []string{"+create", "+fetch", "+update"} {
|
||||
t.Run(shortcut, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut)
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[deprecated] docs " + shortcut + " is using the v1 API.",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("warning missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "will be removed in a future release") {
|
||||
t.Fatalf("warning should not include removal-only guidance:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{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,230 +39,296 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
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
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
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("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 != "" {
|
||||
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 != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
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"),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("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",
|
||||
},
|
||||
)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
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)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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")
|
||||
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):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, 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):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
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())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
@@ -488,6 +488,72 @@ 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,128 +34,160 @@ 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 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"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
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
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
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
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -8,11 +8,19 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveInspectRateLimitRetries = 2
|
||||
driveInspectRetryInitialBackoff = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var driveInspectAfter = time.After
|
||||
|
||||
var DriveInspect = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+inspect",
|
||||
@@ -35,32 +43,15 @@ var DriveInspect = common.Shortcut{
|
||||
},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
||||
}
|
||||
|
||||
_, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Not a recognized URL pattern.
|
||||
if strings.Contains(raw, "://") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
||||
}
|
||||
// Bare token: --type is required.
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
|
||||
}
|
||||
if _, err := driveInspectResolveRef(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
ref, err := driveInspectResolveRef(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
@@ -91,15 +82,9 @@ var DriveInspect = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
|
||||
// Step 1: Parse URL to extract {type, token}.
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Bare token: use --type.
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
ref, err := driveInspectResolveRef(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inputURL := raw
|
||||
@@ -111,14 +96,19 @@ var DriveInspect = common.Shortcut{
|
||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||
if docType == "wiki" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
data, err := driveInspectCallWithRetry(
|
||||
ctx,
|
||||
func() (map[string]interface{}, error) {
|
||||
return runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return driveInspectAnnotateError("resolve_wiki", err)
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
@@ -145,9 +135,9 @@ var DriveInspect = common.Shortcut{
|
||||
}
|
||||
|
||||
// Step 3: Call batch_query to verify and get title.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
|
||||
title, err := driveInspectFetchMetaTitle(ctx, runtime, docToken, docType)
|
||||
if err != nil {
|
||||
return err
|
||||
return driveInspectAnnotateError("query_meta", err)
|
||||
}
|
||||
|
||||
// Step 4: Build the resolved URL.
|
||||
@@ -181,3 +171,116 @@ var DriveInspect = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func driveInspectResolveRef(runtime *common.RuntimeContext) (common.ResourceRef, error) {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
||||
}
|
||||
|
||||
inputType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if ok {
|
||||
if inputType != "" && inputType != ref.Type {
|
||||
return common.ResourceRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments and pass only the raw token with --type", raw).WithParam("--url")
|
||||
}
|
||||
if inputType == "" {
|
||||
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
|
||||
}
|
||||
return common.ResourceRef{Type: inputType, Token: raw}, nil
|
||||
}
|
||||
|
||||
func driveInspectFetchMetaTitle(ctx context.Context, runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||
var title string
|
||||
_, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||
got, callErr := common.FetchDriveMeta(runtime, token, docType, false)
|
||||
if callErr != nil {
|
||||
return nil, callErr
|
||||
}
|
||||
title = got.Title
|
||||
return map[string]interface{}{"title": got.Title}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func driveInspectCallWithRetry(ctx context.Context, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= driveInspectRateLimitRetries; attempt++ {
|
||||
data, err := call()
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !driveInspectShouldRetry(err) || attempt == driveInspectRateLimitRetries {
|
||||
return nil, err
|
||||
}
|
||||
backoff := driveInspectRetryInitialBackoff * time.Duration(1<<attempt)
|
||||
if waitErr := driveInspectWait(ctx, backoff); waitErr != nil {
|
||||
return nil, waitErr
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func driveInspectShouldRetry(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return false
|
||||
}
|
||||
return problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400 || problem.Retryable
|
||||
}
|
||||
|
||||
func driveInspectWait(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errs.WrapInternal(ctx.Err())
|
||||
case <-driveInspectAfter(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func driveInspectAnnotateError(stage string, err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem == nil {
|
||||
return err
|
||||
}
|
||||
label := map[string]string{
|
||||
"resolve_wiki": "resolve wiki node",
|
||||
"query_meta": "query document metadata",
|
||||
}[stage]
|
||||
if label == "" {
|
||||
label = stage
|
||||
}
|
||||
problem.Message = fmt.Sprintf("%s failed: %s", label, problem.Message)
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
switch stage {
|
||||
case "resolve_wiki":
|
||||
problem.Hint = "check that the wiki URL/token is valid and that the current identity can read the wiki node"
|
||||
case "query_meta":
|
||||
problem.Hint = "check that the resolved document still exists and that the current identity can read its metadata"
|
||||
}
|
||||
} else if !strings.Contains(problem.Hint, label) {
|
||||
problem.Hint = label + ": " + problem.Hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/httpmock"
|
||||
@@ -83,6 +86,34 @@ func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_URLTypeConflict(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "sheet")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for conflicting --type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithPathFragment(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken/extra")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token with path fragment, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
@@ -540,6 +571,76 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for batch_query failure, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "query document metadata failed") {
|
||||
t.Fatalf("message = %q, want query document metadata prefix", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_RetriesRateLimitOnWikiResolve(t *testing.T) {
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "request trigger frequency limit",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "doxcnUnwrapped",
|
||||
"space_id": "space123",
|
||||
"node_token": "wikcnNodeToken",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
origAfter := driveInspectAfter
|
||||
driveInspectAfter = func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
}
|
||||
defer func() { driveInspectAfter = origAfter }()
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error after retry: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["token"] != "doxcnUnwrapped" {
|
||||
t.Fatalf("token = %v, want doxcnUnwrapped", data["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||
|
||||
32
shortcuts/event/errors.go
Normal file
32
shortcuts/event/errors.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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 fmt.Errorf("create output dir: %w", err)
|
||||
return eventFileIOError(err, "create output dir")
|
||||
}
|
||||
}
|
||||
if p.config.Router != nil {
|
||||
for _, route := range p.config.Router.routes {
|
||||
if err := vfs.MkdirAll(route.dir, 0700); err != nil {
|
||||
return fmt.Errorf("create route dir %s: %w", route.dir, err)
|
||||
return eventFileIOError(err, "create route dir %s", route.dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,7 +16,13 @@ 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.
|
||||
@@ -44,6 +51,87 @@ 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) {
|
||||
@@ -63,9 +151,11 @@ func TestRegistryDuplicateReturnsError(t *testing.T) {
|
||||
if err := r.Register(&ImMessageProcessor{}); err != nil {
|
||||
t.Fatalf("first register should succeed: %v", err)
|
||||
}
|
||||
if err := r.Register(&ImMessageProcessor{}); err == nil {
|
||||
err := r.Register(&ImMessageProcessor{})
|
||||
if err == nil {
|
||||
t.Error("expected error on duplicate registration")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeUnknown, "")
|
||||
}
|
||||
|
||||
// --- Filters ---
|
||||
@@ -106,6 +196,54 @@ 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\\..*")
|
||||
@@ -339,6 +477,106 @@ 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) {
|
||||
@@ -608,6 +846,7 @@ 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) {
|
||||
@@ -615,6 +854,10 @@ 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) {
|
||||
@@ -622,6 +865,7 @@ 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)
|
||||
}
|
||||
@@ -632,6 +876,7 @@ 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) {
|
||||
@@ -639,6 +884,7 @@ 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) {
|
||||
@@ -646,6 +892,7 @@ 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 "fmt"
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// 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 fmt.Errorf("duplicate event processor for: %s", et)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "duplicate event processor for: %s", et)
|
||||
}
|
||||
r.processors[et] = p
|
||||
return nil
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -34,27 +33,27 @@ func ParseRoutes(specs []string) (*EventRouter, error) {
|
||||
for _, spec := range specs {
|
||||
parts := strings.SplitN(spec, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec)
|
||||
return nil, eventValidationParamError("--route", "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, fmt.Errorf("invalid regex in route %q: %w", spec, err)
|
||||
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid regex in --route %q", spec)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "dir:") {
|
||||
return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
|
||||
return nil, eventValidationParamError("--route", "invalid --route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
|
||||
}
|
||||
dir := strings.TrimPrefix(target, "dir:")
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("invalid route %q: directory path is empty", spec)
|
||||
return nil, eventValidationParamError("--route", "invalid --route %q: directory path is empty", spec)
|
||||
}
|
||||
|
||||
safeDir, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid route %q: %w", spec, err)
|
||||
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid --route %q", spec)
|
||||
}
|
||||
|
||||
routes = append(routes, Route{pattern: re, dir: safeDir})
|
||||
|
||||
@@ -6,6 +6,7 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ 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"
|
||||
@@ -144,7 +146,7 @@ var EventSubscribe = common.Shortcut{
|
||||
if outputDir != "" {
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return eventValidationParamErrorWithCause(err, "--output-dir", "unsafe --output-dir")
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
@@ -162,15 +164,18 @@ var EventSubscribe = common.Shortcut{
|
||||
if !forceFlag {
|
||||
lock, err := lockfile.ForSubscribe(runtime.Config.AppID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create lock: %w", err)
|
||||
return eventFileIOError(err, "failed to create event subscriber lock")
|
||||
}
|
||||
if err := lock.TryLock(); err != nil {
|
||||
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,
|
||||
)
|
||||
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")
|
||||
}
|
||||
defer lock.Unlock()
|
||||
}
|
||||
@@ -179,7 +184,7 @@ var EventSubscribe = common.Shortcut{
|
||||
eventTypeFilter := NewEventTypeFilter(eventTypesStr)
|
||||
regexFilter, err := NewRegexFilter(filterStr)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid --filter regex: %s", filterStr)
|
||||
return eventValidationParamErrorWithCause(err, "--filter", "invalid --filter regex %q", filterStr)
|
||||
}
|
||||
var filterList []EventFilter
|
||||
if eventTypeFilter != nil {
|
||||
@@ -193,7 +198,7 @@ var EventSubscribe = common.Shortcut{
|
||||
// --- Parse route ---
|
||||
router, err := ParseRoutes(routeSpecs)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid --route: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Build pipeline ---
|
||||
@@ -292,7 +297,7 @@ var EventSubscribe = common.Shortcut{
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
return eventNetworkError(err, "WebSocket connection failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -28,8 +30,17 @@ const markdownEmptyContentError = "empty markdown content is not supported; cann
|
||||
const (
|
||||
markdownUploadParentTypeExplorer = "explorer"
|
||||
markdownUploadParentTypeWiki = "wiki"
|
||||
markdownUploadAllAction = "upload markdown file failed"
|
||||
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
|
||||
markdownUploadFinishAction = "finalize markdown multipart upload failed"
|
||||
markdownFetchNameAction = "fetch existing markdown file name failed"
|
||||
)
|
||||
|
||||
var markdownUploadRetryBackoffs = []time.Duration{
|
||||
200 * time.Millisecond,
|
||||
500 * time.Millisecond,
|
||||
}
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
@@ -387,58 +398,68 @@ func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSp
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
fileSize := int64(len(payload))
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
})
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
f, err := runtime.FileIO().Open(spec.FilePath)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
|
||||
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return runtime.FileIO().Open(spec.FilePath)
|
||||
})
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
|
||||
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return runtime.FileIO().Open(spec.FilePath)
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return markdownUploadResult{}, err
|
||||
return withMarkdownUploadRetryResult(runtime, markdownUploadAllAction, func() (markdownUploadResult, error) {
|
||||
fileReader, err := openReader()
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(client.WrapDoAPIError(err), markdownUploadAllAction)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
|
||||
}
|
||||
result, err := parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
@@ -450,31 +471,53 @@ func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUp
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
prepareResult, err := withMarkdownUploadRetryData(runtime, markdownUploadPrepareAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownUploadPrepareAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
session, err := parseMarkdownMultipartSession(prepareResult)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadPrepareAction)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
|
||||
|
||||
fileReader, err := openReader()
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
finishResult, err := withMarkdownUploadRetryData(runtime, markdownUploadFinishAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownUploadFinishAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
result, err := parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadFinishAction)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
|
||||
@@ -484,7 +527,7 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
|
||||
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
return markdownMultipartSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
session.UploadID, session.BlockSize, session.BlockNum)
|
||||
}
|
||||
@@ -494,9 +537,8 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
|
||||
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
|
||||
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
|
||||
if session.BlockNum != expectedBlocks {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
|
||||
session.BlockSize,
|
||||
session.BlockNum,
|
||||
@@ -507,7 +549,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if session.BlockSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
|
||||
buffer := make([]byte, int(session.BlockSize))
|
||||
@@ -528,22 +570,27 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
action := fmt.Sprintf("upload markdown file part %d/%d failed", seq+1, session.BlockNum)
|
||||
if err := withMarkdownUploadRetryVoid(runtime, action, func() error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return markdownUploadProblem(client.WrapDoAPIError(err), action)
|
||||
}
|
||||
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
|
||||
}
|
||||
|
||||
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return markdownUploadProblem(err, action)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -551,9 +598,8 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
|
||||
remaining,
|
||||
session.BlockNum,
|
||||
@@ -572,28 +618,34 @@ func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool)
|
||||
result.Version = common.GetString(data, "data_version")
|
||||
}
|
||||
if result.FileToken == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
}
|
||||
if requireVersion && result.Version == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
|
||||
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite failed: no version returned")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
data, err := withMarkdownUploadRetryData(runtime, markdownFetchNameAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownFetchNameAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -606,6 +658,97 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryResult(runtime *common.RuntimeContext, action string, fn func() (markdownUploadResult, error)) (markdownUploadResult, error) {
|
||||
var zero markdownUploadResult
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return zero, markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryData(runtime *common.RuntimeContext, action string, fn func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return nil, markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryVoid(runtime *common.RuntimeContext, action string, fn func() error) error {
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func markdownUploadShouldRetry(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p == nil {
|
||||
return false
|
||||
}
|
||||
return p.Retryable || p.Category == errs.CategoryNetwork
|
||||
}
|
||||
|
||||
func markdownUploadRetryExhausted(err error, action string, retries int) error {
|
||||
if retries <= 0 {
|
||||
return err
|
||||
}
|
||||
return appendMarkdownProblemHint(err, fmt.Sprintf("%s remained retryable after %d attempts; retry later if the upstream service is throttling or temporarily unavailable", action, retries+1))
|
||||
}
|
||||
|
||||
func markdownUploadProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = action + ": " + p.Message
|
||||
switch p.Code {
|
||||
case 99991672, 99991679:
|
||||
appendMarkdownProblemHint(err, "The current token or identity lacks the required document upload scope/capability. Grant the document upload scope or use a token with the appropriate permissions, then retry.")
|
||||
case 10071:
|
||||
appendMarkdownProblemHint(err, "The target document has reached its version limit. Clean up old versions or create a new file before retrying.")
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func appendMarkdownProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -603,6 +605,100 @@ func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateUploadAllReturnsTypedScopeError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "Access denied. One of the following scopes is required: [drive:file:upload]",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-upload-scope",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error")
|
||||
}
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 99991672 {
|
||||
t.Fatalf("code = %d, want 99991672", p.Code)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Fatalf("subtype = %s, want %s", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "lacks the required document upload scope") {
|
||||
t.Fatalf("hint = %q, want upload scope guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateUploadAllRetriesRateLimit(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "request frequency limit exceeded",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-upload-ratelimit-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_retry_success",
|
||||
"version": "1003",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_retry_success", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_retry_success"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "retrying (attempt 1/2)") {
|
||||
t.Fatalf("stderr = %q, want retry log", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_token": "box_md_retry_success"`) {
|
||||
t.Fatalf("stdout missing retried upload token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1033,6 +1129,270 @@ func TestUploadMarkdownMultipartPartsRejectsOversizedBlockSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMarkdownUploadRetryDataDoesNotRetryNonRetryable(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
|
||||
|
||||
attempts := 0
|
||||
expected := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(1061004)
|
||||
_, err := withMarkdownUploadRetryData(rt, markdownUploadAllAction, func() (map[string]interface{}, error) {
|
||||
attempts++
|
||||
return nil, expected
|
||||
})
|
||||
if err != expected {
|
||||
t.Fatalf("err = %v, want original error", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
t.Fatalf("stderr = %q, want no retry log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMarkdownUploadRetryVoidExhaustedAppendsHint(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
|
||||
|
||||
orig := markdownUploadRetryBackoffs
|
||||
markdownUploadRetryBackoffs = []time.Duration{0, 0}
|
||||
t.Cleanup(func() { markdownUploadRetryBackoffs = orig })
|
||||
|
||||
attempts := 0
|
||||
err := withMarkdownUploadRetryVoid(rt, markdownUploadFinishAction, func() error {
|
||||
attempts++
|
||||
return errs.NewAPIError(errs.SubtypeRateLimit, "too many requests").WithCode(99991400).WithRetryable()
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "remained retryable after 3 attempts") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion guidance", p.Hint)
|
||||
}
|
||||
if strings.Count(stderr.String(), "retrying (attempt") != 2 {
|
||||
t.Fatalf("stderr = %q, want 2 retry logs", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadShouldRetryBranches(t *testing.T) {
|
||||
if markdownUploadShouldRetry(errors.New("plain")) {
|
||||
t.Fatal("plain error should not be retryable")
|
||||
}
|
||||
if !markdownUploadShouldRetry(errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()) {
|
||||
t.Fatal("retryable API error should be retryable")
|
||||
}
|
||||
if !markdownUploadShouldRetry(errs.NewNetworkError(errs.SubtypeNetworkServer, "gateway").WithCode(502)) {
|
||||
t.Fatal("network error should be retryable by category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadRetryExhaustedZeroRetriesKeepsOriginal(t *testing.T) {
|
||||
original := errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()
|
||||
got := markdownUploadRetryExhausted(original, markdownUploadAllAction, 0)
|
||||
if got != original {
|
||||
t.Fatalf("got = %v, want original error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAppendsCodeSpecificHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing scope",
|
||||
code: 99991672,
|
||||
want: "lacks the required document upload scope",
|
||||
},
|
||||
{
|
||||
name: "version limit",
|
||||
code: 10071,
|
||||
want: "reached its version limit",
|
||||
},
|
||||
{
|
||||
name: "document capability",
|
||||
code: 90003087,
|
||||
want: "document capabilities enabled",
|
||||
},
|
||||
{
|
||||
name: "target not found",
|
||||
code: 1061044,
|
||||
want: "target folder or wiki node still exists",
|
||||
},
|
||||
{
|
||||
name: "no write access",
|
||||
code: 1062501,
|
||||
want: "has write access",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(tt.code)
|
||||
got := markdownUploadProblem(err, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", got, got)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want action prefix", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, tt.want) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMarkdownFileAllMissingFileTokenGetsActionPrefix(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileAll(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMarkdownFileMultipartPrepareAndFinishParseErrorsGetActionPrefix(t *testing.T) {
|
||||
t.Run("prepare", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileMultipart(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected prepare parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadPrepareAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadPrepareAction+": ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finish", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(8),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileMultipart(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected finish parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadFinishAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadFinishAction+": ")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendMarkdownProblemHintAppendsAndIgnoresBlank(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
|
||||
appendMarkdownProblemHint(err, "second")
|
||||
appendMarkdownProblemHint(err, " ")
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Hint != "first\nsecond" {
|
||||
t.Fatalf("hint = %q, want newline-joined hints", p.Hint)
|
||||
}
|
||||
|
||||
plain := errors.New("plain")
|
||||
if got := appendMarkdownProblemHint(plain, "ignored"); got != plain {
|
||||
t.Fatalf("plain error should pass through unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteUploadAllIncludesFileTokenAndVersion(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1303,7 +1663,18 @@ func TestMarkdownOverwriteRejectsEmptyLocalFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-meta-notfound",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{
|
||||
"+overwrite",
|
||||
@@ -1313,6 +1684,19 @@ func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected metadata lookup failure")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 1061044 {
|
||||
t.Fatalf("code = %d, want 1061044", p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownFetchNameAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownFetchNameAction+": ")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "target folder or wiki node still exists") {
|
||||
t.Fatalf("hint = %q, want target guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteMissingFileReturnsReadError(t *testing.T) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -54,29 +55,29 @@ var MinutesDownload = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
if len(tokens) == 0 {
|
||||
return output.ErrValidation("--minute-tokens is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens")
|
||||
}
|
||||
if len(tokens) > maxBatchSize {
|
||||
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !validMinuteToken.MatchString(token) {
|
||||
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens")
|
||||
}
|
||||
}
|
||||
// Cheap checks first, then path-safety resolution.
|
||||
out := runtime.Str("output")
|
||||
outDir := runtime.Str("output-dir")
|
||||
if out != "" && outDir != "" {
|
||||
return output.ErrValidation("--output and --output-dir cannot both be set")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output")
|
||||
}
|
||||
if out != "" {
|
||||
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
|
||||
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outDir != "" {
|
||||
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
|
||||
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -112,7 +113,7 @@ var MinutesDownload = common.Shortcut{
|
||||
explicitOutputPath = ""
|
||||
case statErr == nil && !fi.IsDir():
|
||||
if !single {
|
||||
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output")
|
||||
}
|
||||
case errors.Is(statErr, fs.ErrNotExist):
|
||||
if !single {
|
||||
@@ -120,7 +121,7 @@ var MinutesDownload = common.Shortcut{
|
||||
explicitOutputPath = ""
|
||||
}
|
||||
default:
|
||||
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +138,7 @@ var MinutesDownload = common.Shortcut{
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
err error // raw typed error for single-mode passthrough
|
||||
}
|
||||
|
||||
results := make([]result, len(tokens))
|
||||
@@ -151,18 +153,18 @@ var MinutesDownload = common.Shortcut{
|
||||
// download URLs originate from the trusted Lark API, not user input.
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return output.ErrNetwork("failed to get HTTP client: %s", err)
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
|
||||
}
|
||||
clonedClient := *baseClient
|
||||
clonedClient.Timeout = disableClientTimeout
|
||||
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxDownloadRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
return fmt.Errorf("too many redirects") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
|
||||
}
|
||||
if len(via) > 0 {
|
||||
prev := via[len(via)-1]
|
||||
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
||||
return fmt.Errorf("redirect from https to http is not allowed")
|
||||
return fmt.Errorf("redirect from https to http is not allowed") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
|
||||
}
|
||||
}
|
||||
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
|
||||
@@ -193,7 +195,7 @@ var MinutesDownload = common.Shortcut{
|
||||
|
||||
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
|
||||
if err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -220,7 +222,7 @@ var MinutesDownload = common.Shortcut{
|
||||
|
||||
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
|
||||
if err != nil {
|
||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
||||
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
|
||||
continue
|
||||
}
|
||||
results[i] = result{
|
||||
@@ -235,7 +237,10 @@ var MinutesDownload = common.Shortcut{
|
||||
if single {
|
||||
r := results[0]
|
||||
if r.Error != "" {
|
||||
return output.ErrAPI(0, r.Error, nil)
|
||||
if r.err != nil {
|
||||
return r.err // typed error from fetchDownloadURL/downloadMediaFile, exit code preserved
|
||||
}
|
||||
return runtime.OutPartialFailure(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
if urlOnly {
|
||||
runtime.Out(map[string]interface{}{
|
||||
@@ -262,17 +267,19 @@ var MinutesDownload = common.Shortcut{
|
||||
}
|
||||
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
|
||||
outData := map[string]interface{}{"downloads": results}
|
||||
meta := &output.Meta{Count: len(results)}
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
|
||||
return runtime.OutPartialFailure(outData, meta)
|
||||
}
|
||||
runtime.OutFormat(outData, meta, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
|
||||
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet,
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
@@ -280,7 +287,7 @@ func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minut
|
||||
}
|
||||
downloadURL := common.GetString(data, "download_url")
|
||||
if downloadURL == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned empty download_url for %s", minuteToken)
|
||||
}
|
||||
return downloadURL, nil
|
||||
}
|
||||
@@ -302,26 +309,26 @@ type downloadOpts struct {
|
||||
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
|
||||
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
|
||||
return nil, output.ErrValidation("blocked download URL: %s", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked download URL: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("invalid download URL: %s", err)
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// resolve output path
|
||||
@@ -340,7 +347,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
|
||||
|
||||
if !opts.overwrite {
|
||||
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +356,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
return nil, common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := opts.fio.ResolvePath(outputPath)
|
||||
if err != nil || resolvedPath == "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -15,9 +16,11 @@ 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/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -694,3 +697,284 @@ func TestDownload_Batch_DryRun(t *testing.T) {
|
||||
t.Errorf("dry-run should show tokens, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed-error lock tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDownload_TypedErr_ValidationInvalidArgument verifies that an invalid
|
||||
// minute token format (passing cobra's required check but failing our regex)
|
||||
// returns a *errs.ValidationError with SubtypeInvalidArgument and the expected
|
||||
// Param. This locks site :64 (invalid minute token %q).
|
||||
func TestDownload_TypedErr_ValidationInvalidArgument(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "INVALID***TOKEN", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 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 {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--minute-tokens" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--minute-tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_TypedErr_NetworkTransport_HttpError verifies that a non-2xx
|
||||
// download response from downloadMediaFile returns a *errs.NetworkError with
|
||||
// SubtypeNetworkTransport.
|
||||
//
|
||||
// In the end-to-end single-token Execute path the typed error is now passed
|
||||
// through directly via r.err (single-mode passthrough). We call downloadMediaFile
|
||||
// directly via a probe shortcut to assert the typed shape at the source.
|
||||
func TestDownload_TypedErr_NetworkTransport_HttpError(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
var capturedErr error
|
||||
probe := common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+probe-dl",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
client, err := rctx.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, capturedErr = downloadMediaFile(ctx, client,
|
||||
"https://example.com/presigned/download", "tok001",
|
||||
downloadOpts{fio: rctx.FileIO(), outputPath: "out.mp4"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "example.com/presigned/download",
|
||||
Status: 503,
|
||||
RawBody: []byte("Service Unavailable"),
|
||||
})
|
||||
|
||||
if err := mountAndRun(t, probe, []string{"+probe-dl", "--as", "bot"}, f, nil); err != nil {
|
||||
t.Fatalf("probe shortcut should not error: %v", err)
|
||||
}
|
||||
if capturedErr == nil {
|
||||
t.Fatal("expected downloadMediaFile to return an error for HTTP 503, got nil")
|
||||
}
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(capturedErr, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T: %v", capturedErr, capturedErr)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !strings.Contains(ne.Error(), "503") {
|
||||
t.Errorf("error message should contain status code 503, got: %v", ne)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_TypedErr_InternalInvalidResponse verifies that fetchDownloadURL
|
||||
// returns *errs.InternalError with SubtypeInvalidResponse when the API
|
||||
// response contains an empty download_url field.
|
||||
//
|
||||
// In the end-to-end single-token Execute path the typed error is now passed
|
||||
// through directly via r.err (single-mode passthrough). The typed assertion
|
||||
// is also made at the fetchDownloadURL call site directly via a probe shortcut.
|
||||
func TestDownload_TypedErr_InternalInvalidResponse(t *testing.T) {
|
||||
var capturedErr error
|
||||
probe := common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+probe-download-url",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, capturedErr = fetchDownloadURL(ctx, rctx, "tok001")
|
||||
// Always return nil so mountAndRun doesn't swallow the error type.
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tok001/media",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"download_url": ""},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRun(t, probe, []string{"+probe-download-url", "--as", "bot"}, f, nil); err != nil {
|
||||
t.Fatalf("probe shortcut should not error: %v", err)
|
||||
}
|
||||
if capturedErr == nil {
|
||||
t.Fatal("expected fetchDownloadURL to return an error for empty download_url, got nil")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(capturedErr, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", capturedErr, capturedErr)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(ie.Error(), "download_url") {
|
||||
t.Errorf("error message should mention download_url, got: %v", ie)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_TypedErr_OverwriteProtection verifies that the overwrite guard
|
||||
// in downloadMediaFile returns *errs.ValidationError with SubtypeFailedPrecondition.
|
||||
//
|
||||
// In the end-to-end single-token Execute path this typed error is now passed
|
||||
// through directly via r.err (single-mode passthrough), so the typed shape is
|
||||
// also asserted end-to-end via the probe shortcut.
|
||||
func TestDownload_TypedErr_OverwriteProtection(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
if err := os.WriteFile("existing.mp4", []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("setup failed: %v", err)
|
||||
}
|
||||
|
||||
var capturedErr error
|
||||
probe := common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+probe-overwrite",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
client, err := rctx.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, capturedErr = downloadMediaFile(ctx, client,
|
||||
"https://example.com/presigned/download", "tok001",
|
||||
downloadOpts{fio: rctx.FileIO(), outputPath: "existing.mp4", overwrite: false})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(downloadStub("example.com/presigned/download", []byte("new-content"), "video/mp4"))
|
||||
|
||||
if err := mountAndRun(t, probe, []string{"+probe-overwrite", "--as", "bot"}, f, nil); err != nil {
|
||||
t.Fatalf("probe shortcut should not error: %v", err)
|
||||
}
|
||||
if capturedErr == nil {
|
||||
t.Fatal("expected downloadMediaFile to return an error for existing file without overwrite, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(capturedErr, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", capturedErr, capturedErr)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Error(), "exists") {
|
||||
t.Errorf("error message should mention exists, got: %v", ve)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_TypedErr_SingleMode_PassthroughTyped verifies that in single-token
|
||||
// mode a typed error from fetchDownloadURL or downloadMediaFile is returned
|
||||
// directly to the caller with its Problem shape intact (exit code preserved).
|
||||
func TestDownload_TypedErr_SingleMode_PassthroughTyped(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// API returns non-zero code → CallAPITyped yields a typed APIError.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tok001/media",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991, "msg": "permission denied",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "out.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure, got nil")
|
||||
}
|
||||
|
||||
// The error must carry a Problem (typed envelope).
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error (ProblemOf ok), got %T: %v", err, err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Fatal("ProblemOf returned nil Problem")
|
||||
}
|
||||
|
||||
// Exit code must be non-zero and come from the typed error, not a generic 1.
|
||||
code := output.ExitCodeOf(err)
|
||||
if code == 0 {
|
||||
t.Errorf("ExitCodeOf typed error = 0, want non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_TypedErr_Batch_AllFail_OutPartialFailure verifies that when every
|
||||
// token in a batch fails, Execute emits an ok:false stdout envelope (carrying the
|
||||
// full downloads array) and returns *output.PartialFailureError with Code==ExitAPI.
|
||||
// This locks the double-emit fix: the old code called OutFormat then returned ErrAPI;
|
||||
// the new code calls OutPartialFailure once.
|
||||
func TestDownload_TypedErr_Batch_AllFail_OutPartialFailure(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// Both tokens fail at the API level.
|
||||
for _, tok := range []string{"tok001", "tok002"} {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + tok + "/media",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991, "msg": "permission denied",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
// Must return *output.PartialFailureError with ExitAPI.
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("PartialFailureError.Code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
|
||||
// stdout must carry ok:false with the downloads array (both failed entries).
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Downloads []struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Error string `json:"error"`
|
||||
} `json:"downloads"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(stdout.Bytes(), &env); jsonErr != nil {
|
||||
t.Fatalf("failed to parse stdout: %v\nraw: %s", jsonErr, stdout.String())
|
||||
}
|
||||
if env.OK {
|
||||
t.Errorf("ok must be false on all-fail batch, got ok:true\nstdout: %s", stdout.String())
|
||||
}
|
||||
if len(env.Data.Downloads) != 2 {
|
||||
t.Fatalf("expected 2 download entries, got %d\nstdout: %s", len(env.Data.Downloads), stdout.String())
|
||||
}
|
||||
for _, d := range env.Data.Downloads {
|
||||
if d.Error == "" {
|
||||
t.Errorf("token %s: expected non-empty error field in all-fail batch", d.MinuteToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -35,28 +36,28 @@ func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
if start != "" {
|
||||
parsed, err := toRFC3339(start)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
startTime = parsed
|
||||
}
|
||||
if end != "" {
|
||||
parsed, err := toRFC3339(end, "end")
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
endTime = parsed
|
||||
}
|
||||
if startTime != "" && endTime != "" {
|
||||
st, err := time.Parse(time.RFC3339, startTime)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse normalized --start: %w", err)
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "parse normalized --start: %v", err).WithCause(err)
|
||||
}
|
||||
et, err := time.Parse(time.RFC3339, endTime)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse normalized --end: %w", err)
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "parse normalized --end: %v", err).WithCause(err)
|
||||
}
|
||||
if st.After(et) {
|
||||
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start (%s) is after --end (%s)", start, end).WithParam("--start")
|
||||
}
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
@@ -70,7 +71,7 @@ func toRFC3339(input string, hint ...string) (string, error) {
|
||||
}
|
||||
sec, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
|
||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err) //nolint:forbidigo // intermediate parse error; callers wrap it into a typed ValidationError
|
||||
}
|
||||
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
@@ -94,7 +95,7 @@ func buildTimeFilter(startTime, endTime string) map[string]interface{} {
|
||||
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,7 +103,7 @@ func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime
|
||||
filter["owner_ids"] = ownerIDs
|
||||
}
|
||||
|
||||
participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -231,26 +232,26 @@ var MinutesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
|
||||
return output.ErrValidation("--query: length must be between 1 and 50 characters")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query: length must be between 1 and 50 characters").WithParam("--query")
|
||||
}
|
||||
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ownerIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--owner-ids", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range participantIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--participant-ids", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -259,7 +260,7 @@ var MinutesSearch = common.Shortcut{
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
startTime, endTime, err := parseTimeRange(runtime)
|
||||
@@ -288,7 +289,7 @@ var MinutesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
|
||||
data, err := runtime.CallAPITyped(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ package minutes
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -63,10 +65,11 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
|
||||
start string
|
||||
end string
|
||||
wantMessage string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
|
||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
|
||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
|
||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:", wantParam: "--start"},
|
||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:", wantParam: "--end"},
|
||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end", wantParam: "--start"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -88,6 +91,16 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -209,6 +222,16 @@ func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "resolvable open_id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--"+tt.flag {
|
||||
t.Fatalf("Param = %q, want --%s", ve.Param, tt.flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -267,6 +290,13 @@ func TestMinutesSearchValidationNoFilter(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "specify at least one") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
|
||||
@@ -279,6 +309,16 @@ func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid user ID error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--participant-ids" {
|
||||
t.Fatalf("Param = %q, want --participant-ids", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
|
||||
@@ -291,6 +331,16 @@ func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid owner ID error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--owner-ids" {
|
||||
t.Fatalf("Param = %q, want --owner-ids", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
|
||||
@@ -306,6 +356,16 @@ func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--query" {
|
||||
t.Fatalf("Param = %q, want --query", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
|
||||
@@ -335,6 +395,16 @@ func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "--page-size") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--page-size" {
|
||||
t.Fatalf("Param = %q, want --page-size", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
|
||||
@@ -372,6 +442,13 @@ func TestMinutesSearchValidationTimeErrors(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -37,27 +36,27 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
if minuteToken == "" {
|
||||
return output.ErrValidation("--minute-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
|
||||
}
|
||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
|
||||
}
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID == "" {
|
||||
return output.ErrValidation("--from-user-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id")
|
||||
}
|
||||
if _, err := common.ValidateUserID(fromUserID); err != nil {
|
||||
return output.ErrValidation("--from-user-id: %s", err)
|
||||
if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
if toUserID == "" {
|
||||
return output.ErrValidation("--to-user-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--to-user-id is required").WithParam("--to-user-id")
|
||||
}
|
||||
if _, err := common.ValidateUserID(toUserID); err != nil {
|
||||
return output.ErrValidation("--to-user-id: %s", err)
|
||||
if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if fromUserID == toUserID {
|
||||
return output.ErrValidation("--from-user-id and --to-user-id must be different")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -84,7 +83,7 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
|
||||
_, err := runtime.CallAPI(http.MethodPut,
|
||||
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
@@ -103,37 +102,18 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
}
|
||||
|
||||
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
switch exitErr.Detail.Code {
|
||||
switch p.Code {
|
||||
case minutesSpeakerReplaceNoEditPermission:
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "no_edit_permission",
|
||||
Code: minutesSpeakerReplaceNoEditPermission,
|
||||
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken),
|
||||
Hint: "Ask the minute owner for minute edit permission",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
case minutesSpeakerReplaceSpeakerNotFoundCode:
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "speaker_not_found",
|
||||
Code: minutesSpeakerReplaceSpeakerNotFoundCode,
|
||||
Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID),
|
||||
Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID)
|
||||
p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry."
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -44,12 +44,12 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
|
||||
{
|
||||
name: "invalid from prefix",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
|
||||
wantErr: "--from-user-id",
|
||||
wantErr: "invalid user ID format",
|
||||
},
|
||||
{
|
||||
name: "invalid to prefix",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
|
||||
wantErr: "--to-user-id",
|
||||
wantErr: "invalid user ID format",
|
||||
},
|
||||
{
|
||||
name: "from equals to",
|
||||
@@ -76,6 +76,52 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_ValidateTyped(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "invalid from prefix",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
|
||||
wantParam: "--from-user-id",
|
||||
},
|
||||
{
|
||||
name: "from equals to",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"},
|
||||
wantParam: "--to-user-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesSpeakerReplace.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype=%q", ve.Subtype)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Errorf("param=%q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
@@ -179,24 +225,21 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
|
||||
t.Fatal("expected speaker-not-found error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want typed errs.*, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error detail, got nil")
|
||||
if p.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if exitErr.Detail.Type != "speaker_not_found" {
|
||||
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
|
||||
if !strings.Contains(p.Message, "Speaker not found") {
|
||||
t.Errorf("message should be friendly, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "Speaker not found") {
|
||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
||||
if !strings.Contains(p.Message, "ou_missing_speaker") {
|
||||
t.Errorf("message should include missing speaker id, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") {
|
||||
t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") {
|
||||
t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "--from-user-id") {
|
||||
t.Errorf("hint should mention --from-user-id, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,23 +268,20 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
|
||||
t.Fatal("expected no-edit-permission error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want typed errs.*, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error detail, got nil")
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if exitErr.Detail.Type != "no_edit_permission" {
|
||||
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
|
||||
if !strings.Contains(p.Message, "No edit permission") {
|
||||
t.Errorf("message should be friendly, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
|
||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
||||
if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,13 +32,13 @@ var MinutesUpdate = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
if minuteToken == "" {
|
||||
return output.ErrValidation("--minute-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
|
||||
}
|
||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("topic")) == "" {
|
||||
return output.ErrValidation("--topic is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--topic is required").WithParam("--topic")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -57,7 +56,7 @@ var MinutesUpdate = common.Shortcut{
|
||||
"topic": topic,
|
||||
}
|
||||
|
||||
_, err := runtime.CallAPI(http.MethodPatch,
|
||||
_, err := runtime.CallAPITyped(http.MethodPatch,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
@@ -75,20 +74,11 @@ var MinutesUpdate = common.Shortcut{
|
||||
}
|
||||
|
||||
func minutesUpdateError(err error, minuteToken string) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != minutesUpdateNoEditPermissionCode {
|
||||
return err
|
||||
}
|
||||
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "no_edit_permission",
|
||||
Code: minutesUpdateNoEditPermissionCode,
|
||||
Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken),
|
||||
Hint: "Ask the minute owner for minute edit permission",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken)
|
||||
p.Hint = "Ask the minute owner for minute edit permission"
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -55,6 +55,32 @@ func TestMinutesUpdate_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpdate_ValidateTyped(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// ".." triggers ResourceName rejection — hits our Validate, not cobra's required-flag check.
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesUpdate.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+update", "--minute-token", "..", "--topic", "title", "--as", "user"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype=%q", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--minute-token" {
|
||||
t.Errorf("param=%q", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpdate_DryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
@@ -132,23 +158,20 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) {
|
||||
t.Fatal("expected no-edit-permission error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want typed errs.*, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error detail, got nil")
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if exitErr.Detail.Type != "no_edit_permission" {
|
||||
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
|
||||
if !strings.Contains(p.Message, "No edit permission") {
|
||||
t.Errorf("message should be friendly, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
|
||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
||||
if !strings.Contains(p.Message, minutesUpdateTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package minutes
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -36,10 +36,10 @@ var MinutesUpload = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
if fileToken == "" {
|
||||
return output.ErrValidation("--file-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required").WithParam("--file-token")
|
||||
}
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,7 +55,7 @@ var MinutesUpload = common.Shortcut{
|
||||
"file_token": fileToken,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -46,6 +48,31 @@ func TestMinutesUpload_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_ValidateTyped(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// ".." triggers ResourceName rejection — hits our Validate, not cobra's required-flag check.
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesUpload.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+upload", "--file-token", "..", "--as", "user"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype=%q", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "--file-token" {
|
||||
t.Errorf("param=%q", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_HelpMetadata(t *testing.T) {
|
||||
if len(MinutesUpload.Flags) == 0 {
|
||||
t.Fatal("expected file-token flag metadata")
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) {
|
||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -166,121 +166,111 @@ func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T
|
||||
if docsCmd == nil || docsCmd.Name() != "docs" {
|
||||
t.Fatalf("docs command not mounted: %#v", docsCmd)
|
||||
}
|
||||
if docsCmd.Flags().Lookup("api-version") == nil {
|
||||
t.Fatal("docs command should expose --api-version for versioned help")
|
||||
if docsCmd.Flags().Lookup("api-version") != nil {
|
||||
t.Fatal("docs command should not expose service-level --api-version")
|
||||
}
|
||||
|
||||
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
|
||||
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
|
||||
}
|
||||
|
||||
for _, child := range docsCmd.Commands() {
|
||||
if child.Name() == "+get-skill" {
|
||||
t.Fatal("docs +get-skill should not be mounted")
|
||||
}
|
||||
}
|
||||
|
||||
var defaultHelp bytes.Buffer
|
||||
docsCmd.SetOut(&defaultHelp)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs help failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Tips:",
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
"Start here (required for AI agents):",
|
||||
"lark-cli skills read lark-doc",
|
||||
"AI agents MUST read the matching embedded skill",
|
||||
"Do not skip this step",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
} {
|
||||
if !strings.Contains(defaultHelp.String(), want) {
|
||||
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
|
||||
t.Fatalf("set docs api-version: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
docsCmd.SetOut(&out)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs v2 help failed: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"Document and content operations (v2).",
|
||||
"Tips:",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
if startIdx, usageIdx := strings.Index(defaultHelp.String(), "Start here (required for AI agents):"), strings.Index(defaultHelp.String(), "Usage:"); startIdx < 0 || usageIdx < 0 || startIdx > usageIdx {
|
||||
t.Fatalf("docs help should show Start here before Usage:\n%s", defaultHelp.String())
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Tips:",
|
||||
"+get-skill",
|
||||
"Docs shortcuts are v2-only",
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
"lark-cli update",
|
||||
"upgrade skills",
|
||||
"Use --api-version v2 for the latest API",
|
||||
} {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String())
|
||||
if strings.Contains(defaultHelp.String(), unwanted) {
|
||||
t.Fatalf("docs help should not include %q:\n%s", unwanted, defaultHelp.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
|
||||
func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
apiVersion string
|
||||
shortcutHelp string
|
||||
versionedFlag string
|
||||
name string
|
||||
shortcut string
|
||||
shortcutHelp string
|
||||
visibleFlag string
|
||||
skillCommand string
|
||||
hiddenFlags []string
|
||||
contentHelp []string
|
||||
unwanted []string
|
||||
}{
|
||||
{
|
||||
name: "create v1",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--markdown",
|
||||
name: "create",
|
||||
shortcut: "+create",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
visibleFlag: "--content",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
contentHelp: []string{
|
||||
"AI agents MUST read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"before writing any --content payload",
|
||||
"when using --doc-format markdown, also read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"Follow the latest rules",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"use --help for the latest command flags",
|
||||
},
|
||||
unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
},
|
||||
{
|
||||
name: "create v2",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--content",
|
||||
name: "fetch",
|
||||
shortcut: "+fetch",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
visibleFlag: "read scope",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||
hiddenFlags: []string{"offset", "limit"},
|
||||
unwanted: []string{"--offset", "--limit"},
|
||||
},
|
||||
{
|
||||
name: "fetch v1",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "--offset",
|
||||
},
|
||||
{
|
||||
name: "fetch v2",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "partial read scope",
|
||||
},
|
||||
{
|
||||
name: "update v1",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--mode",
|
||||
},
|
||||
{
|
||||
name: "update v2",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--command",
|
||||
name: "update",
|
||||
shortcut: "+update",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
visibleFlag: "--command",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-update.md",
|
||||
hiddenFlags: []string{"mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"},
|
||||
contentHelp: []string{
|
||||
"AI agents MUST read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"before writing any --content payload",
|
||||
"when using --doc-format markdown, also read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||
"Follow the latest rules",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"use --help for the latest command flags",
|
||||
},
|
||||
unwanted: []string{"--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -296,8 +286,25 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
|
||||
if cmd == nil || cmd.Name() != tt.shortcut {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
|
||||
}
|
||||
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
|
||||
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
|
||||
|
||||
for _, flagName := range tt.hiddenFlags {
|
||||
flag := cmd.Flags().Lookup(flagName)
|
||||
if flag == nil {
|
||||
t.Fatalf("docs %s missing hidden compatibility flag %q", tt.shortcut, flagName)
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatalf("docs %s flag %q should be hidden", tt.shortcut, flagName)
|
||||
}
|
||||
}
|
||||
apiVersionFlag := cmd.Flags().Lookup("api-version")
|
||||
if apiVersionFlag == nil {
|
||||
t.Fatalf("docs %s missing --api-version flag", tt.shortcut)
|
||||
}
|
||||
if apiVersionFlag.Hidden {
|
||||
t.Fatalf("docs %s --api-version should be visible", tt.shortcut)
|
||||
}
|
||||
if apiVersionFlag.DefValue != "v2" {
|
||||
t.Fatalf("docs %s --api-version default = %q, want v2", tt.shortcut, apiVersionFlag.DefValue)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
@@ -306,49 +313,39 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
|
||||
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
wantTips := []string{
|
||||
"Tips:",
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
}
|
||||
unwantedTips := []string{
|
||||
"[NOTE]",
|
||||
"Use --api-version v2 for the latest API",
|
||||
"otherwise use the default v1 flags",
|
||||
"legacy v1 examples and flags",
|
||||
}
|
||||
if tt.apiVersion == "v2" {
|
||||
wantTips = []string{
|
||||
"Tips:",
|
||||
"Check the installed lark-doc skill first",
|
||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
||||
}
|
||||
unwantedTips = append(unwantedTips,
|
||||
"Docs v1 is deprecated and will be removed soon",
|
||||
"After confirming lark-doc is v2",
|
||||
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
|
||||
)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
tt.shortcutHelp,
|
||||
tt.versionedFlag,
|
||||
tt.visibleFlag,
|
||||
"--api-version",
|
||||
"deprecated compatibility flag; docs shortcuts always use v2",
|
||||
"both v1/v2 are accepted",
|
||||
"(default \"v2\")",
|
||||
"Start here (required for AI agents):",
|
||||
"AI agents MUST read the matching embedded skill",
|
||||
"Do not skip this step",
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
tt.skillCommand,
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
t.Fatalf("docs %s help missing %q:\n%s", tt.shortcut, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
for _, want := range tt.contentHelp {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
t.Fatalf("docs %s content help missing %q:\n%s", tt.shortcut, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range unwantedTips {
|
||||
if startIdx, usageIdx := strings.Index(out.String(), "Start here (required for AI agents):"), strings.Index(out.String(), "Usage:"); startIdx < 0 || usageIdx < 0 || startIdx > usageIdx {
|
||||
t.Fatalf("docs %s help should show Start here before Usage:\n%s", tt.shortcut, out.String())
|
||||
}
|
||||
for _, unwanted := range []string{"Tips:", "+get-skill", "Docs shortcuts are v2-only"} {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
|
||||
t.Fatalf("docs %s help should not include %q:\n%s", tt.shortcut, unwanted, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tt.unwanted {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs %s help should not include %q:\n%s", tt.shortcut, unwanted, out.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -177,6 +177,18 @@ 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,6 +152,12 @@ 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)},
|
||||
|
||||
58
shortcuts/sheets/csv_put_guard_test.go
Normal file
58
shortcuts/sheets/csv_put_guard_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// 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; appended to the end when omitted",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -413,6 +413,86 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+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": [
|
||||
@@ -431,22 +511,33 @@
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"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": "values",
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"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).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -513,6 +604,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+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": [
|
||||
@@ -1212,19 +1329,65 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "rows-json",
|
||||
"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",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
"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",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1849,7 +2012,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"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": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1880,6 +2043,43 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+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,11 +1730,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1787,11 +1788,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2769,11 +2766,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2826,11 +2824,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6249,6 +6243,457 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+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,14 +364,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
// 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).
|
||||
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",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -381,8 +384,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -392,14 +395,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// 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.
|
||||
// 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.
|
||||
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()
|
||||
@@ -420,8 +422,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
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 data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user