mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
21 Commits
codex/opti
...
codex/insp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54abe817c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 |
@@ -73,20 +73,20 @@ linters:
|
|||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||||
# Add a path when its migration is complete.
|
# 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/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||||
text: errs-typed-only
|
text: errs-typed-only
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||||
text: errs-no-bare-wrap
|
text: errs-no-bare-wrap
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||||
# it bans are still used by other domains until their later migration phase.
|
# helpers have migrated to typed final errors.
|
||||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||||
text: errs-no-legacy-helper
|
text: errs-no-legacy-helper
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
@@ -116,16 +116,14 @@ linters:
|
|||||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||||
(see errs/types.go).
|
(see errs/types.go).
|
||||||
# ── legacy shared error helpers banned on migrated domains ──
|
# ── legacy shared error helpers banned on migrated domains ──
|
||||||
# These helpers internally produce legacy output.Err* shapes, so they
|
# These helpers emit legacy output.Err* / bare error shapes or drop
|
||||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
# typed metadata such as Param/Cause. Migrated domains must use typed
|
||||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
# common replacements or local typed helpers instead.
|
||||||
# prevents reintroduction while unmigrated domains continue to use the
|
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||||
# shared helpers until their later migration phase.
|
|
||||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
|
||||||
msg: >-
|
msg: >-
|
||||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
[errs-no-legacy-helper] these shared helpers emit legacy or
|
||||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
metadata-poor error shapes. Use typed common replacements, typed
|
||||||
file-I/O helper.
|
errs.NewXxxError builders, or domain-local typed helpers.
|
||||||
# ── bare error wraps banned on fully-typed paths ──
|
# ── bare error wraps banned on fully-typed paths ──
|
||||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|||||||
16
cmd/build.go
16
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/profile"
|
"github.com/larksuite/cli/cmd/profile"
|
||||||
"github.com/larksuite/cli/cmd/schema"
|
"github.com/larksuite/cli/cmd/schema"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/cmd/skill"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
@@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||||
|
// at build time. It is registered by the repo-root package main's init via
|
||||||
|
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||||
|
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||||
|
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||||
|
var embeddedSkillContent fs.FS
|
||||||
|
|
||||||
|
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||||
|
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||||
|
// supply its own skill content.
|
||||||
|
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||||
|
|
||||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||||
// When hide is true the flag stays registered (so existing invocations still
|
// When hide is true the flag stays registered (so existing invocations still
|
||||||
// parse) but is omitted from help and shell completion. Typically called as
|
// parse) but is omitted from help and shell completion. Typically called as
|
||||||
@@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
if cfg.keychain != nil {
|
if cfg.keychain != nil {
|
||||||
f.Keychain = cfg.keychain
|
f.Keychain = cfg.keychain
|
||||||
}
|
}
|
||||||
|
f.SkillContent = embeddedSkillContent
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "lark-cli",
|
Use: "lark-cli",
|
||||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||||
@@ -140,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||||
|
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
|
|||||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
|||||||
OK: false,
|
OK: false,
|
||||||
Identity: "bot",
|
Identity: "bot",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "api_error",
|
Type: "api",
|
||||||
Code: 230002,
|
Code: 230002,
|
||||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
Message: "Bot/User can NOT be out of the chat.",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
183
cmd/skill/skill.go
Normal file
183
cmd/skill/skill.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package skill implements the `lark-cli skills` command group, which serves
|
||||||
|
// binary-embedded skill content to AI agents. The package is "skill"; the
|
||||||
|
// user-facing verb is "skills".
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/skillcontent"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
|
||||||
|
if f.SkillContent == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"skill content not embedded in this build")
|
||||||
|
}
|
||||||
|
return skillcontent.New(f.SkillContent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type readEnvelope struct {
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Guidance string `json:"guidance,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listEnvelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Skills []skillcontent.SkillInfo `json:"skills"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listPathEnvelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entries []skillcontent.DirEntry `json:"entries"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "skills",
|
||||||
|
Short: "Read embedded skill content (list / read)",
|
||||||
|
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
|
||||||
|
"the CLI binary at build time, so it stays in sync with the CLI version. " +
|
||||||
|
"Machine resources such as assets/ and scripts/ are not embedded.",
|
||||||
|
}
|
||||||
|
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
cmd.AddCommand(newListCmd(f), newReadCmd(f))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list [name[/path]]",
|
||||||
|
Short: "List skills, or list one layer under a skill path (like ls)",
|
||||||
|
Example: ` lark-cli skills list # all skills: name, description, version
|
||||||
|
lark-cli skills list lark-doc # one layer under a skill (like ls)
|
||||||
|
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 1 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"list takes at most 1 argument: [name[/path]]").
|
||||||
|
WithHint("run 'lark-cli skills list --help'")
|
||||||
|
}
|
||||||
|
r, err := newReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
skills, err := r.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, listed, err := r.ListPath(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
|
||||||
|
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
|
||||||
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "read <name>[/<path>] [path]",
|
||||||
|
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
|
||||||
|
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
|
||||||
|
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
|
||||||
|
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
|
||||||
|
lark-cli skills read lark-doc --json # JSON envelope`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name, relpath, err := parseReadTarget(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r, err := newReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var pathOut string
|
||||||
|
if relpath == "" {
|
||||||
|
content, err = r.ReadSkill(name)
|
||||||
|
pathOut = "SKILL.md"
|
||||||
|
} else {
|
||||||
|
content, pathOut, err = r.ReadReference(name, relpath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isMain := pathOut == "SKILL.md"
|
||||||
|
if asJSON {
|
||||||
|
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
|
||||||
|
if isMain {
|
||||||
|
env.Guidance = readGuidance(name)
|
||||||
|
}
|
||||||
|
output.PrintJson(f.IOStreams.Out, env)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
|
||||||
|
if _, err := f.IOStreams.Out.Write(content); err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
|
||||||
|
}
|
||||||
|
if isMain {
|
||||||
|
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
|
||||||
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
|
||||||
|
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
|
||||||
|
func parseReadTarget(args []string) (name, relpath string, err error) {
|
||||||
|
switch len(args) {
|
||||||
|
case 1:
|
||||||
|
name, relpath = skillcontent.SplitArg(args[0])
|
||||||
|
return name, relpath, nil
|
||||||
|
case 2:
|
||||||
|
return args[0], args[1], nil
|
||||||
|
default:
|
||||||
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
|
||||||
|
WithHint("run 'lark-cli skills read --help'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readGuidance routes cross-skill "../lark-foo/..." references back through
|
||||||
|
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
|
||||||
|
// relative form must be rewritten.
|
||||||
|
func readGuidance(name string) string {
|
||||||
|
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
|
||||||
|
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
|
||||||
|
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
|
||||||
|
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
|
||||||
|
}
|
||||||
306
cmd/skill/skill_test.go
Normal file
306
cmd/skill/skill_test.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calFS is the default single-skill content tree for these tests. The embedded
|
||||||
|
// FS is now injected through the Factory (no package global), so tests pass it
|
||||||
|
// explicitly to run() — nothing is shared, so they are safe under -parallel.
|
||||||
|
func calFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
|
||||||
|
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes the skills command tree against the given content FS (may be nil
|
||||||
|
// to exercise the not-embedded path) and returns stdout/stderr/err.
|
||||||
|
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
|
||||||
|
t.Helper()
|
||||||
|
// Isolate CLI config state so tests never read/write the real config dir
|
||||||
|
// (repo convention).
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.SkillContent = fsys
|
||||||
|
cmd := NewCmdSkill(f)
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err = cmd.Execute()
|
||||||
|
return out.String(), errOut.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillList(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Skills []map[string]any `json:"skills"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
// "ok" is an explicit success marker (the list envelope is a typed struct;
|
||||||
|
// no automatic _notice attaches).
|
||||||
|
if !got.OK {
|
||||||
|
t.Error("expected ok=true in list envelope")
|
||||||
|
}
|
||||||
|
if got.Count != 1 || len(got.Skills) != 1 {
|
||||||
|
t.Fatalf("count: got %d", got.Count)
|
||||||
|
}
|
||||||
|
if got.Skills[0]["name"] != "lark-calendar" {
|
||||||
|
t.Errorf("name: got %v", got.Skills[0]["name"])
|
||||||
|
}
|
||||||
|
// Top-level list carries version + metadata, not a references list.
|
||||||
|
if _, ok := got.Skills[0]["references"]; ok {
|
||||||
|
t.Error("top-level list must not include references")
|
||||||
|
}
|
||||||
|
if got.Skills[0]["version"] != "1.0.0" {
|
||||||
|
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
|
||||||
|
}
|
||||||
|
if _, ok := got.Skills[0]["metadata"]; !ok {
|
||||||
|
t.Error("expected metadata in list entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListJSONFlagAccepted(t *testing.T) {
|
||||||
|
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
|
||||||
|
// so it stays symmetric with read --json.
|
||||||
|
stdout, _, err := run(t, calFS(), "list", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list --json error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if !got.OK || got.Count != 1 {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListPath(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list <name> error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entries []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
} `json:"entries"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if !got.OK || got.Path != "lark-calendar" {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
// One layer under the skill root: SKILL.md (file) + references (dir).
|
||||||
|
if got.Count != 2 || len(got.Entries) != 2 {
|
||||||
|
t.Fatalf("entries: got %+v", got.Entries)
|
||||||
|
}
|
||||||
|
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
|
||||||
|
t.Errorf("entry[0]: got %+v", got.Entries[0])
|
||||||
|
}
|
||||||
|
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
|
||||||
|
t.Errorf("entry[1]: got %+v", got.Entries[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListPathUnknown(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "list", "no-such-skill")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||||
|
t.Fatalf("expected 'unknown skill' error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListPathTraversal(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid path") {
|
||||||
|
t.Fatalf("expected 'invalid path' error, got %v", err)
|
||||||
|
}
|
||||||
|
if stdout != "" {
|
||||||
|
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListTooManyArgs(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "list", "a", "b")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
|
||||||
|
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
|
||||||
|
// omitted from the catalog (no blank entry).
|
||||||
|
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
|
||||||
|
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
|
||||||
|
}
|
||||||
|
stdout, _, err := run(t, fsys, "list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Skills []map[string]any `json:"skills"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
|
||||||
|
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadRaw(t *testing.T) {
|
||||||
|
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
|
||||||
|
t.Errorf("raw output: got %q", stdout)
|
||||||
|
}
|
||||||
|
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
|
||||||
|
if strings.Contains(stdout, "Tip:") {
|
||||||
|
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
|
||||||
|
}
|
||||||
|
// Guidance goes to stderr: own files via `skills read <name> ...`, and
|
||||||
|
// cross-skill refs routed to `skills read <other-skill> ...` (version-
|
||||||
|
// consistent), not "read directly".
|
||||||
|
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
|
||||||
|
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
|
||||||
|
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr, "instead of opening them directly") ||
|
||||||
|
strings.Contains(stderr, "read those directly") {
|
||||||
|
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadJSON(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read --json error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Skill, Path, Content, Guidance string
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", e)
|
||||||
|
}
|
||||||
|
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
// Guidance is a separate field, not merged into content.
|
||||||
|
if got.Guidance == "" {
|
||||||
|
t.Error("expected guidance field for main SKILL.md")
|
||||||
|
}
|
||||||
|
if strings.Contains(got.Content, "Tip:") {
|
||||||
|
t.Error("guidance must not be merged into content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadFile(t *testing.T) {
|
||||||
|
// Both the 2-arg and slash forms read the same file, with no guidance tip.
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"read", "lark-calendar", "references/agenda.md"},
|
||||||
|
{"read", "lark-calendar/references/agenda.md"},
|
||||||
|
} {
|
||||||
|
stdout, stderr, err := run(t, calFS(), args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %v error: %v", args, err)
|
||||||
|
}
|
||||||
|
if stdout != "# Agenda" {
|
||||||
|
t.Errorf("read %v output: got %q", args, stdout)
|
||||||
|
}
|
||||||
|
// Reference reads carry no guidance on either stream.
|
||||||
|
if strings.Contains(stderr, "Tip:") {
|
||||||
|
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadFileJSON(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read file --json error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Skill, Path, Content, Guidance string
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
// Reference reads do not carry the guidance tip.
|
||||||
|
if got.Guidance != "" {
|
||||||
|
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadUnknown(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "read", "no-such")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown skill") {
|
||||||
|
t.Errorf("err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadMissingArg(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "read")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
|
||||||
|
t.Fatalf("expected arg error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadTraversal(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected rejection")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid path") {
|
||||||
|
t.Errorf("err: %v", err)
|
||||||
|
}
|
||||||
|
if stdout != "" {
|
||||||
|
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillNilContentFS(t *testing.T) {
|
||||||
|
_, _, err := run(t, nil, "list")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when SkillContent is nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not embedded") {
|
||||||
|
t.Errorf("err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,12 +49,21 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
u.NpmInstallOverride = npmFn
|
u.NpmInstallOverride = npmFn
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
|
return func() *selfupdate.NpmResult {
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||||
return func(args ...string) *selfupdate.NpmResult {
|
return func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -478,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
|||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||||
u.RestoreAvailableOverride = func() bool { return false }
|
u.RestoreAvailableOverride = func() bool { return false }
|
||||||
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||||
|
t.Fatal("skills sync should not run when binary verification fails")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
t.Fatal("skills sync should not run when binary verification fails")
|
t.Fatal("skills sync should not run when binary verification fails")
|
||||||
return nil
|
return nil
|
||||||
@@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Err = fmt.Errorf("index unavailable")
|
||||||
|
return r
|
||||||
|
}
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
@@ -862,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Err = fmt.Errorf("index unavailable")
|
||||||
|
return r
|
||||||
|
}
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
@@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
@@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{
|
return selfupdate.DetectResult{
|
||||||
Method: selfupdate.InstallManual,
|
Method: selfupdate.InstallManual,
|
||||||
@@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{
|
return selfupdate.DetectResult{
|
||||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||||
@@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||||
},
|
},
|
||||||
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||||
|
skillsCalled = true
|
||||||
|
return successfulSkillsIndexFetch()()
|
||||||
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
@@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||||
|
skillsCalled = true
|
||||||
|
return successfulSkillsIndexFetch()()
|
||||||
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/larksuite/cli/events/im"
|
"github.com/larksuite/cli/events/im"
|
||||||
"github.com/larksuite/cli/events/minutes"
|
"github.com/larksuite/cli/events/minutes"
|
||||||
"github.com/larksuite/cli/events/vc"
|
"github.com/larksuite/cli/events/vc"
|
||||||
|
"github.com/larksuite/cli/events/whiteboard"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ func init() {
|
|||||||
im.Keys(),
|
im.Keys(),
|
||||||
minutes.Keys(),
|
minutes.Keys(),
|
||||||
vc.Keys(),
|
vc.Keys(),
|
||||||
|
whiteboard.Keys(),
|
||||||
}
|
}
|
||||||
for _, keys := range all {
|
for _, keys := range all {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
|||||||
23
events/whiteboard/native.go
Normal file
23
events/whiteboard/native.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||||
|
type BoardWhiteboardUpdatedV1Data struct {
|
||||||
|
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||||
|
WhiteboardID string `json:"whiteboard_id"`
|
||||||
|
// OperatorIDs lists the operators that produced this update batch.
|
||||||
|
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorID identifies an operator that produced the whiteboard update,
|
||||||
|
// expressed in the three Lark identity formats.
|
||||||
|
type OperatorID struct {
|
||||||
|
// OpenID is the operator's open_id within the current app.
|
||||||
|
OpenID string `json:"open_id"`
|
||||||
|
// UnionID is the operator's union_id across apps under the same ISV.
|
||||||
|
UnionID string `json:"union_id"`
|
||||||
|
// UserID is the operator's user_id within the tenant.
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
48
events/whiteboard/preconsume.go
Normal file
48
events/whiteboard/preconsume.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanupTimeout bounds how long the unsubscribe call has to finish during
|
||||||
|
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
|
||||||
|
const cleanupTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
|
||||||
|
// and returns a cleanup that invokes the matching unsubscribe.
|
||||||
|
//
|
||||||
|
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||||
|
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
whiteboardID := params["whiteboard_id"]
|
||||||
|
if whiteboardID == "" {
|
||||||
|
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||||
|
}
|
||||||
|
encoded := validate.EncodePathSegment(whiteboardID)
|
||||||
|
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||||
|
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
|
||||||
|
|
||||||
|
body := map[string]string{"event_type": eventType}
|
||||||
|
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
198
events/whiteboard/preconsume_test.go
Normal file
198
events/whiteboard/preconsume_test.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordedCall captures a single APIClient invocation for assertion.
|
||||||
|
type recordedCall struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAPIClient is a minimal event.APIClient stub that records calls and
|
||||||
|
// can be configured to fail when the request path matches errOnPath.
|
||||||
|
type fakeAPIClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []recordedCall
|
||||||
|
errOnPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallAPI records the invocation and optionally returns a simulated error
|
||||||
|
// when the path contains the configured errOnPath substring.
|
||||||
|
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||||
|
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
|
||||||
|
return nil, errors.New("simulated subscribe failure")
|
||||||
|
}
|
||||||
|
return json.RawMessage(`{}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
|
||||||
|
// PreConsume hook fails fast with an actionable error when whiteboard_id
|
||||||
|
// is absent from the params map.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error when whiteboard_id missing")
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
t.Fatalf("expected nil cleanup on error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||||
|
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||||
|
// returns an error when the runtime APIClient dependency is missing.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error when runtime client is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||||
|
// failed subscribe call surfaces the error and skips registering a cleanup,
|
||||||
|
// so no spurious unsubscribe is invoked.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
rt := &fakeAPIClient{errOnPath: "/subscribe"}
|
||||||
|
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error from subscribe call")
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
t.Fatalf("expected nil cleanup when subscribe fails")
|
||||||
|
}
|
||||||
|
// only the failed subscribe call should have been made; no unsubscribe.
|
||||||
|
if len(rt.calls) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
|
||||||
|
// happy-path: subscribe is called once with the correct method/path/body,
|
||||||
|
// and the returned cleanup invokes the matching unsubscribe.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
rt := &fakeAPIClient{}
|
||||||
|
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cleanup == nil {
|
||||||
|
t.Fatalf("expected non-nil cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rt.calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
got := rt.calls[0]
|
||||||
|
if got.method != "POST" {
|
||||||
|
t.Errorf("subscribe method: got %q, want POST", got.method)
|
||||||
|
}
|
||||||
|
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
|
||||||
|
if got.path != wantSubPath {
|
||||||
|
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
|
||||||
|
}
|
||||||
|
body, _ := got.body.(map[string]string)
|
||||||
|
if body["event_type"] != eventTypeWhiteboardUpdated {
|
||||||
|
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
if len(rt.calls) != 2 {
|
||||||
|
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
got2 := rt.calls[1]
|
||||||
|
if got2.method != "POST" {
|
||||||
|
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
|
||||||
|
}
|
||||||
|
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
|
||||||
|
if got2.path != wantUnsubPath {
|
||||||
|
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
|
||||||
|
}
|
||||||
|
body2, _ := got2.body.(map[string]string)
|
||||||
|
if body2["event_type"] != eventTypeWhiteboardUpdated {
|
||||||
|
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
|
||||||
|
// whiteboard_id values containing reserved URL characters are properly
|
||||||
|
// path-segment encoded so they cannot escape into adjacent path segments.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
rt := &fakeAPIClient{}
|
||||||
|
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
|
||||||
|
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(rt.calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call, got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
|
||||||
|
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
|
||||||
|
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
|
||||||
|
// required whiteboard_id parameter.
|
||||||
|
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
keys := Keys()
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.Key == eventTypeWhiteboardUpdated {
|
||||||
|
if k.PreConsume == nil {
|
||||||
|
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
if len(k.Params) == 0 {
|
||||||
|
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, p := range k.Params {
|
||||||
|
if p.Name == "whiteboard_id" && p.Required {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 event.APIClient 接口与本测试 mock 一致。
|
||||||
|
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||||
48
events/whiteboard/register.go
Normal file
48
events/whiteboard/register.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package whiteboard registers Board-domain EventKeys.
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
"github.com/larksuite/cli/internal/event/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||||
|
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||||
|
|
||||||
|
// Keys returns all Board-domain EventKey definitions.
|
||||||
|
func Keys() []event.KeyDefinition {
|
||||||
|
return []event.KeyDefinition{
|
||||||
|
{
|
||||||
|
Key: eventTypeWhiteboardUpdated,
|
||||||
|
DisplayName: "Whiteboard updated",
|
||||||
|
Description: "Pushed when the whiteboard content is updated.",
|
||||||
|
EventType: eventTypeWhiteboardUpdated,
|
||||||
|
Params: []event.ParamDef{
|
||||||
|
{
|
||||||
|
Name: "whiteboard_id",
|
||||||
|
Type: event.ParamString,
|
||||||
|
Required: true,
|
||||||
|
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||||
|
FieldOverrides: map[string]schemas.FieldMeta{
|
||||||
|
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||||
|
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||||
|
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||||
|
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||||
|
Scopes: []string{"board:whiteboard:node:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ type Factory struct {
|
|||||||
Credential *credential.CredentialProvider
|
Credential *credential.CredentialProvider
|
||||||
|
|
||||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||||
|
|
||||||
|
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
base.Troubleshooter = ts
|
base.Troubleshooter = ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
||||||
|
// returns these as free-text reason strings with no machine-readable field
|
||||||
|
// name (verified for code 190014:
|
||||||
|
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
||||||
|
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
||||||
|
// prompt — rather than fabricated structured params. Lifted before the
|
||||||
|
// category switch so any classified arm inherits it; the CategoryAPI arm
|
||||||
|
// below prefers this server detail over the context-free APIHint default.
|
||||||
|
detailHint := liftErrorDetailValues(resp)
|
||||||
|
if detailHint != "" {
|
||||||
|
base.Hint = detailHint
|
||||||
|
}
|
||||||
|
|
||||||
switch meta.Category {
|
switch meta.Category {
|
||||||
case errs.CategoryAuthorization:
|
case errs.CategoryAuthorization:
|
||||||
@@ -129,7 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
Action: action,
|
Action: action,
|
||||||
}
|
}
|
||||||
case errs.CategoryAPI:
|
case errs.CategoryAPI:
|
||||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
// A server-supplied detail (lifted into base.Hint above) wins over the
|
||||||
|
// context-free APIHint default; only fall back to APIHint when absent.
|
||||||
|
if base.Hint == "" {
|
||||||
|
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||||
|
}
|
||||||
return &errs.APIError{Problem: base}
|
return &errs.APIError{Problem: base}
|
||||||
default:
|
default:
|
||||||
// Fail closed: an unrecognized Category routes to InternalError
|
// Fail closed: an unrecognized Category routes to InternalError
|
||||||
@@ -214,6 +230,10 @@ func stringFromAny(v any) string {
|
|||||||
// per-subtype recovery hint before returning it, so the wire envelope
|
// per-subtype recovery hint before returning it, so the wire envelope
|
||||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||||
|
// Config categories have authoritative recovery guidance, so the curated
|
||||||
|
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
||||||
|
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
||||||
|
// detail wins).
|
||||||
p.Hint = ConfigHint(p.Subtype)
|
p.Hint = ConfigHint(p.Subtype)
|
||||||
return &errs.ConfigError{Problem: p}
|
return &errs.ConfigError{Problem: p}
|
||||||
}
|
}
|
||||||
@@ -258,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
|||||||
}
|
}
|
||||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||||
|
// Permission categories have authoritative recovery guidance (scopes to
|
||||||
|
// grant, console URL), so the curated PermissionHint deliberately overrides
|
||||||
|
// any server detail lifted into p.Hint (the opposite precedence from the
|
||||||
|
// CategoryAPI arm, where the lifted detail wins).
|
||||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||||
permErr := &errs.PermissionError{
|
permErr := &errs.PermissionError{
|
||||||
Problem: p,
|
Problem: p,
|
||||||
@@ -366,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
|
|||||||
return "check the calling identity has the required scope"
|
return "check the calling identity has the required scope"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||||
|
// strings and joins them with "; ". Returns "" when the structure is absent or
|
||||||
|
// carries no non-empty value. The shape (verified for code 190014) is
|
||||||
|
// {"error":{"details":[{"value":"<reason>"}]}}.
|
||||||
|
func liftErrorDetailValues(resp map[string]any) string {
|
||||||
|
errBlock, ok := resp["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
details, ok := errBlock["details"].([]any)
|
||||||
|
if !ok || len(details) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
for _, d := range details {
|
||||||
|
m, ok := d.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, _ := m["value"].(string); v != "" {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(values, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||||
// Returns nil when the structure is absent.
|
// Returns nil when the structure is absent.
|
||||||
func extractMissingScopes(resp map[string]any) []string {
|
func extractMissingScopes(resp map[string]any) []string {
|
||||||
|
|||||||
@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
|
||||||
|
// resp.error.details[].value into Problem.Hint when the response routes to the
|
||||||
|
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
|
||||||
|
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
|
||||||
|
// — only a human-readable reason string, no machine-readable field name. It is
|
||||||
|
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
|
||||||
|
// structured params.
|
||||||
|
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"code": 190014,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]any{
|
||||||
|
"details": []any{
|
||||||
|
map[string]any{"value": "end_time should be later than start_time"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
|
||||||
|
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
|
||||||
|
// detail values are joined with "; " into a single Hint, and empty values are
|
||||||
|
// skipped.
|
||||||
|
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"code": 190014,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]any{
|
||||||
|
"details": []any{
|
||||||
|
map[string]any{"value": "first reason"},
|
||||||
|
map[string]any{"value": ""},
|
||||||
|
map[string]any{"value": "second reason"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
if p.Hint != "first reason; second reason" {
|
||||||
|
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
|
||||||
|
// the details array (not a JSON object) are skipped rather than panicking, and
|
||||||
|
// well-formed siblings still surface in the Hint.
|
||||||
|
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"code": 190014,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]any{
|
||||||
|
"details": []any{
|
||||||
|
"i am a bare string, not an object",
|
||||||
|
map[string]any{"value": "the real reason"},
|
||||||
|
42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
if p.Hint != "the real reason" {
|
||||||
|
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
|
||||||
|
// block, a non-array details field, and an empty details array all leave the
|
||||||
|
// Hint untouched (no lifted detail) instead of erroring.
|
||||||
|
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
resp map[string]any
|
||||||
|
}{
|
||||||
|
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
|
||||||
|
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
|
||||||
|
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
|
||||||
|
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
// With no liftable detail, the Hint must not echo a server detail.
|
||||||
|
if strings.Contains(p.Hint, "nope") {
|
||||||
|
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
||||||
// when the upstream response omits it — wire envelope must omit the field.
|
// when the upstream response omits it — wire envelope must omit the field.
|
||||||
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
||||||
|
|||||||
16
internal/errclass/codemeta_calendar.go
Normal file
16
internal/errclass/codemeta_calendar.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
|
||||||
|
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||||
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
|
var calendarCodeMeta = map[int]CodeMeta{
|
||||||
|
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }
|
||||||
39
internal/errclass/codemeta_calendar_test.go
Normal file
39
internal/errclass/codemeta_calendar_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
|
||||||
|
// via the codemeta_calendar.go init() merge to its expected
|
||||||
|
// Category/Subtype/Retryable.
|
||||||
|
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
wantCat errs.Category
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
wantRetry bool
|
||||||
|
}{
|
||||||
|
// 190014: calendar "invalid params" with a field-level detail
|
||||||
|
// (error.details[].value) lifted into Hint by BuildAPIError.
|
||||||
|
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
meta, ok := LookupCodeMeta(tc.code)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||||
|
}
|
||||||
|
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||||
|
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||||
|
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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) {
|
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||||
got, ok := LookupCodeMeta(20050)
|
got, ok := LookupCodeMeta(20050)
|
||||||
if !ok {
|
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") }
|
||||||
@@ -231,14 +231,9 @@ func TestLoadAutoApproveSet(t *testing.T) {
|
|||||||
t.Fatal("expected non-empty auto-approve set")
|
t.Fatal("expected non-empty auto-approve set")
|
||||||
}
|
}
|
||||||
|
|
||||||
// From scope_overrides.json allow list
|
// From scope_priorities.json recommend=="true"
|
||||||
if !aaSet["calendar:calendar.event:create"] {
|
|
||||||
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify allow list entries are present
|
|
||||||
if !aaSet["sheets:spreadsheet:read"] {
|
if !aaSet["sheets:spreadsheet:read"] {
|
||||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
|
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
||||||
@@ -257,16 +252,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||||
allowSet := LoadOverrideAutoApproveAllow()
|
allowSet := LoadOverrideAutoApproveAllow()
|
||||||
if len(allowSet) == 0 {
|
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||||
t.Fatal("expected non-empty override allow set")
|
// no scopes are special-cased into the auto-approve set anymore.
|
||||||
}
|
if len(allowSet) != 0 {
|
||||||
|
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||||
// Known entries from scope_overrides.json
|
|
||||||
if !allowSet["calendar:calendar.event:create"] {
|
|
||||||
t.Error("expected calendar:calendar.event:create in allow set")
|
|
||||||
}
|
|
||||||
if !allowSet["mail:event"] {
|
|
||||||
t.Error("expected mail:event in allow set")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,9 +266,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIsAutoApproveScope(t *testing.T) {
|
func TestIsAutoApproveScope(t *testing.T) {
|
||||||
// Known auto-approve scope (in allow list)
|
// Known auto-approve scope (recommend=true in scope_priorities.json)
|
||||||
if !IsAutoApproveScope("calendar:calendar.event:create") {
|
if !IsAutoApproveScope("sheets:spreadsheet:read") {
|
||||||
t.Error("expected calendar:calendar.event:create to be auto-approve")
|
t.Error("expected sheets:spreadsheet:read to be auto-approve")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completely unknown scope
|
// Completely unknown scope
|
||||||
@@ -290,9 +279,8 @@ func TestIsAutoApproveScope(t *testing.T) {
|
|||||||
|
|
||||||
func TestFilterAutoApproveScopes(t *testing.T) {
|
func TestFilterAutoApproveScopes(t *testing.T) {
|
||||||
scopes := []string{
|
scopes := []string{
|
||||||
"calendar:calendar.event:create", // auto-approve (in allow list)
|
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
|
||||||
"zzz:unknown:scope", // not in auto-approve
|
"zzz:unknown:scope", // not in auto-approve
|
||||||
"sheets:spreadsheet:read", // auto-approve (in allow list)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := FilterAutoApproveScopes(scopes)
|
result := FilterAutoApproveScopes(scopes)
|
||||||
@@ -300,10 +288,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
|||||||
t.Fatal("expected at least 1 auto-approve scope in result")
|
t.Fatal("expected at least 1 auto-approve scope in result")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that calendar:calendar.event:create is included
|
// Check that sheets:spreadsheet:read is included
|
||||||
found := false
|
found := false
|
||||||
for _, s := range result {
|
for _, s := range result {
|
||||||
if s == "calendar:calendar.event:create" {
|
if s == "sheets:spreadsheet:read" {
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
// Ensure unknown scopes are not included
|
// Ensure unknown scopes are not included
|
||||||
@@ -312,7 +300,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("expected calendar:calendar.event:create in result")
|
t.Error("expected sheets:spreadsheet:read in result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,25 +12,7 @@
|
|||||||
"vc:meeting.meetingevent:read": 75
|
"vc:meeting.meetingevent:read": 75
|
||||||
},
|
},
|
||||||
"recommend": {
|
"recommend": {
|
||||||
"allow": [
|
"allow": [],
|
||||||
"calendar:calendar.event:create",
|
|
||||||
"calendar:calendar.event:delete",
|
|
||||||
"calendar:calendar.event:read",
|
|
||||||
"calendar:calendar.event:update",
|
|
||||||
"calendar:calendar.free_busy:read",
|
|
||||||
"calendar:calendar:create",
|
|
||||||
"calendar:calendar:delete",
|
|
||||||
"calendar:calendar:read",
|
|
||||||
"calendar:calendar:update",
|
|
||||||
"contact:user.basic_profile:readonly",
|
|
||||||
"mail:event",
|
|
||||||
"mail:user_mailbox.mail_contact:read",
|
|
||||||
"mail:user_mailbox.mail_contact:write",
|
|
||||||
"mail:user_mailbox.message.address:read",
|
|
||||||
"mail:user_mailbox.message.body:read",
|
|
||||||
"mail:user_mailbox.message.subject:read",
|
|
||||||
"mail:user_mailbox.message:readonly"
|
|
||||||
],
|
|
||||||
"deny": [
|
"deny": [
|
||||||
"im:chat",
|
"im:chat",
|
||||||
"im:message.send_as_user"
|
"im:message.send_as_user"
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/transport"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +40,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
npmInstallTimeout = 10 * time.Minute
|
npmInstallTimeout = 10 * time.Minute
|
||||||
skillsUpdateTimeout = 2 * time.Minute
|
skillsUpdateTimeout = 2 * time.Minute
|
||||||
verifyTimeout = 10 * time.Second
|
skillsIndexMaxBodySize = 1 << 20
|
||||||
|
verifyTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
skillsIndexFetchTimeout = 10 * time.Second
|
||||||
|
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
RestoreAvailableOverride func() bool
|
RestoreAvailableOverride func() bool
|
||||||
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
|
return u.SkillsIndexFetchOverride()
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &NpmResult{}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
r.Err = err
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
client := transport.NewHTTPClient(0)
|
||||||
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
r.Err = err
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
|
||||||
|
if _, err := io.Copy(&r.Stdout, limited); err != nil {
|
||||||
|
r.Err = err
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if r.Stdout.Len() > skillsIndexMaxBodySize {
|
||||||
|
r.Stdout.Reset()
|
||||||
|
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||||
if r.Err != nil {
|
if r.Err != nil {
|
||||||
|
|||||||
@@ -4,12 +4,18 @@
|
|||||||
package selfupdate
|
package selfupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
@@ -232,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oldURL := officialSkillsIndexURL
|
||||||
|
officialSkillsIndexURL = server.URL
|
||||||
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||||
|
|
||||||
|
result := New().ListOfficialSkillsIndex()
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oldURL := officialSkillsIndexURL
|
||||||
|
officialSkillsIndexURL = server.URL
|
||||||
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||||
|
|
||||||
|
result := New().ListOfficialSkillsIndex()
|
||||||
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oldURL := officialSkillsIndexURL
|
||||||
|
officialSkillsIndexURL = server.URL
|
||||||
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||||
|
|
||||||
|
result := New().ListOfficialSkillsIndex()
|
||||||
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
|
||||||
|
}
|
||||||
|
if result.Stdout.Len() != 0 {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oldURL := officialSkillsIndexURL
|
||||||
|
oldTimeout := skillsIndexFetchTimeout
|
||||||
|
officialSkillsIndexURL = server.URL
|
||||||
|
skillsIndexFetchTimeout = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
officialSkillsIndexURL = oldURL
|
||||||
|
skillsIndexFetchTimeout = oldTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
result := New().ListOfficialSkillsIndex()
|
||||||
|
var netErr net.Error
|
||||||
|
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oldURL := officialSkillsIndexURL
|
||||||
|
officialSkillsIndexURL = server.URL
|
||||||
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||||
|
|
||||||
|
result := New().ListOfficialSkillsIndex()
|
||||||
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
|
||||||
|
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
|
||||||
|
return r
|
||||||
|
}}).ListOfficialSkillsIndex()
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Stdout.String(), "override-skill") {
|
||||||
|
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||||
called := []string{}
|
called := []string{}
|
||||||
updater := &Updater{
|
updater := &Updater{
|
||||||
|
|||||||
209
internal/skillcontent/reader.go
Normal file
209
internal/skillcontent/reader.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package skillcontent reads embedded skill content from an injected fs.FS
|
||||||
|
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
|
||||||
|
package skillcontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Reader struct {
|
||||||
|
fsys fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
|
||||||
|
|
||||||
|
type SkillInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
|
||||||
|
// fed straight back into `read`.
|
||||||
|
type DirEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) List() ([]SkillInfo, error) {
|
||||||
|
entries, err := fs.ReadDir(r.fsys, ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
|
||||||
|
}
|
||||||
|
out := make([]SkillInfo, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip dirs that aren't real skills (no SKILL.md).
|
||||||
|
if info, ok := r.skillInfo(e.Name()); ok {
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
|
||||||
|
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||||
|
if err != nil {
|
||||||
|
return SkillInfo{}, false
|
||||||
|
}
|
||||||
|
desc, version, metadata := parseFrontmatter(data)
|
||||||
|
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPath lists one directory layer (no recursion) under "<name>" or
|
||||||
|
// "<name>/<sub>", returning the entries and the cleaned path listed.
|
||||||
|
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
|
||||||
|
name, sub := SplitArg(arg)
|
||||||
|
if err := r.ensureSkill(name); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
dir := name
|
||||||
|
if sub != "" {
|
||||||
|
cleaned, err := cleanSubPath(sub)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
dir = name + "/" + cleaned
|
||||||
|
info, err := fs.Stat(r.fsys, dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"path %q not found in skill %q", sub, name).
|
||||||
|
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, err := fs.ReadDir(r.fsys, dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"failed to read embedded skill content: %v", err)
|
||||||
|
}
|
||||||
|
out := make([]DirEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||||
|
return out, dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
|
||||||
|
// separator is a bare skill name (rest "").
|
||||||
|
func SplitArg(arg string) (name, rest string) {
|
||||||
|
name, rest, _ = strings.Cut(arg, "/")
|
||||||
|
return name, rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
|
||||||
|
// unparseable frontmatter yields ("", "", nil), never an error.
|
||||||
|
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
|
||||||
|
lines := strings.Split(string(skillMD), "\n")
|
||||||
|
if strings.TrimRight(lines[0], "\r") != "---" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
block := make([]string, 0, len(lines))
|
||||||
|
closed := false
|
||||||
|
for _, ln := range lines[1:] {
|
||||||
|
if strings.TrimRight(ln, "\r") == "---" {
|
||||||
|
closed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
block = append(block, ln)
|
||||||
|
}
|
||||||
|
if !closed {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
var fm struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Metadata map[string]any `yaml:"metadata"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
return fm.Description, fm.Version, fm.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) ReadSkill(name string) ([]byte, error) {
|
||||||
|
if err := r.ensureSkill(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"failed to read embedded skill content: %v", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) ensureSkill(name string) error {
|
||||||
|
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||||
|
return unknownSkill(name)
|
||||||
|
}
|
||||||
|
info, err := fs.Stat(r.fsys, name)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return unknownSkill(name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unknownSkill(name string) error {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
|
||||||
|
WithHint("run 'lark-cli skills list' to see available skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
|
||||||
|
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
|
||||||
|
func cleanSubPath(relpath string) (string, error) {
|
||||||
|
cleaned := path.Clean(relpath)
|
||||||
|
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
|
||||||
|
// survives; reject it explicitly alongside "../".
|
||||||
|
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
|
||||||
|
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
|
||||||
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"invalid path %q: must be a relative path without '..'", relpath)
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
|
||||||
|
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
|
||||||
|
if err := r.ensureSkill(name); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
cleaned, err := cleanSubPath(relpath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
full := name + "/" + cleaned
|
||||||
|
info, err := fs.Stat(r.fsys, full)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"reference %q not found in skill %q", relpath, name).
|
||||||
|
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"reference %q is a directory, not a file", relpath)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(r.fsys, full)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"failed to read embedded skill content: %v", err)
|
||||||
|
}
|
||||||
|
return data, cleaned, nil
|
||||||
|
}
|
||||||
290
internal/skillcontent/reader_test.go
Normal file
290
internal/skillcontent/reader_test.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package skillcontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
|
||||||
|
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||||
|
"lark-calendar/references/create.md": {Data: []byte("# Create")},
|
||||||
|
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
|
||||||
|
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
|
||||||
|
"lark-im/references/send.md": {Data: []byte("# Send")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
r := New(testFS())
|
||||||
|
skills, err := r.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(skills) != 2 {
|
||||||
|
t.Fatalf("got %d skills, want 2", len(skills))
|
||||||
|
}
|
||||||
|
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
|
||||||
|
t.Fatalf("skills not sorted by name: %v", skills)
|
||||||
|
}
|
||||||
|
if skills[0].Description != "Calendar skill" {
|
||||||
|
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
|
||||||
|
}
|
||||||
|
// version is the frontmatter `version:` field, passed through for drift checks.
|
||||||
|
if skills[0].Version != "1.0.0" {
|
||||||
|
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
|
||||||
|
}
|
||||||
|
// metadata is the frontmatter `metadata:` block, passed through verbatim.
|
||||||
|
if skills[0].Metadata == nil {
|
||||||
|
t.Fatal("expected metadata for lark-calendar")
|
||||||
|
}
|
||||||
|
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
|
||||||
|
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
|
||||||
|
}
|
||||||
|
// No frontmatter → empty description and nil metadata (omitted from JSON).
|
||||||
|
if skills[1].Description != "" {
|
||||||
|
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
|
||||||
|
}
|
||||||
|
if skills[1].Metadata != nil {
|
||||||
|
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
|
||||||
|
}
|
||||||
|
if skills[1].Version != "" {
|
||||||
|
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPath(t *testing.T) {
|
||||||
|
r := New(testFS())
|
||||||
|
|
||||||
|
// Skill root: direct children only (one layer), each path skill-prefixed.
|
||||||
|
entries, listed, err := r.ListPath("lark-calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPath root error: %v", err)
|
||||||
|
}
|
||||||
|
if listed != "lark-calendar" {
|
||||||
|
t.Errorf("listed path: got %q", listed)
|
||||||
|
}
|
||||||
|
want := map[string]bool{ // path → isDir
|
||||||
|
"lark-calendar/SKILL.md": false,
|
||||||
|
"lark-calendar/references": true,
|
||||||
|
"lark-calendar/assets": true,
|
||||||
|
}
|
||||||
|
if len(entries) != len(want) {
|
||||||
|
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
isDir, ok := want[e.Path]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected entry %q", e.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.IsDir != isDir {
|
||||||
|
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Entries are sorted by path.
|
||||||
|
if entries[0].Path != "lark-calendar/SKILL.md" {
|
||||||
|
t.Errorf("entries not sorted: %v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdirectory: one layer under <name>/<subpath>.
|
||||||
|
subEntries, subListed, err := r.ListPath("lark-calendar/references")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPath subdir error: %v", err)
|
||||||
|
}
|
||||||
|
if subListed != "lark-calendar/references" {
|
||||||
|
t.Errorf("listed subpath: got %q", subListed)
|
||||||
|
}
|
||||||
|
if len(subEntries) != 2 ||
|
||||||
|
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
|
||||||
|
subEntries[1].Path != "lark-calendar/references/create.md" {
|
||||||
|
t.Errorf("subdir entries: got %v", subEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown skill → typed validation error.
|
||||||
|
if _, _, err := r.ListPath("no-such-skill"); err == nil {
|
||||||
|
t.Error("expected error for unknown skill")
|
||||||
|
} else {
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Errorf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path that points at a file (not a dir) → validation error.
|
||||||
|
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
|
||||||
|
t.Error("expected error listing a file")
|
||||||
|
} else if !strings.Contains(err.Error(), "is a file") {
|
||||||
|
t.Errorf("message: got %q", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nonexistent subpath → validation error.
|
||||||
|
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
|
||||||
|
t.Error("expected not-found error")
|
||||||
|
} else if !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Errorf("message: got %q", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traversal in the subpath is rejected, no listing leaked.
|
||||||
|
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
|
||||||
|
entries, _, err := r.ListPath(bad)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected rejection for %q", bad)
|
||||||
|
}
|
||||||
|
if entries != nil {
|
||||||
|
t.Errorf("entries leaked for %q: %v", bad, entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSkill(t *testing.T) {
|
||||||
|
r := New(testFS())
|
||||||
|
|
||||||
|
data, err := r.ReadSkill("lark-calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadSkill error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
|
||||||
|
t.Errorf("unexpected content: %q", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.ReadSkill("no-such-skill")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown skill")
|
||||||
|
}
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
|
||||||
|
t.Errorf("message: got %q", verr.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.ReadSkill("../etc"); err == nil {
|
||||||
|
t.Error("expected error for name with separator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadReference(t *testing.T) {
|
||||||
|
r := New(testFS())
|
||||||
|
|
||||||
|
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadReference error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "# Agenda" {
|
||||||
|
t.Errorf("content: got %q", string(data))
|
||||||
|
}
|
||||||
|
if cleaned != "references/agenda.md" {
|
||||||
|
t.Errorf("cleaned path: got %q", cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
|
||||||
|
t.Error("expected not-found error")
|
||||||
|
} else if !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Errorf("message: got %q", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
|
||||||
|
t.Error("expected directory error")
|
||||||
|
} else if !strings.Contains(err.Error(), "is a directory") {
|
||||||
|
t.Errorf("message: got %q", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
|
||||||
|
data, _, err := r.ReadReference("lark-calendar", bad)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected rejection for %q", bad)
|
||||||
|
}
|
||||||
|
if data != nil {
|
||||||
|
t.Errorf("content leaked for %q: %q", bad, string(data))
|
||||||
|
}
|
||||||
|
var verr *errs.ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Errorf("expected validation error for %q, got %T", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFrontmatter(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantDesc string
|
||||||
|
wantVer string
|
||||||
|
wantHasMeta bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "description, version and metadata",
|
||||||
|
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
|
||||||
|
wantDesc: "My skill",
|
||||||
|
wantVer: "2.1.0",
|
||||||
|
wantHasMeta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description only, no metadata",
|
||||||
|
input: "---\ndescription: Plain\n---\nbody\n",
|
||||||
|
wantDesc: "Plain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no frontmatter",
|
||||||
|
input: "no frontmatter here\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclosed frontmatter",
|
||||||
|
input: "---\ndescription: Never closed\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed YAML inside frontmatter",
|
||||||
|
input: "---\n: bad: yaml: [\n---\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CRLF line endings",
|
||||||
|
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
|
||||||
|
wantDesc: "CRLF skill",
|
||||||
|
wantHasMeta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
desc, ver, meta := parseFrontmatter([]byte(tc.input))
|
||||||
|
if desc != tc.wantDesc {
|
||||||
|
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
|
||||||
|
}
|
||||||
|
if ver != tc.wantVer {
|
||||||
|
t.Errorf("version = %q, want %q", ver, tc.wantVer)
|
||||||
|
}
|
||||||
|
if (meta != nil) != tc.wantHasMeta {
|
||||||
|
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSkillMissingFile(t *testing.T) {
|
||||||
|
// Use a separate MapFS so testFS() (and TestList) are unaffected.
|
||||||
|
emptyFS := fstest.MapFS{
|
||||||
|
"lark-empty/references/x.md": {Data: []byte("# X")},
|
||||||
|
}
|
||||||
|
r := New(emptyFS)
|
||||||
|
_, err := r.ReadSkill("lark-empty")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when SKILL.md is absent")
|
||||||
|
}
|
||||||
|
var ierr *errs.InternalError
|
||||||
|
if !errors.As(err, &ierr) {
|
||||||
|
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,30 @@ func ParseGlobalSkillsJSON(text string) []string {
|
|||||||
return sortedKeys(seen)
|
return sortedKeys(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
|
||||||
|
type officialSkill struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type officialIndex struct {
|
||||||
|
Skills []officialSkill `json:"skills"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var index officialIndex
|
||||||
|
if err := json.Unmarshal([]byte(text), &index); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, skill := range index.Skills {
|
||||||
|
candidate := strings.TrimSpace(skill.Name)
|
||||||
|
if skillNamePattern.MatchString(candidate) {
|
||||||
|
seen[candidate] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedKeys(seen), nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||||
func parseGlobalSkillsList(lines []string) []string {
|
func parseGlobalSkillsList(lines []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -160,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
|
|||||||
|
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
candidate := parts[0]
|
candidate := parts[0]
|
||||||
// Check if it's a valid official skill name
|
if skillNamePattern.MatchString(candidate) {
|
||||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
|
||||||
seen[candidate] = true
|
seen[candidate] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +246,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SkillsRunner interface {
|
type SkillsRunner interface {
|
||||||
|
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||||
ListOfficialSkills() *selfupdate.NpmResult
|
ListOfficialSkills() *selfupdate.NpmResult
|
||||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||||
ListGlobalSkills() *selfupdate.NpmResult
|
ListGlobalSkills() *selfupdate.NpmResult
|
||||||
@@ -258,14 +282,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 1: List official skills ---
|
// --- Step 1: List official skills ---
|
||||||
officialResult := opts.Runner.ListOfficialSkills()
|
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||||
if officialResult == nil || officialResult.Err != nil {
|
if !ok {
|
||||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
return fallbackFullInstall(opts, reason, nil)
|
||||||
}
|
|
||||||
official := ParseSkillsList(officialResult.Stdout.String())
|
|
||||||
|
|
||||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
|
||||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 2: List local (installed) skills ---
|
// --- Step 2: List local (installed) skills ---
|
||||||
@@ -327,6 +346,40 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||||
|
reasons := []string{}
|
||||||
|
|
||||||
|
indexResult := runner.ListOfficialSkillsIndex()
|
||||||
|
if indexResult == nil || indexResult.Err != nil {
|
||||||
|
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
|
||||||
|
} else {
|
||||||
|
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
|
||||||
|
if err != nil {
|
||||||
|
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
|
||||||
|
} else if len(official) > 0 {
|
||||||
|
return official, "", true
|
||||||
|
} else {
|
||||||
|
reasons = append(reasons, "official skills index contains no skills")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
officialResult := runner.ListOfficialSkills()
|
||||||
|
if officialResult == nil || officialResult.Err != nil {
|
||||||
|
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
|
||||||
|
return nil, strings.Join(reasons, "; "), false
|
||||||
|
}
|
||||||
|
official := ParseSkillsList(officialResult.Stdout.String())
|
||||||
|
if len(official) > 0 {
|
||||||
|
return official, "", true
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||||
|
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
|
||||||
|
} else {
|
||||||
|
reasons = append(reasons, "official skills list returned no skills")
|
||||||
|
}
|
||||||
|
return nil, strings.Join(reasons, "; "), false
|
||||||
|
}
|
||||||
|
|
||||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||||
jsonResult := runner.ListGlobalSkillsJSON()
|
jsonResult := runner.ListGlobalSkillsJSON()
|
||||||
if jsonResult != nil && jsonResult.Err == nil {
|
if jsonResult != nil && jsonResult.Err == nil {
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||||
|
input := `Available Skills
|
||||||
|
│ lark-calendar
|
||||||
|
│ official-shared
|
||||||
|
│ bad/name
|
||||||
|
`
|
||||||
|
got := ParseSkillsList(input)
|
||||||
|
want := []string{"lark-calendar", "official-shared"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseGlobalSkillsList(t *testing.T) {
|
func TestParseGlobalSkillsList(t *testing.T) {
|
||||||
input := `Global Skills
|
input := `Global Skills
|
||||||
|
|
||||||
@@ -110,6 +123,43 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"skills": [
|
||||||
|
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
|
||||||
|
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
|
||||||
|
{"name":" lark-base ","description":"Base","files":[]},
|
||||||
|
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
|
||||||
|
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
|
||||||
|
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
|
||||||
|
{"name":"","description":"empty","files":["SKILL.md"]}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
|
||||||
|
for _, input := range []string{
|
||||||
|
`not json`,
|
||||||
|
`[{"name":"lark-calendar"}]`,
|
||||||
|
`{"name":"lark-calendar"}`,
|
||||||
|
`{"skills":[]}`,
|
||||||
|
`{"skills":[{"name":"bad skill"}]}`,
|
||||||
|
} {
|
||||||
|
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||||
|
if err == nil && len(got) != 0 {
|
||||||
|
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||||
got := PlanSync(SyncInput{
|
got := PlanSync(SyncInput{
|
||||||
@@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeSkillsRunner struct {
|
type fakeSkillsRunner struct {
|
||||||
|
officialIndexOut string
|
||||||
officialOut string
|
officialOut string
|
||||||
globalJSONOut string
|
globalJSONOut string
|
||||||
globalOut string
|
globalOut string
|
||||||
|
officialIndexErr error
|
||||||
officialErr error
|
officialErr error
|
||||||
globalJSONErr error
|
globalJSONErr error
|
||||||
globalErr error
|
globalErr error
|
||||||
@@ -166,6 +218,8 @@ type fakeSkillsRunner struct {
|
|||||||
installAllErr error
|
installAllErr error
|
||||||
installed [][]string
|
installed [][]string
|
||||||
installedAll int
|
installedAll int
|
||||||
|
listedIndex int
|
||||||
|
listedOfficial int
|
||||||
listedGlobalJSON int
|
listedGlobalJSON int
|
||||||
listedGlobalText int
|
listedGlobalText int
|
||||||
}
|
}
|
||||||
@@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func officialSkillsIndexOutput(names ...string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`{"skills":[`)
|
||||||
|
for i, name := range names {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(",")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
|
||||||
|
}
|
||||||
|
b.WriteString(`]}`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func globalSkillsOutput(names ...string) string {
|
func globalSkillsOutput(names ...string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("Global Skills\n\n")
|
b.WriteString("Global Skills\n\n")
|
||||||
@@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
|
||||||
|
f.listedIndex++
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Stdout.WriteString(f.officialIndexOut)
|
||||||
|
r.Err = f.officialIndexErr
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||||
|
f.listedOfficial++
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stdout.WriteString(f.officialOut)
|
r.Stdout.WriteString(f.officialOut)
|
||||||
r.Err = f.officialErr
|
r.Err = f.officialErr
|
||||||
@@ -255,9 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||||
globalOut: globalSkillsOutput("lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||||
|
globalOut: globalSkillsOutput("lark-mail"),
|
||||||
}
|
}
|
||||||
result := SyncSkills(SyncOptions{
|
result := SyncSkills(SyncOptions{
|
||||||
Version: "1.0.33",
|
Version: "1.0.33",
|
||||||
@@ -289,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||||
|
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||||
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||||
|
globalOut: globalSkillsOutput("lark-mail"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||||
|
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||||
|
if runner.listedIndex != 1 {
|
||||||
|
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||||
|
}
|
||||||
|
if runner.listedOfficial != 0 {
|
||||||
|
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||||
|
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||||
|
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||||
|
}
|
||||||
|
if runner.installedAll != 0 {
|
||||||
|
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexOut: `{"skills":[]}`,
|
||||||
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||||
|
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||||
|
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Action != "fallback_synced" {
|
||||||
|
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||||
|
}
|
||||||
|
if runner.installedAll != 1 {
|
||||||
|
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||||
|
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexOut: `{"skills":[]}`,
|
||||||
|
installAllErr: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Action != "fallback_synced" {
|
||||||
|
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||||
|
}
|
||||||
|
if runner.installedAll != 1 {
|
||||||
|
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||||
|
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialErr: fmt.Errorf("list failed"),
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -322,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialErr: fmt.Errorf("list failed"),
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: fmt.Errorf("full install failed"),
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: fmt.Errorf("full install failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -342,9 +527,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONErr: fmt.Errorf("json list failed"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar"),
|
globalJSONErr: fmt.Errorf("json list failed"),
|
||||||
|
globalOut: globalSkillsOutput("lark-calendar"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -367,9 +553,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||||
|
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -391,9 +578,10 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: `[]`,
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: "Some unrecognized output format\n",
|
globalJSONOut: `[]`,
|
||||||
|
globalOut: "Some unrecognized output format\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -420,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput(),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
globalOut: globalSkillsOutput(),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -445,11 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -477,11 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: fmt.Errorf("full install boom"),
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: fmt.Errorf("full install boom"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -510,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: "Some unrecognized output format\n",
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialOut: "Some unrecognized output format\n",
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -527,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: "Some unrecognized output format\n",
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: fmt.Errorf("full install failed"),
|
officialOut: "Some unrecognized output format\n",
|
||||||
|
installAllErr: fmt.Errorf("full install failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -551,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: "Some unrecognized output format\n",
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialOut: "Some unrecognized output format\n",
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -576,11 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -601,11 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -625,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialErr: fmt.Errorf("list failed"),
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -643,9 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner2 := &fakeSkillsRunner{
|
runner2 := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
}
|
}
|
||||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||||
if result2.Action != "synced" {
|
if result2.Action != "synced" {
|
||||||
|
|||||||
@@ -15,8 +15,15 @@ import (
|
|||||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||||
// common replacements or construct an errs.* typed error directly.
|
// common replacements or construct an errs.* typed error directly.
|
||||||
var migratedCommonHelperPaths = []string{
|
var migratedCommonHelperPaths = []string{
|
||||||
|
"shortcuts/base/",
|
||||||
|
"shortcuts/calendar/",
|
||||||
"shortcuts/drive/",
|
"shortcuts/drive/",
|
||||||
"shortcuts/mail/",
|
"shortcuts/mail/",
|
||||||
|
"shortcuts/minutes/",
|
||||||
|
"shortcuts/okr/",
|
||||||
|
"shortcuts/task/",
|
||||||
|
"shortcuts/vc/",
|
||||||
|
"shortcuts/whiteboard/",
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||||
|
|||||||
@@ -16,8 +16,16 @@ import (
|
|||||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||||
// appending their path prefix here.
|
// appending their path prefix here.
|
||||||
var migratedEnvelopePaths = []string{
|
var migratedEnvelopePaths = []string{
|
||||||
|
"shortcuts/base/",
|
||||||
|
"shortcuts/calendar/",
|
||||||
"shortcuts/drive/",
|
"shortcuts/drive/",
|
||||||
"shortcuts/mail/",
|
"shortcuts/mail/",
|
||||||
|
"shortcuts/minutes/",
|
||||||
|
"shortcuts/okr/",
|
||||||
|
"shortcuts/task/",
|
||||||
|
"shortcuts/vc/",
|
||||||
|
"shortcuts/whiteboard/",
|
||||||
|
"shortcuts/im/",
|
||||||
}
|
}
|
||||||
|
|
||||||
// legacyOutputImportPath is the import path of the package that declares the
|
// legacyOutputImportPath is the import path of the package that declares the
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||||
//
|
//
|
||||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
// Migrated code must call the domain's typed API wrapper or use
|
||||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||||
// typed errs.* errors.
|
// typed errs.* errors.
|
||||||
//
|
//
|
||||||
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
|||||||
File: path,
|
File: path,
|
||||||
Line: fset.Position(call.Pos()).Line,
|
Line: fset.Position(call.Pos()).Line,
|
||||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||||
"so failures classify into typed errs.* errors",
|
"so failures classify into typed errs.* errors",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -618,6 +618,35 @@ func boom() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
"shortcuts/okr/okr_image_upload.go",
|
||||||
|
"shortcuts/task/task_update.go",
|
||||||
|
"shortcuts/whiteboard/whiteboard_update.go",
|
||||||
|
} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
src := `package migrated
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/internal/output"
|
||||||
|
|
||||||
|
func boom() error {
|
||||||
|
return &output.ExitError{Code: 1}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
||||||
|
if len(v) != 1 {
|
||||||
|
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||||
|
}
|
||||||
|
if v[0].Action != ActionReject {
|
||||||
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||||
|
}
|
||||||
|
if !strings.Contains(v[0].Message, "ExitError") {
|
||||||
|
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||||
src := `package drive
|
src := `package drive
|
||||||
|
|
||||||
@@ -662,7 +691,7 @@ func boom() error {
|
|||||||
return &output.ExitError{Code: 1}
|
return &output.ExitError{Code: 1}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||||
if len(v) != 0 {
|
if len(v) != 0 {
|
||||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||||
}
|
}
|
||||||
@@ -801,6 +830,26 @@ func boom(runtime *common.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||||
|
src := `package task
|
||||||
|
|
||||||
|
func boom(runtime *common.RuntimeContext) error {
|
||||||
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
`
|
||||||
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
|
||||||
|
if len(v) != 1 {
|
||||||
|
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||||
|
}
|
||||||
|
if v[0].Action != ActionReject {
|
||||||
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||||
|
}
|
||||||
|
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||||
|
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||||
src := `package drive
|
src := `package drive
|
||||||
|
|
||||||
@@ -851,14 +900,14 @@ func boom(runtime *common.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||||
src := `package im
|
src := `package contact
|
||||||
|
|
||||||
func boom(runtime *common.RuntimeContext) error {
|
func boom(runtime *common.RuntimeContext) error {
|
||||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||||
if len(v) != 0 {
|
if len(v) != 0 {
|
||||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||||
}
|
}
|
||||||
@@ -897,6 +946,9 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
|||||||
paths := []string{
|
paths := []string{
|
||||||
"shortcuts/drive/drive_search.go",
|
"shortcuts/drive/drive_search.go",
|
||||||
"shortcuts/mail/mail_send.go",
|
"shortcuts/mail/mail_send.go",
|
||||||
|
"shortcuts/okr/okr_progress_create.go",
|
||||||
|
"shortcuts/task/task_update.go",
|
||||||
|
"shortcuts/whiteboard/whiteboard_query.go",
|
||||||
}
|
}
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
for _, helper := range helpers {
|
for _, helper := range helpers {
|
||||||
@@ -924,8 +976,29 @@ common.` + helper + `()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||||
|
src := `package calendar
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/shortcuts/common"
|
||||||
|
|
||||||
|
func boom() {
|
||||||
|
common.RejectDangerousChars("--summary", "x")
|
||||||
|
}
|
||||||
|
`
|
||||||
|
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
|
||||||
|
if len(v) != 1 {
|
||||||
|
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||||
|
}
|
||||||
|
if v[0].Action != ActionReject {
|
||||||
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||||
|
}
|
||||||
|
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
|
||||||
|
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||||
src := `package im
|
src := `package contact
|
||||||
|
|
||||||
import "github.com/larksuite/cli/shortcuts/common"
|
import "github.com/larksuite/cli/shortcuts/common"
|
||||||
|
|
||||||
@@ -933,7 +1006,7 @@ func boom() {
|
|||||||
common.FlagErrorf("legacy allowed until domain migrates")
|
common.FlagErrorf("legacy allowed until domain migrates")
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||||
if len(v) != 0 {
|
if len(v) != 0 {
|
||||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
|
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
|
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,9 +196,7 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+advperm-enable", "--base-token", "app_x"}
|
args := []string{"+advperm-enable", "--base-token", "app_x"}
|
||||||
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
|
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
|
||||||
@@ -226,7 +224,5 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
|
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
|
||||||
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,24 +55,24 @@ func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *c
|
|||||||
|
|
||||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||||
return common.FlagErrorf("--name must not be blank")
|
return baseFlagErrorf("--name must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||||
return common.FlagErrorf("--type must not be blank")
|
return baseFlagErrorf("--type must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
return baseFlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||||
return common.FlagErrorf("--name must not be blank")
|
return baseFlagErrorf("--name must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
|
|||||||
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
|
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
|
||||||
dec.UseNumber()
|
dec.UseNumber()
|
||||||
if err := dec.Decode(&dsl); err != nil {
|
if err := dec.Decode(&dsl); err != nil {
|
||||||
return common.FlagErrorf("--dsl invalid JSON: %v", err)
|
return baseFlagErrorf("--dsl invalid JSON: %v", err)
|
||||||
}
|
}
|
||||||
_, hasDim := dsl["dimensions"]
|
_, hasDim := dsl["dimensions"]
|
||||||
_, hasMeas := dsl["measures"]
|
_, hasMeas := dsl["measures"]
|
||||||
if !hasDim && !hasMeas {
|
if !hasDim && !hasMeas {
|
||||||
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
package base
|
package base
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,76 +28,198 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
|
|||||||
// structured ErrAPI, with server-provided message/hint promoted to the top level.
|
// structured ErrAPI, with server-provided message/hint promoted to the top level.
|
||||||
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
|
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
return nil, baseAPIBoundaryError(err, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultMap, _ := result.(map[string]interface{})
|
resultMap, ok := result.(map[string]interface{})
|
||||||
code, _ := util.ToFloat64(resultMap["code"])
|
if !ok || resultMap == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
|
||||||
|
}
|
||||||
|
if _, exists := resultMap["code"]; !exists {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
|
||||||
|
}
|
||||||
|
code, numeric := util.ToFloat64(resultMap["code"])
|
||||||
|
if !numeric {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
|
||||||
|
}
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
return resultMap["data"], nil
|
return resultMap["data"], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
larkCode := int(code)
|
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
|
||||||
msg := extractDataErrorMessage(resultMap)
|
|
||||||
if strings.TrimSpace(msg) == "" {
|
|
||||||
msg, _ = resultMap["msg"].(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
detail := extractErrorDetail(resultMap)
|
|
||||||
apiErr := output.ErrAPI(larkCode, msg, detail)
|
|
||||||
hint := extractErrorHint(resultMap)
|
|
||||||
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
|
|
||||||
apiErr.Detail.Hint = hint
|
|
||||||
}
|
|
||||||
if apiErr.Detail != nil {
|
|
||||||
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
|
|
||||||
}
|
|
||||||
return nil, apiErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
|
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
|
||||||
detailMap, ok := detail.(map[string]interface{})
|
// typed envelope and exists so call sites read as flag rejections.
|
||||||
if !ok {
|
func baseFlagErrorf(format string, args ...any) error {
|
||||||
return nil
|
return baseValidationErrorf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseValidationErrorf(format string, args ...any) error {
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||||
|
if params := flagParams(msg); len(params) > 0 {
|
||||||
|
err = err.WithParam(params[0].Name).WithParams(params...)
|
||||||
}
|
}
|
||||||
for key, value := range detailMap {
|
if cause := firstErrorArg(args); cause != nil {
|
||||||
if value == nil {
|
err = err.WithCause(cause)
|
||||||
delete(detailMap, key)
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagParams(msg string) []errs.InvalidParam {
|
||||||
|
reason := msg
|
||||||
|
seen := map[string]bool{}
|
||||||
|
params := []errs.InvalidParam{}
|
||||||
|
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
|
||||||
|
end := start + 2
|
||||||
|
for end < len(msg) {
|
||||||
|
ch := msg[end]
|
||||||
|
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
|
||||||
|
end++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
if end > start+2 {
|
||||||
|
name := msg[start:end]
|
||||||
|
if !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = msg[end:]
|
||||||
}
|
}
|
||||||
if len(detailMap) == 0 {
|
return params
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return detailMap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
|
func firstErrorArg(args []any) error {
|
||||||
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
|
for _, arg := range args {
|
||||||
return detail
|
if err, ok := arg.(error); ok {
|
||||||
}
|
return err
|
||||||
data, _ := resultMap["data"].(map[string]interface{})
|
}
|
||||||
if detail, ok := nonNilMapValue(data, "error"); ok {
|
|
||||||
return detail
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
|
// baseMissingFileIOError reports a broken runtime wiring: a command that needs
|
||||||
if src == nil {
|
// local file access was constructed without a FileIO provider. The user cannot
|
||||||
return nil, false
|
// fix this by changing flags, so it classifies as internal, not validation.
|
||||||
|
func baseMissingFileIOError(format string, args ...any) error {
|
||||||
|
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseInputStatError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
value, ok := src[key]
|
if errors.Is(err, fileio.ErrPathValidation) {
|
||||||
if !ok {
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
switch value.(type) {
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
|
||||||
case nil:
|
}
|
||||||
return nil, false
|
|
||||||
|
func baseSaveError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var me *fileio.MkdirError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fileio.ErrPathValidation):
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
|
||||||
|
case errors.As(err, &me):
|
||||||
|
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
|
||||||
default:
|
default:
|
||||||
return value, true
|
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baseAPIBoundaryError(err error, action string) error {
|
||||||
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseUploadAttachmentError(filePath string, err error) error {
|
||||||
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
|
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
|
||||||
|
if resultMap == nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
|
||||||
|
}
|
||||||
|
if msg := extractDataErrorMessage(resultMap); msg != "" {
|
||||||
|
resultMap["msg"] = msg
|
||||||
|
}
|
||||||
|
hint := extractErrorHint(resultMap)
|
||||||
|
if logID := extractBaseErrorLogID(resultMap); logID != "" {
|
||||||
|
resultMap["log_id"] = logID
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resultMap, cc)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p, ok := errs.ProblemOf(err); ok && hint != "" {
|
||||||
|
p.Hint = hint
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
|
||||||
|
if _, ok := errs.ProblemOf(err); !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, parseErr := decodeBaseV3Response(body)
|
||||||
|
if parseErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
enriched := baseAPIErrorFromResult(result, cc)
|
||||||
|
if enriched == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src, _ := errs.ProblemOf(enriched)
|
||||||
|
dst, _ := errs.ProblemOf(err)
|
||||||
|
if src != nil && dst != nil {
|
||||||
|
dst.Message = src.Message
|
||||||
|
dst.Hint = src.Hint
|
||||||
|
// A body without log_id must not erase a header-derived LogID
|
||||||
|
// already carried by err.
|
||||||
|
if src.LogID != "" {
|
||||||
|
dst.LogID = src.LogID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
|
||||||
|
for _, key := range []string{"log_id", "logid"} {
|
||||||
|
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
|
||||||
|
return strings.TrimSpace(logID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||||
|
for _, key := range []string{"log_id", "logid"} {
|
||||||
|
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
|
||||||
|
return strings.TrimSpace(logID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, _ := resultMap["data"].(map[string]interface{})
|
||||||
|
if detail, ok := data["error"].(map[string]interface{}); ok {
|
||||||
|
for _, key := range []string{"log_id", "logid"} {
|
||||||
|
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
|
||||||
|
return strings.TrimSpace(logID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func extractErrorHint(resultMap map[string]interface{}) string {
|
func extractErrorHint(resultMap map[string]interface{}) string {
|
||||||
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||||
if hint := consumeStringField(detail, "hint"); hint != "" {
|
if hint := consumeStringField(detail, "hint"); hint != "" {
|
||||||
|
|||||||
@@ -4,30 +4,15 @@
|
|||||||
package base
|
package base
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestErrorDetailHelpers(t *testing.T) {
|
func TestErrorDetailHelpers(t *testing.T) {
|
||||||
if value, ok := nonNilMapValue(nil, "error"); ok || value != nil {
|
|
||||||
t.Fatalf("nil map should not return value")
|
|
||||||
}
|
|
||||||
if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil {
|
|
||||||
t.Fatalf("nil entry should not return value")
|
|
||||||
}
|
|
||||||
detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
|
detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
|
||||||
if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil {
|
|
||||||
t.Fatalf("expected non-nil detail")
|
|
||||||
}
|
|
||||||
if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil {
|
|
||||||
t.Fatalf("expected root detail")
|
|
||||||
}
|
|
||||||
if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil {
|
|
||||||
t.Fatalf("expected nested detail")
|
|
||||||
}
|
|
||||||
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
|
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
|
||||||
t.Fatalf("hint=%q", got)
|
t.Fatalf("hint=%q", got)
|
||||||
}
|
}
|
||||||
@@ -53,9 +38,12 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
|||||||
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
} else {
|
} else {
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
|
if !ok || p.Code != 190001 {
|
||||||
t.Fatalf("expected structured code 190001, got %v", err)
|
t.Fatalf("expected typed code 190001, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Hint != "check field name" {
|
||||||
|
t.Fatalf("hint=%q", p.Hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
|
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
|
||||||
@@ -63,7 +51,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"code": 800010407,
|
"code": 800010407,
|
||||||
"msg": "cell value invalid",
|
"msg": "cell value invalid",
|
||||||
@@ -87,55 +75,27 @@ func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !ok {
|
||||||
t.Fatalf("expected structured exit error, got %v", err)
|
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||||
}
|
}
|
||||||
|
if p.Code != 800010407 {
|
||||||
errDetail := exitErr.Detail
|
t.Fatalf("code=%d", p.Code)
|
||||||
if errDetail.Code != 800010407 {
|
|
||||||
t.Fatalf("code=%d", errDetail.Code)
|
|
||||||
}
|
}
|
||||||
if errDetail.Hint != "Provide a number value." {
|
if p.Message != "The cell value does not match the expected input shape." {
|
||||||
t.Fatalf("hint=%q", errDetail.Hint)
|
t.Fatalf("message=%q", p.Message)
|
||||||
}
|
}
|
||||||
detail, _ := errDetail.Detail.(map[string]interface{})
|
if p.Hint != "Provide a number value." {
|
||||||
if detail == nil {
|
t.Fatalf("hint=%q", p.Hint)
|
||||||
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
|
|
||||||
}
|
}
|
||||||
if _, exists := detail["message"]; exists {
|
if p.LogID != "20260508160000000000000000000000" {
|
||||||
t.Fatalf("detail should not repeat message: %#v", detail)
|
t.Fatalf("logID=%q", p.LogID)
|
||||||
}
|
|
||||||
if _, exists := detail["hint"]; exists {
|
|
||||||
t.Fatalf("detail should not repeat hint: %#v", detail)
|
|
||||||
}
|
|
||||||
if _, exists := detail["docs_url"]; exists {
|
|
||||||
t.Fatalf("detail should omit nil docs_url: %#v", detail)
|
|
||||||
}
|
|
||||||
if detail["level"] != "error" {
|
|
||||||
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
|
|
||||||
}
|
|
||||||
if detail["extra_context"] != "future detail field" {
|
|
||||||
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
|
|
||||||
}
|
|
||||||
if detail["path"] != "Amount" || detail["value"] != "abc" {
|
|
||||||
t.Fatalf("cleaned detail mismatch: %#v", detail)
|
|
||||||
}
|
|
||||||
if detail["logid"] != "20260508160000000000000000000000" {
|
|
||||||
t.Fatalf("logid=%q", detail["logid"])
|
|
||||||
}
|
|
||||||
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
|
|
||||||
t.Fatalf("retryable=%v", detail["retryable"])
|
|
||||||
}
|
|
||||||
table, _ := detail["table"].(map[string]interface{})
|
|
||||||
if table["id"] != "tbl_1" || table["name"] != "Orders" {
|
|
||||||
t.Fatalf("table=%#v", detail["table"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
|
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"code": output.LarkErrTokenNoPermission,
|
"code": 99991676,
|
||||||
"msg": "permission denied",
|
"msg": "permission denied",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"error": map[string]interface{}{
|
"error": map[string]interface{}{
|
||||||
@@ -146,15 +106,15 @@ func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !ok {
|
||||||
t.Fatalf("expected structured exit error, got %v", err)
|
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Message != "Permission denied [99991676]" {
|
if p.Code != 99991676 {
|
||||||
t.Fatalf("message=%q", exitErr.Detail.Message)
|
t.Fatalf("code=%d", p.Code)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Detail != nil {
|
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
|
||||||
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
|
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,16 +127,91 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
|
|||||||
attachBaseErrorLogID(result, "20260508170000000000000000000000")
|
attachBaseErrorLogID(result, "20260508170000000000000000000000")
|
||||||
|
|
||||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !ok {
|
||||||
t.Fatalf("expected structured exit error, got %v", err)
|
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||||
}
|
}
|
||||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
if p.LogID != "20260508170000000000000000000000" {
|
||||||
if detail["logid"] != "20260508170000000000000000000000" {
|
t.Fatalf("logID=%q", p.LogID)
|
||||||
t.Fatalf("logid=%q", detail["logid"])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleBaseAPIResultRejectsNonNumericCode(t *testing.T) {
|
||||||
|
for _, code := range []interface{}{"oops", map[string]interface{}{}, nil} {
|
||||||
|
result := map[string]interface{}{"code": code, "msg": "weird envelope"}
|
||||||
|
_, err := handleBaseAPIResultAny(result, nil, "list tables")
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("code=%#v: expected typed error, got %T %v", code, err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Fatalf("code=%#v: category/subtype=%s/%s", code, p.Category, p.Subtype)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "list tables") {
|
||||||
|
t.Fatalf("code=%#v: message=%q", code, p.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichBaseAPIErrorFromBodyLogIDMerge(t *testing.T) {
|
||||||
|
t.Run("body without log_id keeps header-derived LogID", func(t *testing.T) {
|
||||||
|
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
|
||||||
|
err := enrichBaseAPIErrorFromBody(outer, []byte(`{"code":190001,"msg":"boom"}`), errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Message != "boom" {
|
||||||
|
t.Fatalf("message=%q", p.Message)
|
||||||
|
}
|
||||||
|
if p.LogID != "header-log-id" {
|
||||||
|
t.Fatalf("logID=%q, want header-log-id", p.LogID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("body log_id overrides header-derived LogID", func(t *testing.T) {
|
||||||
|
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
|
||||||
|
body := `{"code":190001,"msg":"boom","data":{"error":{"logid":"body-log-id"}}}`
|
||||||
|
err := enrichBaseAPIErrorFromBody(outer, []byte(body), errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if p.LogID != "body-log-id" {
|
||||||
|
t.Fatalf("logID=%q, want body-log-id", p.LogID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseMissingFileIOErrorIsInternal(t *testing.T) {
|
||||||
|
p, ok := errs.ProblemOf(baseMissingFileIOError("file operations require a FileIO provider"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected typed error")
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||||
|
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type assertErr struct{}
|
type assertErr struct{}
|
||||||
|
|
||||||
func (assertErr) Error() string { return "network timeout" }
|
func (assertErr) Error() string { return "network timeout" }
|
||||||
|
|
||||||
|
func assertProblemCode(t *testing.T, err error, code int, messageParts ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error with code %d", code)
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Code != code {
|
||||||
|
t.Fatalf("code=%d, want %d; err=%v", p.Code, code, err)
|
||||||
|
}
|
||||||
|
for _, part := range messageParts {
|
||||||
|
if !strings.Contains(p.Message, part) {
|
||||||
|
t.Fatalf("message=%q missing %q", p.Message, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -513,6 +514,65 @@ func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
|
||||||
|
factory, stdout, _ := newExecuteFactory(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
shortcut common.Shortcut
|
||||||
|
args []string
|
||||||
|
params []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "create blank name",
|
||||||
|
shortcut: BaseBaseBlockCreate,
|
||||||
|
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
|
||||||
|
params: []string{"--name"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "move conflicting sibling anchors",
|
||||||
|
shortcut: BaseBaseBlockMove,
|
||||||
|
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
|
||||||
|
params: []string{"--before-id", "--after-id"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rename blank name",
|
||||||
|
shortcut: BaseBaseBlockRename,
|
||||||
|
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
|
||||||
|
params: []string{"--name"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != tt.params[0] {
|
||||||
|
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
|
||||||
|
}
|
||||||
|
if len(validationErr.Params) != len(tt.params) {
|
||||||
|
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
||||||
|
}
|
||||||
|
for i, param := range tt.params {
|
||||||
|
if validationErr.Params[i].Name != param {
|
||||||
|
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
||||||
|
}
|
||||||
|
if validationErr.Params[i].Reason == "" {
|
||||||
|
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseHistoryExecute(t *testing.T) {
|
func TestBaseHistoryExecute(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -871,10 +931,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
|||||||
t.Run("list-http-404", func(t *testing.T) {
|
t.Run("list-http-404", func(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/base/v3/bases/app_x/tables",
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
||||||
Status: 404,
|
Status: 404,
|
||||||
Body: "404 page not found",
|
RawBody: []byte("404 page not found"),
|
||||||
Headers: map[string][]string{
|
Headers: map[string][]string{
|
||||||
"Content-Type": {"text/plain"},
|
"Content-Type": {"text/plain"},
|
||||||
},
|
},
|
||||||
@@ -2093,6 +2153,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
|
||||||
|
t.Fatalf("err=%v should name the offending file", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||||
@@ -2262,6 +2325,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
|
||||||
|
factory, stdout, _ := newExecuteFactory(t)
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
withBaseWorkingDir(t, tmpDir)
|
||||||
|
|
||||||
|
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||||
|
"+record-download-attachment",
|
||||||
|
"--base-token", "app_x",
|
||||||
|
"--table-id", "tbl_x",
|
||||||
|
"--record-id", "rec_x",
|
||||||
|
"--output", "../escape",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -2458,21 +2538,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
|||||||
"--record-id", "rec_x",
|
"--record-id", "rec_x",
|
||||||
"--output", "downloads",
|
"--output", "downloads",
|
||||||
}, factory, stdout)
|
}, factory, stdout)
|
||||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
if err == nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var partialErr *output.PartialFailureError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &partialErr) {
|
||||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
t.Fatalf("expected partial failure error, got %T %v", err, err)
|
||||||
}
|
}
|
||||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
|
||||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
var envelope map[string]interface{}
|
||||||
failed, _ := detail["failed"].([]map[string]interface{})
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
|
||||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
|
||||||
}
|
}
|
||||||
if detail["log_id"] != "202605270001" {
|
if envelope["ok"] != false {
|
||||||
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
|
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
|
||||||
|
}
|
||||||
|
data, _ := envelope["data"].(map[string]interface{})
|
||||||
|
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||||
|
t.Fatalf("message=%q", msg)
|
||||||
|
}
|
||||||
|
downloaded, _ := data["downloaded"].([]interface{})
|
||||||
|
failed, _ := data["failed"].([]interface{})
|
||||||
|
if len(downloaded) != 1 || len(failed) != 1 {
|
||||||
|
t.Fatalf("data=%#v", data)
|
||||||
|
}
|
||||||
|
downloadedItem, _ := downloaded[0].(map[string]interface{})
|
||||||
|
failedItem, _ := failed[0].(map[string]interface{})
|
||||||
|
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
|
||||||
|
t.Fatalf("data=%#v", data)
|
||||||
|
}
|
||||||
|
if data["log_id"] != "202605270001" {
|
||||||
|
t.Fatalf("data=%#v, want log_id", data)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||||
t.Fatalf("expected first file to remain: %v", err)
|
t.Fatalf("expected first file to remain: %v", err)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
|||||||
|
|
||||||
var questions []interface{}
|
var questions []interface{}
|
||||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := baseV3Call(runtime, "POST",
|
data, err := baseV3Call(runtime, "POST",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
|||||||
|
|
||||||
var questionIds []string
|
var questionIds []string
|
||||||
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
|
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
|
||||||
return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err)
|
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := baseV3Call(runtime, "DELETE",
|
_, err := baseV3Call(runtime, "DELETE",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
|||||||
|
|
||||||
var questions []interface{}
|
var questions []interface{}
|
||||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := baseV3Call(runtime, "PATCH",
|
data, err := baseV3Call(runtime, "PATCH",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -62,31 +61,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
|
|||||||
attachments, hasAttachments := raw["attachments"]
|
attachments, hasAttachments := raw["attachments"]
|
||||||
|
|
||||||
if !hasAttachments && fields == nil {
|
if !hasAttachments && fields == nil {
|
||||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasAttachments {
|
if hasAttachments {
|
||||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||||
if runtime.Str("base-token") == "" {
|
if runtime.Str("base-token") == "" {
|
||||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
attMap, ok := attachments.(map[string]interface{})
|
attMap, ok := attachments.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||||
}
|
}
|
||||||
for fieldName, value := range attMap {
|
for fieldName, value := range attMap {
|
||||||
paths, ok := value.([]interface{})
|
paths, ok := value.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
return baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||||
}
|
}
|
||||||
for i, item := range paths {
|
for i, item := range paths {
|
||||||
if _, ok := item.(string); !ok {
|
if _, ok := item.(string); !ok {
|
||||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,21 +110,21 @@ func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}
|
|||||||
if attachments, ok := raw["attachments"]; ok {
|
if attachments, ok := raw["attachments"]; ok {
|
||||||
attObj, ok := attachments.(map[string]interface{})
|
attObj, ok := attachments.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
return nil, nil, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||||
}
|
}
|
||||||
if len(attObj) > 0 {
|
if len(attObj) > 0 {
|
||||||
attMap = make(map[string][]string, len(attObj))
|
attMap = make(map[string][]string, len(attObj))
|
||||||
for fieldName, value := range attObj {
|
for fieldName, value := range attObj {
|
||||||
paths, ok := value.([]interface{})
|
paths, ok := value.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
return nil, nil, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||||
}
|
}
|
||||||
filePaths := make([]string, 0, len(paths))
|
filePaths := make([]string, 0, len(paths))
|
||||||
for _, item := range paths {
|
for _, item := range paths {
|
||||||
if s, ok := item.(string); ok {
|
if s, ok := item.(string); ok {
|
||||||
filePaths = append(filePaths, s)
|
filePaths = append(filePaths, s)
|
||||||
} else {
|
} else {
|
||||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
return nil, nil, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(filePaths) > 0 {
|
if len(filePaths) > 0 {
|
||||||
@@ -195,33 +194,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
|
|||||||
baseToken := runtime.Str("base-token")
|
baseToken := runtime.Str("base-token")
|
||||||
fio := runtime.FileIO()
|
fio := runtime.FileIO()
|
||||||
if fio == nil {
|
if fio == nil {
|
||||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||||
allPaths := collectUniquePaths(attachmentMap)
|
allPaths := collectUniquePaths(attachmentMap)
|
||||||
if len(allPaths) == 0 {
|
if len(allPaths) == 0 {
|
||||||
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
return baseFlagErrorf("attachments in --json contains no valid file paths")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
||||||
sizeMap := make(map[string]int64, len(allPaths))
|
sizeMap := make(map[string]int64, len(allPaths))
|
||||||
for _, filePath := range allPaths {
|
for _, filePath := range allPaths {
|
||||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
fileInfo, err := fio.Stat(filePath)
|
fileInfo, err := fio.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fileio.ErrPathValidation) {
|
if errors.Is(err, fileio.ErrPathValidation) {
|
||||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
|
||||||
}
|
}
|
||||||
if !fileInfo.Mode().IsRegular() {
|
if !fileInfo.Mode().IsRegular() {
|
||||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
return baseValidationErrorf("attachment file %s is not a regular file", filePath)
|
||||||
}
|
}
|
||||||
sizeMap[filePath] = fileInfo.Size()
|
sizeMap[filePath] = fileInfo.Size()
|
||||||
}
|
}
|
||||||
@@ -328,7 +327,7 @@ func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, t
|
|||||||
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
||||||
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
return nil, baseUploadAttachmentError(filePath, err)
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ package base
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,14 @@ import (
|
|||||||
// - Inner: business-level code/message inside the data object
|
// - Inner: business-level code/message inside the data object
|
||||||
//
|
//
|
||||||
// The data field may be a JSON object (actual behavior) or a JSON string (per doc).
|
// The data field may be a JSON object (actual behavior) or a JSON string (per doc).
|
||||||
|
func handleRoleAPIResponse(runtime *common.RuntimeContext, apiResp *larkcore.ApiResp, action string) error {
|
||||||
|
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||||
|
enriched := enrichBaseAPIErrorFromBody(err, apiResp.RawBody, runtime.APIClassifyContext())
|
||||||
|
return prefixRoleActionError(enriched, action)
|
||||||
|
}
|
||||||
|
return handleRoleResponse(runtime, apiResp.RawBody, action)
|
||||||
|
}
|
||||||
|
|
||||||
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
|
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
@@ -24,23 +33,17 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
|||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(rawBody, &resp); err != nil {
|
if err := json.Unmarshal(rawBody, &resp); err != nil {
|
||||||
return fmt.Errorf("failed to parse response: %v", err)
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err)
|
||||||
}
|
}
|
||||||
if resp.Code != 0 {
|
if resp.Code != 0 {
|
||||||
msg := resp.Msg
|
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg}
|
||||||
// When outer msg is empty, try to extract error details from data.error.message
|
if len(resp.Data) > 0 {
|
||||||
if msg == "" && len(resp.Data) > 0 {
|
var data interface{}
|
||||||
var errData struct {
|
if json.Unmarshal(resp.Data, &data) == nil {
|
||||||
Error struct {
|
result["data"] = data
|
||||||
Message string `json:"message"`
|
|
||||||
Hint string `json:"hint"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" {
|
|
||||||
msg = errData.Error.Message
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
|
return baseRoleAPIError(runtime, result, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
|
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
|
||||||
@@ -75,7 +78,8 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
|||||||
}
|
}
|
||||||
if codeInt != 0 {
|
if codeInt != 0 {
|
||||||
msg, _ := m["message"].(string)
|
msg, _ := m["message"].(string)
|
||||||
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
|
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
|
||||||
|
return baseRoleAPIError(runtime, result, action)
|
||||||
}
|
}
|
||||||
// code == 0, extract the inner data if present
|
// code == 0, extract the inner data if present
|
||||||
if innerData, hasInner := m["data"]; hasInner {
|
if innerData, hasInner := m["data"]; hasInner {
|
||||||
@@ -98,3 +102,20 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
|||||||
runtime.Out(data, nil)
|
runtime.Out(data, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baseRoleAPIError(runtime *common.RuntimeContext, result map[string]interface{}, action string) error {
|
||||||
|
return prefixRoleActionError(baseAPIErrorFromResult(result, runtime.APIClassifyContext()), action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixRoleActionError prepends the failed role action ("create role failed",
|
||||||
|
// "get role failed", ...) to a typed error's message so both the classified
|
||||||
|
// outer-response path and the parsed-body path carry the same context.
|
||||||
|
func prefixRoleActionError(err error, action string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p, ok := errs.ProblemOf(err); ok && action != "" {
|
||||||
|
p.Message = action + ": " + p.Message
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ var BaseRoleCreate = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "create role failed")
|
return handleRoleAPIResponse(runtime, apiResp, "create role failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ var BaseRoleDelete = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||||
return common.FlagErrorf("--role-id must not be blank")
|
return baseFlagErrorf("--role-id must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed")
|
return handleRoleAPIResponse(runtime, apiResp, "delete role failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ var BaseRoleGet = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||||
return common.FlagErrorf("--role-id must not be blank")
|
return baseFlagErrorf("--role-id must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "get role failed")
|
return handleRoleAPIResponse(runtime, apiResp, "get role failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ var BaseRoleList = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
|
return handleRoleAPIResponse(runtime, apiResp, "list roles failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,9 +375,7 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
|
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
|
||||||
if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseRoleListExecuteTransportError(t *testing.T) {
|
func TestBaseRoleListExecuteTransportError(t *testing.T) {
|
||||||
@@ -405,9 +403,7 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+role-list", "--base-token", "app_x"}
|
args := []string{"+role-list", "--base-token", "app_x"}
|
||||||
if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||||
@@ -421,9 +417,7 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
|
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
|
||||||
if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
|
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||||
@@ -437,9 +431,7 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
|
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
|
||||||
if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
|
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||||
@@ -457,9 +449,7 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
|
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
|
||||||
if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") {
|
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -487,9 +477,7 @@ func TestHandleRoleResponse(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("outer error code", func(t *testing.T) {
|
t.Run("outer error code", func(t *testing.T) {
|
||||||
rt := newRoleResponseRuntime(t)
|
rt := newRoleResponseRuntime(t)
|
||||||
if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
|
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
|
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
|
||||||
@@ -574,9 +562,7 @@ func TestHandleRoleResponse(t *testing.T) {
|
|||||||
t.Run("business code non-zero", func(t *testing.T) {
|
t.Run("business code non-zero", func(t *testing.T) {
|
||||||
rt := newRoleResponseRuntime(t)
|
rt := newRoleResponseRuntime(t)
|
||||||
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
|
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
|
||||||
if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
|
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied")
|
||||||
t.Fatalf("err=%v", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("data is array", func(t *testing.T) {
|
t.Run("data is array", func(t *testing.T) {
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||||
return common.FlagErrorf("--role-id must not be blank")
|
return baseFlagErrorf("--role-id must not be blank")
|
||||||
}
|
}
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRoleResponse(runtime, apiResp.RawBody, "update role failed")
|
return handleRoleAPIResponse(runtime, apiResp, "update role failed")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,34 +30,34 @@ func baseTableID(runtime *common.RuntimeContext) string {
|
|||||||
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return "", common.FlagErrorf("--%s cannot be empty", flagName)
|
return "", baseFlagErrorf("--%s cannot be empty", flagName)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(raw, "@") {
|
if !strings.HasPrefix(raw, "@") {
|
||||||
return raw, nil
|
return raw, nil
|
||||||
}
|
}
|
||||||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
|
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||||
}
|
}
|
||||||
if pc.fio == nil {
|
if pc.fio == nil {
|
||||||
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
|
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName)
|
||||||
}
|
}
|
||||||
f, err := pc.fio.Open(path)
|
f, err := pc.fio.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var pathErr *fileio.PathValidationError
|
var pathErr *fileio.PathValidationError
|
||||||
if errors.As(err, &pathErr) {
|
if errors.As(err, &pathErr) {
|
||||||
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
return "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||||
}
|
}
|
||||||
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
data, err := io.ReadAll(f)
|
data, err := io.ReadAll(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||||
}
|
}
|
||||||
content := strings.TrimSpace(string(data))
|
content := strings.TrimSpace(string(data))
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path)
|
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path)
|
||||||
}
|
}
|
||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
|
|||||||
|
|
||||||
func formatJSONError(flagName string, target string, err error) error {
|
func formatJSONError(flagName string, target string, err error) error {
|
||||||
if syntaxErr, ok := err.(*json.SyntaxError); ok {
|
if syntaxErr, ok := err.(*json.SyntaxError); ok {
|
||||||
return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
return baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
||||||
}
|
}
|
||||||
if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
|
if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
|
||||||
if typeErr.Field != "" {
|
if typeErr.Field != "" {
|
||||||
return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
return baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
||||||
}
|
}
|
||||||
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||||
}
|
}
|
||||||
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||||
}
|
}
|
||||||
|
|
||||||
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
|
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
|
||||||
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return "", common.FlagErrorf("specify one action")
|
return "", baseFlagErrorf("specify one action")
|
||||||
}
|
}
|
||||||
if len(active) > 1 {
|
if len(active) > 1 {
|
||||||
flags := make([]string, 0, len(active))
|
flags := make([]string, 0, len(active))
|
||||||
for _, item := range active {
|
for _, item := range active {
|
||||||
flags = append(flags, "--"+item)
|
flags = append(flags, "--"+item)
|
||||||
}
|
}
|
||||||
return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
||||||
}
|
}
|
||||||
return active[0], nil
|
return active[0], nil
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
|
|||||||
for idx, item := range arr {
|
for idx, item := range arr {
|
||||||
obj, ok := item.(map[string]interface{})
|
obj, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
return nil, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
||||||
}
|
}
|
||||||
items = append(items, obj)
|
items = append(items, obj)
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
|
|||||||
case map[string]interface{}, []interface{}:
|
case map[string]interface{}, []interface{}:
|
||||||
return value, nil
|
return value, nil
|
||||||
default:
|
default:
|
||||||
return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
|
return nil, baseFlagErrorf("--%s must be a JSON object or array", flagName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package base
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
|||||||
if strings.TrimSpace(raw) == "" {
|
if strings.TrimSpace(raw) == "" {
|
||||||
// text 类型必须提供 data-config(含 text 内容)
|
// text 类型必须提供 data-config(含 text 内容)
|
||||||
if strings.ToLower(runtime.Str("type")) == "text" {
|
if strings.ToLower(runtime.Str("type")) == "text" {
|
||||||
return fmt.Errorf("text 类型组件必须提供 data-config,包含必填字段 text")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
|
|||||||
if fieldType == "lookup" {
|
if fieldType == "lookup" {
|
||||||
guidePath = "skills/lark-base/references/lookup-field-guide.md"
|
guidePath = "skills/lark-base/references/lookup-field-guide.md"
|
||||||
}
|
}
|
||||||
return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,10 +42,10 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
|||||||
if errors.As(err, &syntaxErr) {
|
if errors.As(err, &syntaxErr) {
|
||||||
return nil, formatJSONError(flagName, "object", err)
|
return nil, formatJSONError(flagName, "object", err)
|
||||||
}
|
}
|
||||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ func cloneValue(value interface{}) interface{} {
|
|||||||
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
||||||
trimmed := strings.TrimSpace(typeName)
|
trimmed := strings.TrimSpace(typeName)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
|
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
|
||||||
}
|
}
|
||||||
switch strings.ToLower(trimmed) {
|
switch strings.ToLower(trimmed) {
|
||||||
case "text", "phone", "url", "email", "barcode":
|
case "text", "phone", "url", "email", "barcode":
|
||||||
@@ -192,7 +193,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
|||||||
case "modifiedtime", "modified_time", "modified-time":
|
case "modifiedtime", "modified_time", "modified-time":
|
||||||
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
||||||
default:
|
default:
|
||||||
return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
|
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +253,10 @@ func normalizeSelectOptions(raw interface{}) []interface{} {
|
|||||||
|
|
||||||
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
|
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
|
||||||
if isPrimary {
|
if isPrimary {
|
||||||
return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
|
||||||
}
|
}
|
||||||
if isHidden {
|
if isHidden {
|
||||||
return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
|
||||||
}
|
}
|
||||||
spec, err := resolveFieldTypeSpec(typeName)
|
spec, err := resolveFieldTypeSpec(typeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -354,7 +355,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
|||||||
if rawFields != "" {
|
if rawFields != "" {
|
||||||
var fields []interface{}
|
var fields []interface{}
|
||||||
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
|
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
|
||||||
return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
|
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
|
||||||
}
|
}
|
||||||
return fields, nil
|
return fields, nil
|
||||||
}
|
}
|
||||||
@@ -366,7 +367,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
|||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
|
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("field %q: %w", spec.Name, err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
|
||||||
}
|
}
|
||||||
fields = append(fields, body)
|
fields = append(fields, body)
|
||||||
}
|
}
|
||||||
@@ -410,20 +411,15 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
|||||||
h.Set("X-App-Id", runtime.Config.AppID)
|
h.Set("X-App-Id", runtime.Config.AppID)
|
||||||
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
|
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, baseAPIBoundaryError(err, "API call failed")
|
||||||
|
}
|
||||||
|
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
|
||||||
|
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
|
||||||
|
return nil, statusErr
|
||||||
|
}
|
||||||
|
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
|
||||||
}
|
}
|
||||||
result, parseErr := decodeBaseV3Response(resp.RawBody)
|
result, parseErr := decodeBaseV3Response(resp.RawBody)
|
||||||
if parseErr == nil && baseV3ResultCode(result) != 0 {
|
|
||||||
attachBaseErrorLogID(result, baseResponseLogID(resp))
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if resp.StatusCode >= http.StatusBadRequest {
|
|
||||||
body := strings.TrimSpace(string(resp.RawBody))
|
|
||||||
if body == "" {
|
|
||||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return nil, parseErr
|
return nil, parseErr
|
||||||
}
|
}
|
||||||
@@ -435,18 +431,14 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
|
|||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
dec.UseNumber()
|
dec.UseNumber()
|
||||||
if err := dec.Decode(&result); err != nil {
|
if err := dec.Decode(&result); err != nil {
|
||||||
return nil, fmt.Errorf("response parse error: %w", err)
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func baseV3ResultCode(result map[string]interface{}) int {
|
|
||||||
if result == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return toInt(result["code"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
|
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
|
||||||
if result == nil || strings.TrimSpace(logID) == "" {
|
if result == nil || strings.TrimSpace(logID) == "" {
|
||||||
return
|
return
|
||||||
@@ -480,6 +472,33 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
|
|||||||
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
|
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
|
||||||
|
if resp == nil || resp.StatusCode < http.StatusBadRequest {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(classified)
|
||||||
|
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(string(resp.RawBody))
|
||||||
|
if resp.StatusCode >= http.StatusInternalServerError {
|
||||||
|
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
|
||||||
|
if logID := baseResponseLogID(resp); logID != "" {
|
||||||
|
err = err.WithLogID(logID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
subtype := errs.SubtypeUnknown
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
subtype = errs.SubtypeNotFound
|
||||||
|
}
|
||||||
|
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
|
||||||
|
if logID := baseResponseLogID(resp); logID != "" {
|
||||||
|
err = err.WithLogID(logID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||||
result, err := baseV3Raw(runtime, method, path, params, data)
|
result, err := baseV3Raw(runtime, method, path, params, data)
|
||||||
return handleBaseAPIResult(result, err, "API call failed")
|
return handleBaseAPIResult(result, err, "API call failed")
|
||||||
@@ -525,7 +544,7 @@ func toStringSlice(v interface{}) []string {
|
|||||||
|
|
||||||
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
|
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||||
}
|
}
|
||||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -555,7 +574,7 @@ func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, lim
|
|||||||
|
|
||||||
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||||
}
|
}
|
||||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -577,7 +596,7 @@ func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, of
|
|||||||
|
|
||||||
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||||
}
|
}
|
||||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -603,7 +622,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
|
|||||||
return field, nil
|
return field, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("field %q not found", ref)
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||||
@@ -612,7 +631,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
|
|||||||
return table, nil
|
return table, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("table %q not found", ref)
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||||
@@ -621,7 +640,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
|
|||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("view %q not found", ref)
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
|
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
|
||||||
@@ -738,18 +757,18 @@ func canonicalValue(v interface{}) string {
|
|||||||
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
|
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
|
||||||
var tuples []interface{}
|
var tuples []interface{}
|
||||||
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
|
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
|
||||||
return nil, fmt.Errorf("--%s invalid JSON array", flagName)
|
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
|
||||||
}
|
}
|
||||||
result := make([]namedTypeSpec, 0, len(tuples))
|
result := make([]namedTypeSpec, 0, len(tuples))
|
||||||
for idx, item := range tuples {
|
for idx, item := range tuples {
|
||||||
pair, ok := item.([]interface{})
|
pair, ok := item.([]interface{})
|
||||||
if !ok || len(pair) != 2 {
|
if !ok || len(pair) != 2 {
|
||||||
return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1)
|
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
|
||||||
}
|
}
|
||||||
name, ok1 := pair[0].(string)
|
name, ok1 := pair[0].(string)
|
||||||
typeName, ok2 := pair[1].(string)
|
typeName, ok2 := pair[1].(string)
|
||||||
if !ok1 || !ok2 {
|
if !ok1 || !ok2 {
|
||||||
return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
|
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||||
}
|
}
|
||||||
result = append(result, namedTypeSpec{Name: name, Type: typeName})
|
result = append(result, namedTypeSpec{Name: name, Type: typeName})
|
||||||
}
|
}
|
||||||
@@ -1155,9 +1174,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
|
|||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatDataConfigErrors(errs []string) error {
|
func formatDataConfigErrors(problems []string) error {
|
||||||
if len(errs) == 0 {
|
if len(problems) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,7 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
|||||||
case "", "json", "markdown":
|
case "", "json", "markdown":
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return output.ErrValidation("--format must be json or markdown")
|
return baseValidationErrorf("--format must be json or markdown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
|||||||
runtime.Out(data, nil)
|
runtime.Out(data, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
|
||||||
}
|
}
|
||||||
rendered, err := renderer(data)
|
rendered, err := renderer(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,7 +44,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
|||||||
}
|
}
|
||||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||||
if scanResult.Blocked {
|
if scanResult.Blocked {
|
||||||
return scanResult.BlockErr
|
return baseContentSafetyBlockError(scanResult)
|
||||||
}
|
}
|
||||||
if scanResult.Alert != nil {
|
if scanResult.Alert != nil {
|
||||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||||
@@ -52,6 +53,20 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
|
||||||
|
message := "content safety violation detected"
|
||||||
|
var rules []string
|
||||||
|
if scanResult.Alert != nil {
|
||||||
|
rules = scanResult.Alert.MatchedRules
|
||||||
|
}
|
||||||
|
if len(rules) > 0 {
|
||||||
|
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
|
||||||
|
}
|
||||||
|
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
|
||||||
|
WithRules(rules...).
|
||||||
|
WithCause(scanResult.BlockErr)
|
||||||
|
}
|
||||||
|
|
||||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
||||||
}
|
}
|
||||||
@@ -61,7 +76,7 @@ func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
|
|||||||
recordIDs := stringSliceValue(data["record_id_list"])
|
recordIDs := stringSliceValue(data["record_id_list"])
|
||||||
rows, ok := data["data"].([]interface{})
|
rows, ok := data["data"].([]interface{})
|
||||||
if len(fields) == 0 || !ok {
|
if len(fields) == 0 || !ok {
|
||||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||||
}
|
}
|
||||||
if len(recordIDs) == 1 && len(rows) == 1 {
|
if len(recordIDs) == 1 && len(rows) == 1 {
|
||||||
rowItems, _ := rows[0].([]interface{})
|
rowItems, _ := rows[0].([]interface{})
|
||||||
@@ -78,7 +93,7 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
|||||||
recordIDs := stringSliceValue(data["record_id_list"])
|
recordIDs := stringSliceValue(data["record_id_list"])
|
||||||
rows, ok := data["data"].([]interface{})
|
rows, ok := data["data"].([]interface{})
|
||||||
if len(fields) == 0 || !ok {
|
if len(fields) == 0 || !ok {
|
||||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -212,9 +213,12 @@ func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T)
|
|||||||
"record_id_list": []interface{}{"rec_1"},
|
"record_id_list": []interface{}{"rec_1"},
|
||||||
"data": []interface{}{[]interface{}{"Alice"}},
|
"data": []interface{}{[]interface{}{"Alice"}},
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
var csErr *errs.ContentSafetyError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
|
if !errors.As(err, &csErr) {
|
||||||
t.Fatalf("err=%v, want content safety exit error", err)
|
t.Fatalf("err=%v, want typed content safety error", err)
|
||||||
|
}
|
||||||
|
if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" {
|
||||||
|
t.Fatalf("rules=%v", csErr.Rules)
|
||||||
}
|
}
|
||||||
if stdout.Len() > 0 {
|
if stdout.Len() > 0 {
|
||||||
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
|||||||
fieldIDs := runtime.StrArray("field-id")
|
fieldIDs := runtime.StrArray("field-id")
|
||||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||||
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
|
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||||
}
|
}
|
||||||
if jsonRaw != "" {
|
if jsonRaw != "" {
|
||||||
pc := newParseCtx(runtime)
|
pc := newParseCtx(runtime)
|
||||||
@@ -59,11 +59,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
|||||||
}
|
}
|
||||||
recordIDListValue, ok := body["record_id_list"]
|
recordIDListValue, ok := body["record_id_list"]
|
||||||
if !ok {
|
if !ok {
|
||||||
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
return recordSelection{}, baseFlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||||
}
|
}
|
||||||
recordIDItems, ok := recordIDListValue.([]interface{})
|
recordIDItems, ok := recordIDListValue.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
return recordSelection{}, baseFlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||||
}
|
}
|
||||||
normalized, err := normalizeRecordIDs(recordIDItems)
|
normalized, err := normalizeRecordIDs(recordIDItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,14 +117,14 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
|
|||||||
return fromFlags, nil
|
return fromFlags, nil
|
||||||
}
|
}
|
||||||
if len(fromFlags) > 0 {
|
if len(fromFlags) > 0 {
|
||||||
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||||
}
|
}
|
||||||
items, ok := rawJSONFields.([]interface{})
|
items, ok := rawJSONFields.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
return nil, baseFlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
return nil, baseFlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||||
}
|
}
|
||||||
normalized, err := normalizeRecordGetSelectFields(items)
|
normalized, err := normalizeRecordGetSelectFields(items)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,7 +152,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
|
|||||||
if opts.allowNil {
|
if opts.allowNil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, common.FlagErrorf(opts.typeError)
|
return nil, baseFlagErrorf(opts.typeError)
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
rawItems = typed
|
rawItems = typed
|
||||||
case []string:
|
case []string:
|
||||||
@@ -161,30 +161,30 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
|
|||||||
rawItems = append(rawItems, item)
|
rawItems = append(rawItems, item)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil, common.FlagErrorf(opts.typeError)
|
return nil, baseFlagErrorf(opts.typeError)
|
||||||
}
|
}
|
||||||
if len(rawItems) == 0 {
|
if len(rawItems) == 0 {
|
||||||
if opts.allowEmpty {
|
if opts.allowEmpty {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, common.FlagErrorf(opts.emptyError)
|
return nil, baseFlagErrorf(opts.emptyError)
|
||||||
}
|
}
|
||||||
if opts.max > 0 && len(rawItems) > opts.max {
|
if opts.max > 0 && len(rawItems) > opts.max {
|
||||||
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||||
}
|
}
|
||||||
seen := make(map[string]int, len(rawItems))
|
seen := make(map[string]int, len(rawItems))
|
||||||
result := make([]string, 0, len(rawItems))
|
result := make([]string, 0, len(rawItems))
|
||||||
for index, value := range rawItems {
|
for index, value := range rawItems {
|
||||||
item, ok := value.(string)
|
item, ok := value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
return nil, baseFlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||||
}
|
}
|
||||||
item = strings.TrimSpace(item)
|
item = strings.TrimSpace(item)
|
||||||
if item == "" {
|
if item == "" {
|
||||||
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
return nil, baseFlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||||
}
|
}
|
||||||
if first, exists := seen[item]; exists {
|
if first, exists := seen[item]; exists {
|
||||||
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
return nil, baseFlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||||
}
|
}
|
||||||
seen[item] = index + 1
|
seen[item] = index + 1
|
||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
@@ -332,10 +332,10 @@ const maxShareBatchSize = 100
|
|||||||
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
|
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||||
recordIDs := deduplicateRecordIDs(runtime)
|
recordIDs := deduplicateRecordIDs(runtime)
|
||||||
if len(recordIDs) == 0 {
|
if len(recordIDs) == 0 {
|
||||||
return common.FlagErrorf("--record-ids is required and must not be empty")
|
return baseFlagErrorf("--record-ids is required and must not be empty")
|
||||||
}
|
}
|
||||||
if len(recordIDs) > maxShareBatchSize {
|
if len(recordIDs) > maxShareBatchSize {
|
||||||
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,18 +71,18 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
|
|||||||
} else if obj, ok := value.(map[string]interface{}); ok {
|
} else if obj, ok := value.(map[string]interface{}); ok {
|
||||||
rawSortConfig, ok := obj["sort_config"]
|
rawSortConfig, ok := obj["sort_config"]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||||
}
|
}
|
||||||
parsed, ok := rawSortConfig.([]interface{})
|
parsed, ok := rawSortConfig.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
|
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label)
|
||||||
}
|
}
|
||||||
sortConfig = parsed
|
sortConfig = parsed
|
||||||
} else {
|
} else {
|
||||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||||
}
|
}
|
||||||
if len(sortConfig) > recordSortMaxCount {
|
if len(sortConfig) > recordSortMaxCount {
|
||||||
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||||
}
|
}
|
||||||
return sortConfig, nil
|
return sortConfig, nil
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
|
|||||||
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
|
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||||
}
|
}
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
@@ -220,16 +220,16 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
|||||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||||
if jsonRaw != "" {
|
if jsonRaw != "" {
|
||||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||||
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||||
}
|
}
|
||||||
_, err := recordSearchJSONBody(runtime)
|
_, err := recordSearchJSONBody(runtime)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||||
return common.FlagErrorf("--keyword is required unless --json is used")
|
return baseFlagErrorf("--keyword is required unless --json is used")
|
||||||
}
|
}
|
||||||
if len(runtime.StrArray("search-field")) == 0 {
|
if len(runtime.StrArray("search-field")) == 0 {
|
||||||
return common.FlagErrorf("--search-field is required unless --json is used")
|
return baseFlagErrorf("--search-field is required unless --json is used")
|
||||||
}
|
}
|
||||||
return validateRecordQueryOptions(runtime)
|
return validateRecordQueryOptions(runtime)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -225,7 +224,7 @@ func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeCont
|
|||||||
|
|
||||||
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||||
if runtime.Changed("name") {
|
if runtime.Changed("name") {
|
||||||
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
return baseFlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||||
}
|
}
|
||||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -245,9 +244,16 @@ func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(tokens) != 1 {
|
if len(tokens) != 1 {
|
||||||
|
const outputDirRequired = "--output must be an existing directory when downloading multiple attachments or when --file-token is omitted"
|
||||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||||
if statErr != nil || !info.IsDir() {
|
if statErr != nil {
|
||||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
if errors.Is(statErr, fileio.ErrPathValidation) {
|
||||||
|
return baseValidationErrorf("unsafe output path: %s", statErr)
|
||||||
|
}
|
||||||
|
return baseFlagErrorf(outputDirRequired)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return baseFlagErrorf(outputDirRequired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -269,7 +275,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||||
}
|
}
|
||||||
resolvedFieldID := fieldID(field)
|
resolvedFieldID := fieldID(field)
|
||||||
if resolvedFieldID == "" {
|
if resolvedFieldID == "" {
|
||||||
@@ -316,7 +322,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||||
}
|
}
|
||||||
resolvedFieldID := fieldID(field)
|
resolvedFieldID := fieldID(field)
|
||||||
if resolvedFieldID == "" {
|
if resolvedFieldID == "" {
|
||||||
@@ -353,7 +359,7 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
|
|||||||
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed := attachmentDownloadFailure(target, err)
|
failed := attachmentDownloadFailure(target, err)
|
||||||
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
|
return attachmentDownloadProgressError(runtime, err, downloaded, []map[string]interface{}{failed})
|
||||||
}
|
}
|
||||||
downloaded = append(downloaded, saved)
|
downloaded = append(downloaded, saved)
|
||||||
}
|
}
|
||||||
@@ -364,20 +370,20 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
|
|||||||
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
||||||
fio := runtime.FileIO()
|
fio := runtime.FileIO()
|
||||||
if fio == nil {
|
if fio == nil {
|
||||||
return nil, output.ErrValidation("file operations require a FileIO provider")
|
return nil, baseValidationErrorf("file operations require a FileIO provider")
|
||||||
}
|
}
|
||||||
fileInfo, err := fio.Stat(filePath)
|
fileInfo, err := fio.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fileio.ErrPathValidation) {
|
if errors.Is(err, fileio.ErrPathValidation) {
|
||||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
return nil, baseValidationErrorf("unsafe file path: %s", err)
|
||||||
}
|
}
|
||||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
return nil, baseValidationErrorf("file not accessible: %s: %v", filePath, err)
|
||||||
}
|
}
|
||||||
if fileInfo.IsDir() {
|
if fileInfo.IsDir() {
|
||||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
return nil, baseValidationErrorf("file path is a directory: %s", filePath)
|
||||||
}
|
}
|
||||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
return nil, baseValidationErrorf("file %s exceeds 2GB limit (size: %s)", filePath, common.FormatSize(fileInfo.Size()))
|
||||||
}
|
}
|
||||||
return fileInfo, nil
|
return fileInfo, nil
|
||||||
}
|
}
|
||||||
@@ -412,13 +418,13 @@ func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, e
|
|||||||
for index, token := range tokens {
|
for index, token := range tokens {
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
return nil, baseFlagErrorf("attachment file token %d must not be empty", index+1)
|
||||||
}
|
}
|
||||||
normalized = append(normalized, token)
|
normalized = append(normalized, token)
|
||||||
}
|
}
|
||||||
normalized = dedupeStringsPreserveOrder(normalized)
|
normalized = dedupeStringsPreserveOrder(normalized)
|
||||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||||
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
return nil, baseFlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||||
}
|
}
|
||||||
return normalized, nil
|
return normalized, nil
|
||||||
}
|
}
|
||||||
@@ -453,10 +459,10 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
|||||||
|
|
||||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||||
if len(recordIDs) == 0 {
|
if len(recordIDs) == 0 {
|
||||||
return nil, output.ErrValidation("provide at least one record id")
|
return nil, baseValidationErrorf("provide at least one record id")
|
||||||
}
|
}
|
||||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
return nil, baseValidationErrorf("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||||
}
|
}
|
||||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
||||||
"record_id_list": recordIDs,
|
"record_id_list": recordIDs,
|
||||||
@@ -560,14 +566,14 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
|||||||
|
|
||||||
f, err := fio.Open(filePath)
|
f, err := fio.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", common.WrapInputStatError(err)
|
return "", baseInputStatError(err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
buf := make([]byte, 512)
|
buf := make([]byte, 512)
|
||||||
n, readErr := f.Read(buf)
|
n, readErr := f.Read(buf)
|
||||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
return "", baseValidationErrorf("cannot read file: %s", readErr)
|
||||||
}
|
}
|
||||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||||
}
|
}
|
||||||
@@ -617,11 +623,11 @@ type baseAttachmentDownloadTarget struct {
|
|||||||
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
||||||
recordRaw, ok := attachments[recordID]
|
recordRaw, ok := attachments[recordID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
return nil, baseValidationErrorf("record %q has no attachment metadata; verify the record-id", recordID)
|
||||||
}
|
}
|
||||||
fields, ok := recordRaw.(map[string]interface{})
|
fields, ok := recordRaw.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
return nil, baseValidationErrorf("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||||
}
|
}
|
||||||
byToken := map[string]baseAttachmentDownloadItem{}
|
byToken := map[string]baseAttachmentDownloadItem{}
|
||||||
fieldIDs := make([]string, 0, len(fields))
|
fieldIDs := make([]string, 0, len(fields))
|
||||||
@@ -633,12 +639,12 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
|||||||
rawList := fields[currentFieldID]
|
rawList := fields[currentFieldID]
|
||||||
items, ok := rawList.([]interface{})
|
items, ok := rawList.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
return nil, baseValidationErrorf("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||||
}
|
}
|
||||||
for _, rawItem := range items {
|
for _, rawItem := range items {
|
||||||
item, ok := rawItem.(map[string]interface{})
|
item, ok := rawItem.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
return nil, baseValidationErrorf("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||||
}
|
}
|
||||||
fileToken, _ := item["file_token"].(string)
|
fileToken, _ := item["file_token"].(string)
|
||||||
if fileToken == "" {
|
if fileToken == "" {
|
||||||
@@ -668,7 +674,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
|||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
if len(result) == 0 {
|
if len(result) == 0 {
|
||||||
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
|
return nil, baseValidationErrorf("record %q has no attachments to download", recordID)
|
||||||
}
|
}
|
||||||
sort.SliceStable(result, func(i, j int) bool {
|
sort.SliceStable(result, func(i, j int) bool {
|
||||||
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
||||||
@@ -683,7 +689,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
|||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
item, ok := byToken[token]
|
item, ok := byToken[token]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
return nil, baseValidationErrorf("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||||
}
|
}
|
||||||
result = append(result, item)
|
result = append(result, item)
|
||||||
}
|
}
|
||||||
@@ -702,15 +708,15 @@ func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseA
|
|||||||
}
|
}
|
||||||
resolved, err := runtime.ResolveSavePath(targetPath)
|
resolved, err := runtime.ResolveSavePath(targetPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
||||||
}
|
}
|
||||||
if previous, exists := seen[resolved]; exists {
|
if previous, exists := seen[resolved]; exists {
|
||||||
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
return nil, baseValidationErrorf("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||||
}
|
}
|
||||||
seen[resolved] = item
|
seen[resolved] = item
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
targets = append(targets, baseAttachmentDownloadTarget{
|
targets = append(targets, baseAttachmentDownloadTarget{
|
||||||
@@ -776,7 +782,7 @@ func safeAttachmentFileTokenSuffix(fileToken string) string {
|
|||||||
|
|
||||||
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
||||||
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
||||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := larkcore.QueryParams{}
|
query := larkcore.QueryParams{}
|
||||||
@@ -795,7 +801,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
|||||||
|
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
||||||
@@ -803,7 +809,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
|||||||
ContentLength: resp.ContentLength,
|
ContentLength: resp.ContentLength,
|
||||||
}, resp.Body)
|
}, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
return nil, baseSaveError(err)
|
||||||
}
|
}
|
||||||
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
||||||
if savedPath == "" {
|
if savedPath == "" {
|
||||||
@@ -822,7 +828,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
failure := map[string]interface{}{
|
||||||
"record_id": target.Item.RecordID,
|
"record_id": target.Item.RecordID,
|
||||||
"field_id": target.Item.FieldID,
|
"field_id": target.Item.FieldID,
|
||||||
"file_token": target.Item.FileToken,
|
"file_token": target.Item.FileToken,
|
||||||
@@ -831,72 +837,45 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m
|
|||||||
"resolved_path": target.ResolvedPath,
|
"resolved_path": target.ResolvedPath,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
}
|
}
|
||||||
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
|
failure["type"] = string(p.Category)
|
||||||
|
failure["subtype"] = string(p.Subtype)
|
||||||
|
if p.Code != 0 {
|
||||||
|
failure["code"] = p.Code
|
||||||
|
}
|
||||||
|
if p.LogID != "" {
|
||||||
|
failure["log_id"] = p.LogID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failure
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
func attachmentDownloadProgressError(runtime *common.RuntimeContext, err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||||
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
||||||
detail := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
|
"message": msg,
|
||||||
"downloaded": downloaded,
|
"downloaded": downloaded,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
}
|
}
|
||||||
|
const hint = "Some files may already have been saved. Inspect downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
||||||
|
payload["hint"] = hint
|
||||||
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
|
payload["type"] = string(p.Category)
|
||||||
|
payload["subtype"] = string(p.Subtype)
|
||||||
|
if p.Code != 0 {
|
||||||
|
payload["code"] = p.Code
|
||||||
|
}
|
||||||
|
}
|
||||||
if logID := baseAttachmentDownloadLogID(err); logID != "" {
|
if logID := baseAttachmentDownloadLogID(err); logID != "" {
|
||||||
detail["log_id"] = logID
|
payload["log_id"] = logID
|
||||||
}
|
|
||||||
const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
|
||||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
|
||||||
return &output.ExitError{
|
|
||||||
Code: exitErr.Code,
|
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: exitErr.Detail.Type,
|
|
||||||
Code: exitErr.Detail.Code,
|
|
||||||
Message: msg,
|
|
||||||
Hint: hint,
|
|
||||||
Detail: detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var netErr *errs.NetworkError
|
|
||||||
if errors.As(err, &netErr) {
|
|
||||||
return &output.ExitError{
|
|
||||||
Code: output.ExitNetwork,
|
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: "network",
|
|
||||||
Code: netErr.Code,
|
|
||||||
Message: msg,
|
|
||||||
Hint: hint,
|
|
||||||
Detail: detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &output.ExitError{
|
|
||||||
Code: output.ExitInternal,
|
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: "io",
|
|
||||||
Message: msg,
|
|
||||||
Hint: hint,
|
|
||||||
Detail: detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
}
|
||||||
|
return runtime.OutPartialFailure(payload, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func baseAttachmentDownloadLogID(err error) string {
|
func baseAttachmentDownloadLogID(err error) string {
|
||||||
var netErr *errs.NetworkError
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
if errors.As(err, &netErr) {
|
if logID := strings.TrimSpace(p.LogID); logID != "" {
|
||||||
if id := strings.TrimSpace(netErr.LogID); id != "" {
|
return logID
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var exitErr *output.ExitError
|
|
||||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
|
||||||
if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok {
|
|
||||||
if logID, _ := detail["log_id"].(string); logID != "" {
|
|
||||||
return strings.TrimSpace(logID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package base
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -117,7 +116,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
|
|||||||
for idx, item := range fieldItems {
|
for idx, item := range fieldItems {
|
||||||
body, ok := item.(map[string]interface{})
|
body, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("--fields item %d must be an object", idx+1)
|
return baseValidationErrorf("--fields item %d must be an object", idx+1)
|
||||||
}
|
}
|
||||||
if idx == 0 && len(defaultFields) > 0 {
|
if idx == 0 && len(defaultFields) > 0 {
|
||||||
fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body)
|
fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var BaseWorkflowCreate = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
pc := newParseCtx(runtime)
|
pc := newParseCtx(runtime)
|
||||||
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
|
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ var BaseWorkflowDisable = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||||
return common.FlagErrorf("--workflow-id must not be blank")
|
return baseFlagErrorf("--workflow-id must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ var BaseWorkflowEnable = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||||
return common.FlagErrorf("--workflow-id must not be blank")
|
return baseFlagErrorf("--workflow-id must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ var BaseWorkflowGet = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||||
return common.FlagErrorf("--workflow-id must not be blank")
|
return baseFlagErrorf("--workflow-id must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ var BaseWorkflowList = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ var BaseWorkflowUpdate = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||||
return common.FlagErrorf("--base-token must not be blank")
|
return baseFlagErrorf("--base-token must not be blank")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||||
return common.FlagErrorf("--workflow-id must not be blank")
|
return baseFlagErrorf("--workflow-id must not be blank")
|
||||||
}
|
}
|
||||||
pc := newParseCtx(runtime)
|
pc := newParseCtx(runtime)
|
||||||
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {
|
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/util"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -29,7 +29,7 @@ const (
|
|||||||
|
|
||||||
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
|
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
|
||||||
if depth > 10 {
|
if depth > 10 {
|
||||||
return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view")
|
return nil, errs.NewInternalError(errs.SubtypeUnknown, "too many splits for instance_view")
|
||||||
}
|
}
|
||||||
if startTime > endTime {
|
if startTime > endTime {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -48,68 +48,67 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
|
|||||||
return append(left, right...), nil
|
return append(left, right...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := runtime.RawAPI("GET",
|
data, err := runtime.CallAPITyped("GET",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)),
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"start_time": fmt.Sprintf("%d", startTime),
|
"start_time": fmt.Sprintf("%d", startTime),
|
||||||
"end_time": fmt.Sprintf("%d", endTime),
|
"end_time": fmt.Sprintf("%d", endTime),
|
||||||
}, nil)
|
}, nil)
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
|
// CallAPITyped returns a typed error for any non-zero API code. The two
|
||||||
}
|
// calendar instance_view limits (193103 time-range, 193104 too-many) are
|
||||||
|
// recoverable by narrowing the window, so inspect the typed code and
|
||||||
resultMap, _ := result.(map[string]interface{})
|
// recurse instead of treating them as fatal. Any other code falls through
|
||||||
code, _ := util.ToFloat64(resultMap["code"])
|
// to return the typed error unchanged.
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
if code == 0 {
|
if !ok {
|
||||||
data, _ := resultMap["data"].(map[string]interface{})
|
return nil, err
|
||||||
items, _ := data["items"].([]interface{})
|
}
|
||||||
var events []map[string]interface{}
|
switch p.Code {
|
||||||
for _, item := range items {
|
case larkErrCalendarTimeRangeExceeded:
|
||||||
if m, ok := item.(map[string]interface{}); ok {
|
mid := startTime + span/2
|
||||||
events = append(events, m)
|
if mid <= startTime {
|
||||||
|
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
|
||||||
|
"query failed: time range exceeds 40-day limit, please narrow the range").
|
||||||
|
WithCode(larkErrCalendarTimeRangeExceeded)
|
||||||
}
|
}
|
||||||
|
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
|
||||||
|
case larkErrCalendarTooManyInstances:
|
||||||
|
if span <= minSplitWindowSeconds {
|
||||||
|
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
|
||||||
|
"query failed: more than 1000 instances in the time range, please narrow the range").
|
||||||
|
WithCode(larkErrCalendarTooManyInstances)
|
||||||
|
}
|
||||||
|
mid := startTime + span/2
|
||||||
|
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return events, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error 193103: time range exceeds limit -> split
|
items, _ := data["items"].([]interface{})
|
||||||
if int(code) == larkErrCalendarTimeRangeExceeded {
|
var events []map[string]interface{}
|
||||||
mid := startTime + span/2
|
for _, item := range items {
|
||||||
if mid <= startTime {
|
if m, ok := item.(map[string]interface{}); ok {
|
||||||
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range")
|
events = append(events, m)
|
||||||
}
|
}
|
||||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return append(left, right...), nil
|
|
||||||
}
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Error 193104: too many instances -> split
|
// fetchInstanceViewSplit halves [startTime, endTime] at mid and concatenates the
|
||||||
if int(code) == larkErrCalendarTooManyInstances {
|
// results of the two recursive sub-range queries. Shared by the 193103/193104
|
||||||
if span <= minSplitWindowSeconds {
|
// split paths.
|
||||||
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range")
|
func fetchInstanceViewSplit(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, mid, endTime int64, depth int) ([]map[string]interface{}, error) {
|
||||||
}
|
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||||
mid := startTime + span/2
|
if err != nil {
|
||||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
return nil, err
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return append(left, right...), nil
|
|
||||||
}
|
}
|
||||||
|
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||||
msg, _ := resultMap["msg"].(string)
|
if err != nil {
|
||||||
return nil, output.ErrAPI(int(code), msg, resultMap["error"])
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(left, right...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
|
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
|
||||||
@@ -147,20 +146,20 @@ func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) {
|
|||||||
|
|
||||||
startTime, err := common.ParseTime(startInput)
|
startTime, err := common.ParseTime(startInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, output.ErrValidation("--start: %v", err)
|
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
endTime, err := common.ParseTime(endInput, "end")
|
endTime, err := common.ParseTime(endInput, "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, output.ErrValidation("--end: %v", err)
|
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
|
|
||||||
startInt, err := strconv.ParseInt(startTime, 10, 64)
|
startInt, err := strconv.ParseInt(startTime, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, output.ErrValidation("invalid start time: %v", err)
|
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
endInt, err := strconv.ParseInt(endTime, 10, 64)
|
endInt, err := strconv.ParseInt(endTime, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, output.ErrValidation("invalid end time: %v", err)
|
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
|
|
||||||
return startInt, endInt, nil
|
return startInt, endInt, nil
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -60,7 +61,7 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
|||||||
case strings.HasPrefix(id, "ou_"):
|
case strings.HasPrefix(id, "ou_"):
|
||||||
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
|
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported attendee id format: %s", id)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported attendee id format: %s", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return attendees, nil
|
return attendees, nil
|
||||||
@@ -89,8 +90,8 @@ var CalendarCreate = common.Shortcut{
|
|||||||
}
|
}
|
||||||
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
|
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
|
||||||
if val := runtime.Str(flag); val != "" {
|
if val := runtime.Str(flag); val != "" {
|
||||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
return output.ErrValidation(err.Error())
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,35 +103,35 @@ var CalendarCreate = common.Shortcut{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
||||||
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--attendee-ids")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime.Str("start") == "" {
|
if runtime.Str("start") == "" {
|
||||||
return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --start (e.g. '2026-03-12T14:00+08:00')").WithParam("--start")
|
||||||
}
|
}
|
||||||
if runtime.Str("end") == "" {
|
if runtime.Str("end") == "" {
|
||||||
return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end")
|
||||||
}
|
}
|
||||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.FlagErrorf("--start: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.FlagErrorf("--end: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
s, err := strconv.ParseInt(startTs, 10, 64)
|
s, err := strconv.ParseInt(startTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.FlagErrorf("invalid start time: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
e, err := strconv.ParseInt(endTs, 10, 64)
|
e, err := strconv.ParseInt(endTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.FlagErrorf("invalid end time: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
if e <= s {
|
if e <= s {
|
||||||
return common.FlagErrorf("end time must be after start time")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -183,27 +184,26 @@ var CalendarCreate = common.Shortcut{
|
|||||||
|
|
||||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("--start: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("--end: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
|
|
||||||
eventData := buildEventData(runtime, startTs, endTs)
|
eventData := buildEventData(runtime, startTs, endTs)
|
||||||
|
|
||||||
// Create event
|
// Create event
|
||||||
data, err := runtime.CallAPI("POST",
|
data, err := runtime.CallAPITyped("POST",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
|
||||||
nil, eventData)
|
nil, eventData)
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
event, _ := data["event"].(map[string]interface{})
|
event, _ := data["event"].(map[string]interface{})
|
||||||
eventId, _ := event["event_id"].(string)
|
eventId, _ := event["event_id"].(string)
|
||||||
if eventId == "" {
|
if eventId == "" {
|
||||||
return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned")
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create event: no event_id returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add attendees if specified
|
// Add attendees if specified
|
||||||
@@ -214,27 +214,25 @@ var CalendarCreate = common.Shortcut{
|
|||||||
}
|
}
|
||||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("invalid attendee id: %v", err)
|
return withParam(err, "--attendee-ids")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = runtime.CallAPI("POST",
|
_, err = runtime.CallAPITyped("POST",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
map[string]interface{}{"user_id_type": "open_id"},
|
map[string]interface{}{"user_id_type": "open_id"},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"need_notification": true,
|
"need_notification": true,
|
||||||
})
|
})
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Rollback: delete the event
|
// Rollback: delete the event
|
||||||
_, rollbackErr := runtime.RawAPI("DELETE",
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
map[string]interface{}{"need_notification": false}, nil)
|
map[string]interface{}{"need_notification": false}, nil)
|
||||||
rollbackErr = wrapPredefinedError(rollbackErr)
|
|
||||||
if rollbackErr != nil {
|
if rollbackErr != nil {
|
||||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
return withStepContext(err, "rollback also failed (%v); orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
||||||
}
|
}
|
||||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err)
|
return withStepContext(err, "event rolled back successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -20,20 +21,20 @@ func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, err
|
|||||||
|
|
||||||
startTs, err := common.ParseTime(startInput)
|
startTs, err := common.ParseTime(startInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", output.ErrValidation("--start: %v", err)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
endTs, err := common.ParseTime(endInput, "end")
|
endTs, err := common.ParseTime(endInput, "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", output.ErrValidation("--end: %v", err)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
|
|
||||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||||
}
|
}
|
||||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
timeMin := time.Unix(startSec, 0).Format(time.RFC3339)
|
timeMin := time.Unix(startSec, 0).Format(time.RFC3339)
|
||||||
@@ -73,13 +74,13 @@ var CalendarFreebusy = common.Shortcut{
|
|||||||
}
|
}
|
||||||
userId := runtime.Str("user-id")
|
userId := runtime.Str("user-id")
|
||||||
if userId == "" && runtime.IsBot() {
|
if userId == "" && runtime.IsBot() {
|
||||||
return common.FlagErrorf("--user-id is required for bot identity")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required for bot identity").WithParam("--user-id")
|
||||||
}
|
}
|
||||||
if userId == "" && runtime.UserOpenId() == "" {
|
if userId == "" && runtime.UserOpenId() == "" {
|
||||||
return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot determine user ID, specify --user-id or ensure you are logged in").WithParam("--user-id")
|
||||||
}
|
}
|
||||||
if userId != "" {
|
if userId != "" {
|
||||||
if _, err := common.ValidateUserID(userId); err != nil {
|
if _, err := common.ValidateUserIDTyped("--user-id", userId); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,16 +94,17 @@ var CalendarFreebusy = common.Shortcut{
|
|||||||
|
|
||||||
timeMin, timeMax, err := parseFreebusyTimeRange(runtime)
|
timeMin, timeMax, err := parseFreebusyTimeRange(runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("--start/--end: %v", err)
|
// parseFreebusyTimeRange already returns a typed *errs.ValidationError
|
||||||
|
// carrying the offending flag in .Param; pass it through unchanged.
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
|
data, err := runtime.CallAPITyped("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
|
||||||
"time_min": timeMin,
|
"time_min": timeMin,
|
||||||
"time_max": timeMax,
|
"time_max": timeMax,
|
||||||
"user_id": userId,
|
"user_id": userId,
|
||||||
"need_rsvp_status": true,
|
"need_rsvp_status": true,
|
||||||
})
|
})
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
@@ -126,40 +126,40 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
|
|||||||
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
|
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
|
||||||
rawSlots := runtime.StrArray(flagSlot)
|
rawSlots := runtime.StrArray(flagSlot)
|
||||||
if len(rawSlots) == 0 {
|
if len(rawSlots) == 0 {
|
||||||
return nil, output.ErrValidation("specify at least one --slot")
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one --slot").WithParam("--slot")
|
||||||
}
|
}
|
||||||
slots := make([]roomFindSlot, 0, len(rawSlots))
|
slots := make([]roomFindSlot, 0, len(rawSlots))
|
||||||
for _, raw := range rawSlots {
|
for _, raw := range rawSlots {
|
||||||
parts := strings.Split(strings.TrimSpace(raw), "~")
|
parts := strings.Split(strings.TrimSpace(raw), "~")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --slot format %q, expected start~end", raw).WithParam("--slot")
|
||||||
}
|
}
|
||||||
startTs, err := common.ParseTime(parts[0])
|
startTs, err := common.ParseTime(parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start time %q: %v", parts[0], err).WithParam("--slot")
|
||||||
}
|
}
|
||||||
endTs, err := common.ParseTime(parts[1])
|
endTs, err := common.ParseTime(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end time %q: %v", parts[1], err).WithParam("--slot")
|
||||||
}
|
}
|
||||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
|
||||||
}
|
}
|
||||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
|
||||||
}
|
}
|
||||||
if endSec <= startSec {
|
if endSec <= startSec {
|
||||||
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slot end time must be after start time: %q", raw).WithParam("--slot")
|
||||||
}
|
}
|
||||||
startRFC3339, err := unixStringToRFC3339(startTs)
|
startRFC3339, err := unixStringToRFC3339(startTs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
|
||||||
}
|
}
|
||||||
endRFC3339, err := unixStringToRFC3339(endTs)
|
endRFC3339, err := unixStringToRFC3339(endTs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
|
||||||
}
|
}
|
||||||
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
|
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string
|
|||||||
seenChats[id] = true
|
seenChats[id] = true
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if currentUserID != "" && !seenUsers[currentUserID] {
|
if currentUserID != "" && !seenUsers[currentUserID] {
|
||||||
@@ -249,20 +249,19 @@ func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*room
|
|||||||
Body: req,
|
Body: req,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errs.WrapInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||||
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp = &OpenAPIResponse[*roomFindData]{}
|
var resp = &OpenAPIResponse[*roomFindData]{}
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Code != 0 {
|
|
||||||
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.Data != nil {
|
if resp.Data != nil {
|
||||||
@@ -317,8 +316,8 @@ var CalendarRoomFind = common.Shortcut{
|
|||||||
}
|
}
|
||||||
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} {
|
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} {
|
||||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
return output.ErrValidation(err.Error())
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,8 +326,8 @@ var CalendarRoomFind = common.Shortcut{
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := common.RejectDangerousChars("--"+flagRoomName, name); err != nil {
|
if err := common.RejectDangerousCharsTyped("--"+flagRoomName, name); err != nil {
|
||||||
return output.ErrValidation(err.Error())
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := parseRoomFindSlots(runtime); err != nil {
|
if _, err := parseRoomFindSlots(runtime); err != nil {
|
||||||
@@ -338,13 +337,13 @@ var CalendarRoomFind = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
|
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
|
||||||
return output.ErrValidation("--min-capacity must be >= 0")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be >= 0").WithParam("--min-capacity")
|
||||||
}
|
}
|
||||||
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
|
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
|
||||||
return output.ErrValidation("--max-capacity must be >= 0")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-capacity must be >= 0").WithParam("--max-capacity")
|
||||||
}
|
}
|
||||||
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
|
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
|
||||||
return output.ErrValidation("--min-capacity must be <= --max-capacity")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be <= --max-capacity").WithParam("--min-capacity")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -51,15 +51,15 @@ var CalendarRsvp = common.Shortcut{
|
|||||||
}
|
}
|
||||||
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
|
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
|
||||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
return output.ErrValidation(err.Error())
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
if eventId == "" {
|
if eventId == "" {
|
||||||
return output.ErrValidation("event-id cannot be empty")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -71,7 +71,7 @@ var CalendarRsvp = common.Shortcut{
|
|||||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
status := strings.TrimSpace(runtime.Str("rsvp-status"))
|
status := strings.TrimSpace(runtime.Str("rsvp-status"))
|
||||||
|
|
||||||
_, err := runtime.DoAPIJSON("POST",
|
_, err := runtime.CallAPITyped("POST",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
|
||||||
validate.EncodePathSegment(calendarId),
|
validate.EncodePathSegment(calendarId),
|
||||||
validate.EncodePathSegment(eventId)),
|
validate.EncodePathSegment(eventId)),
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -70,11 +70,11 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
|||||||
|
|
||||||
timeMin, err := common.ParseTime(startInput)
|
timeMin, err := common.ParseTime(startInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid --start: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
minSec, err := strconv.ParseInt(timeMin, 10, 64)
|
minSec, err := strconv.ParseInt(timeMin, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid start timestamp: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||||
}
|
}
|
||||||
startTime := time.Unix(minSec, 0)
|
startTime := time.Unix(minSec, 0)
|
||||||
|
|
||||||
@@ -87,12 +87,12 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
|||||||
|
|
||||||
timeMax, err := common.ParseTime(endInput, "end")
|
timeMax, err := common.ParseTime(endInput, "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid --end: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
// Convert Unix timestamp string back to RFC3339 since the API requires RFC3339
|
// Convert Unix timestamp string back to RFC3339 since the API requires RFC3339
|
||||||
maxSec, err := strconv.ParseInt(timeMax, 10, 64)
|
maxSec, err := strconv.ParseInt(timeMax, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid end timestamp: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||||
}
|
}
|
||||||
req.SearchStartTime = startTime.Format(time.RFC3339)
|
req.SearchStartTime = startTime.Format(time.RFC3339)
|
||||||
req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339)
|
req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339)
|
||||||
@@ -157,23 +157,23 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
|||||||
}
|
}
|
||||||
parts := strings.Split(r, "~")
|
parts := strings.Split(r, "~")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --exclude format %q, expected 'start~end'", r).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
startTsStr, err := common.ParseTime(parts[0])
|
startTsStr, err := common.ParseTime(parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
endTsStr, err := common.ParseTime(parts[1], "end")
|
endTsStr, err := common.ParseTime(parts[1], "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
startSec, err := strconv.ParseInt(startTsStr, 10, 64)
|
startSec, err := strconv.ParseInt(startTsStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp in --exclude: %v", err).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
endSec, err := strconv.ParseInt(endTsStr, 10, 64)
|
endSec, err := strconv.ParseInt(endTsStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp in --exclude: %v", err).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
excludedTimes = append(excludedTimes, &EventTime{
|
excludedTimes = append(excludedTimes, &EventTime{
|
||||||
EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339),
|
EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339),
|
||||||
@@ -219,13 +219,13 @@ var CalendarSuggestion = common.Shortcut{
|
|||||||
}
|
}
|
||||||
durationMinutes := runtime.Int(flagDurationMinutes)
|
durationMinutes := runtime.Int(flagDurationMinutes)
|
||||||
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
|
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
|
||||||
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440").WithParam("--duration-minutes")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, flag := range []string{flagEventRrule, flagTimezone} {
|
for _, flag := range []string{flagEventRrule, flagTimezone} {
|
||||||
if val := runtime.Str(flag); val != "" {
|
if val := runtime.Str(flag); val != "" {
|
||||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
return output.ErrValidation(err.Error())
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ var CalendarSuggestion = common.Shortcut{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") {
|
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") {
|
||||||
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,14 +245,14 @@ var CalendarSuggestion = common.Shortcut{
|
|||||||
startInput := runtime.Str(flagStart)
|
startInput := runtime.Str(flagStart)
|
||||||
if startInput != "" {
|
if startInput != "" {
|
||||||
if _, err := common.ParseTime(startInput); err != nil {
|
if _, err := common.ParseTime(startInput); err != nil {
|
||||||
return output.ErrValidation("invalid start time: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endInput := runtime.Str(flagEnd)
|
endInput := runtime.Str(flagEnd)
|
||||||
if endInput != "" {
|
if endInput != "" {
|
||||||
if _, err := common.ParseTime(endInput, "end"); err != nil {
|
if _, err := common.ParseTime(endInput, "end"); err != nil {
|
||||||
return output.ErrValidation("invalid end time: %v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,13 +267,13 @@ var CalendarSuggestion = common.Shortcut{
|
|||||||
}
|
}
|
||||||
parts := strings.Split(r, "~")
|
parts := strings.Split(r, "~")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid range format in --exclude: %q, expect start~end", r).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
if _, err := common.ParseTime(parts[0]); err != nil {
|
if _, err := common.ParseTime(parts[0]); err != nil {
|
||||||
return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
if _, err := common.ParseTime(parts[1], "end"); err != nil {
|
if _, err := common.ParseTime(parts[1], "end"); err != nil {
|
||||||
return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,20 +292,19 @@ var CalendarSuggestion = common.Shortcut{
|
|||||||
Body: req,
|
Body: req,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errs.WrapInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||||
return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp = &OpenAPIResponse[*SuggestionResponse]{}
|
var resp = &OpenAPIResponse[*SuggestionResponse]{}
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||||
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Code != 0 {
|
|
||||||
return output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data := resp.Data
|
data := resp.Data
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -53,14 +54,14 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} {
|
for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} {
|
||||||
if val := runtime.Str(flag); val != "" {
|
if val := runtime.Str(flag); val != "" {
|
||||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
return output.ErrValidation(err.Error())
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(runtime.Str("event-id")) == "" {
|
if strings.TrimSpace(runtime.Str("event-id")) == "" {
|
||||||
return common.FlagErrorf("specify --event-id")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||||
}
|
}
|
||||||
if _, _, err := buildCalendarUpdateEventData(runtime); err != nil {
|
if _, _, err := buildCalendarUpdateEventData(runtime); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -69,7 +70,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !hasCalendarUpdateOperation(runtime) {
|
if !hasCalendarUpdateOperation(runtime) {
|
||||||
return common.FlagErrorf("nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -77,11 +78,11 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
|||||||
func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
|
func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
|
||||||
addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids"))
|
addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return withParam(err, "--add-attendee-ids")
|
||||||
}
|
}
|
||||||
removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids"))
|
removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return withParam(err, "--remove-attendee-ids")
|
||||||
}
|
}
|
||||||
removeSet := make(map[string]struct{}, len(removeIDs))
|
removeSet := make(map[string]struct{}, len(removeIDs))
|
||||||
for _, id := range removeIDs {
|
for _, id := range removeIDs {
|
||||||
@@ -89,7 +90,7 @@ func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
for _, id := range addIDs {
|
for _, id := range addIDs {
|
||||||
if _, ok := removeSet[id]; ok {
|
if _, ok := removeSet[id]; ok {
|
||||||
return output.ErrValidation("attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -124,27 +125,27 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
|||||||
startChanged := runtime.Cmd.Flags().Changed("start")
|
startChanged := runtime.Cmd.Flags().Changed("start")
|
||||||
endChanged := runtime.Cmd.Flags().Changed("end")
|
endChanged := runtime.Cmd.Flags().Changed("end")
|
||||||
if startChanged != endChanged {
|
if startChanged != endChanged {
|
||||||
return nil, false, common.FlagErrorf("--start and --end must be specified together when updating event time")
|
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start and --end must be specified together when updating event time")
|
||||||
}
|
}
|
||||||
if startChanged {
|
if startChanged {
|
||||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, common.FlagErrorf("--start: %v", err)
|
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, common.FlagErrorf("--end: %v", err)
|
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
s, err := strconv.ParseInt(startTs, 10, 64)
|
s, err := strconv.ParseInt(startTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, common.FlagErrorf("invalid start time: %v", err)
|
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
e, err := strconv.ParseInt(endTs, 10, 64)
|
e, err := strconv.ParseInt(endTs, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, common.FlagErrorf("invalid end time: %v", err)
|
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
if e <= s {
|
if e <= s {
|
||||||
return nil, false, common.FlagErrorf("end time must be after start time")
|
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
|
||||||
}
|
}
|
||||||
body["start_time"] = map[string]string{"timestamp": startTs}
|
body["start_time"] = map[string]string{"timestamp": startTs}
|
||||||
body["end_time"] = map[string]string{"timestamp": endTs}
|
body["end_time"] = map[string]string{"timestamp": endTs}
|
||||||
@@ -169,7 +170,7 @@ func parseCalendarAttendeeIDs(attendeesStr string) ([]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
||||||
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
||||||
}
|
}
|
||||||
if _, ok := seen[id]; ok {
|
if _, ok := seen[id]; ok {
|
||||||
continue
|
continue
|
||||||
@@ -195,7 +196,7 @@ func attendeeDeleteIDs(attendeesStr string) ([]map[string]string, error) {
|
|||||||
case strings.HasPrefix(id, "ou_"):
|
case strings.HasPrefix(id, "ou_"):
|
||||||
deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id})
|
deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id})
|
||||||
default:
|
default:
|
||||||
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--remove-attendee-ids")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deleteIDs, nil
|
return deleteIDs, nil
|
||||||
@@ -280,7 +281,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
|
|||||||
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
|
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||||
calendarID, eventID := calendarUpdateIDs(runtime)
|
calendarID, eventID := calendarUpdateIDs(runtime)
|
||||||
if eventID == "" {
|
if eventID == "" {
|
||||||
return output.ErrValidation("specify --event-id")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
||||||
@@ -291,10 +292,9 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
|||||||
completed := []string{}
|
completed := []string{}
|
||||||
event := map[string]interface{}{}
|
event := map[string]interface{}{}
|
||||||
if hasEventFields {
|
if hasEventFields {
|
||||||
data, err := runtime.CallAPI("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
|
data, err := runtime.CallAPITyped("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitAPI, "api_error", "failed to update event %s: %v", eventID, err)
|
return withStepContext(err, "failed to update event %s after completed steps %v", eventID, completed)
|
||||||
}
|
}
|
||||||
if v, _ := data["event"].(map[string]interface{}); v != nil {
|
if v, _ := data["event"].(map[string]interface{}); v != nil {
|
||||||
event = v
|
event = v
|
||||||
@@ -308,12 +308,11 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
|
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
|
||||||
map[string]interface{}{"user_id_type": "open_id"},
|
map[string]interface{}{"user_id_type": "open_id"},
|
||||||
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
|
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitAPI, "api_error", "failed to remove attendees from event %s after completed steps %v: %v", eventID, completed, err)
|
return withStepContext(err, "failed to remove attendees from event %s after completed steps %v", eventID, completed)
|
||||||
}
|
}
|
||||||
removedCount = len(deleteIDs)
|
removedCount = len(deleteIDs)
|
||||||
completed = append(completed, "remove_attendees")
|
completed = append(completed, "remove_attendees")
|
||||||
@@ -323,14 +322,13 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
|||||||
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
|
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
|
||||||
attendees, err := parseAttendees(addStr, "")
|
attendees, err := parseAttendees(addStr, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("invalid attendee id: %v", err)
|
return withParam(err, "--add-attendee-ids")
|
||||||
}
|
}
|
||||||
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID),
|
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID),
|
||||||
map[string]interface{}{"user_id_type": "open_id"},
|
map[string]interface{}{"user_id_type": "open_id"},
|
||||||
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
|
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
|
||||||
err = wrapPredefinedError(err)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees to event %s after completed steps %v: %v", eventID, completed, err)
|
return withStepContext(err, "failed to add attendees to event %s after completed steps %v", eventID, completed)
|
||||||
}
|
}
|
||||||
addedCount = len(attendees)
|
addedCount = len(attendees)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,68 +6,39 @@ package calendar
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// withStepContext annotates err with multi-step context (e.g. which steps
|
||||||
errCodeInvalidParamsWithDetail = 190014
|
// already completed, or that a rollback ran) while preserving the underlying
|
||||||
)
|
// failure's classification. An already-typed error keeps its own
|
||||||
|
// category/subtype/code/log_id; we only append the formatted context to its
|
||||||
// getErrorDetailValue extracts the first detail value from the output.ErrDetail.
|
// Hint so the top-level envelope still tells the truth about what failed.
|
||||||
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
|
// Only an unclassified error falls back to a typed internal wrap.
|
||||||
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
|
func withStepContext(err error, format string, args ...any) error {
|
||||||
// Returns an empty string if the structure doesn't match or the array is empty.
|
|
||||||
//
|
|
||||||
// Deprecated: getErrorDetailValue reads from the legacy *output.ErrDetail
|
|
||||||
// that predates the typed error contract introduced by errs/. New code MUST
|
|
||||||
// NOT use it — typed errs.* errors expose Message, Hint, and extension
|
|
||||||
// fields directly on the typed struct via errors.As / errs.ProblemOf. This
|
|
||||||
// helper is retained only while existing call sites are migrated; it will
|
|
||||||
// be removed once they have moved to the typed surface.
|
|
||||||
func getErrorDetailValue(e *output.ErrDetail) string {
|
|
||||||
if e == nil || e.Detail == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
errMap, ok := e.Detail.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
details, ok := errMap["details"].([]interface{})
|
|
||||||
if !ok || len(details) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
detailObj, ok := details[0].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val, _ := detailObj["value"].(string)
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapPredefinedError wraps an error into *output.ExitError if it matches predefined error codes.
|
|
||||||
// Currently handles error code 190014 (invalid params with detail), extracting the detail value into the message.
|
|
||||||
// If the error is nil or doesn't match predefined codes, returns the original error.
|
|
||||||
func wrapPredefinedError(err error) error {
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
extra := fmt.Sprintf(format, args...)
|
||||||
var exitErr *output.ExitError
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if strings.TrimSpace(p.Hint) != "" {
|
||||||
|
p.Hint = p.Hint + "\n" + extra
|
||||||
|
} else {
|
||||||
|
p.Hint = extra
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(extra).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
if exitErr.Detail.Code == errCodeInvalidParamsWithDetail {
|
// withParam attaches the offending flag to a typed validation error, preserving
|
||||||
if val := getErrorDetailValue(exitErr.Detail); val != "" {
|
// the original error instead of re-wrapping it. Non-validation errors pass through.
|
||||||
fullMsg := fmt.Sprintf("%s: %s", exitErr.Detail.Message, val)
|
func withParam(err error, flag string) error {
|
||||||
return output.ErrAPI(exitErr.Detail.Code, fullMsg, exitErr.Detail.Detail)
|
var ve *errs.ValidationError
|
||||||
}
|
if errors.As(err, &ve) {
|
||||||
|
return ve.WithParam(flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
242
shortcuts/calendar/errors_attribution_test.go
Normal file
242
shortcuts/calendar/errors_attribution_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAttendeeValidateRuntime builds a RuntimeContext with the add/remove
|
||||||
|
// attendee-id flags set, for exercising validateCalendarUpdateAttendees.
|
||||||
|
func newAttendeeValidateRuntime(t *testing.T, add, remove string) *common.RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
cmd.Flags().String("add-attendee-ids", "", "")
|
||||||
|
cmd.Flags().String("remove-attendee-ids", "", "")
|
||||||
|
if err := cmd.ParseFlags(nil); err != nil {
|
||||||
|
t.Fatalf("ParseFlags: %v", err)
|
||||||
|
}
|
||||||
|
if add != "" {
|
||||||
|
_ = cmd.Flags().Set("add-attendee-ids", add)
|
||||||
|
}
|
||||||
|
if remove != "" {
|
||||||
|
_ = cmd.Flags().Set("remove-attendee-ids", remove)
|
||||||
|
}
|
||||||
|
return &common.RuntimeContext{Cmd: cmd}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertValidationParam asserts err is a *errs.ValidationError whose Param
|
||||||
|
// equals wantParam, and returns it for any further message assertions.
|
||||||
|
func assertValidationParam(t *testing.T, err error, wantParam string) *errs.ValidationError {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("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.Param != wantParam {
|
||||||
|
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
|
||||||
|
}
|
||||||
|
return ve
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// withStepContext helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWithStepContext_Nil(t *testing.T) {
|
||||||
|
if got := withStepContext(nil, "step %d", 1); got != nil {
|
||||||
|
t.Fatalf("withStepContext(nil) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithStepContext_AppendsToTypedHint(t *testing.T) {
|
||||||
|
// A typed error keeps its classification; the context is appended to Hint.
|
||||||
|
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
|
||||||
|
got := withStepContext(inner, "after steps %v", []string{"event"})
|
||||||
|
var ae *errs.APIError
|
||||||
|
if !errors.As(got, &ae) {
|
||||||
|
t.Fatalf("want *errs.APIError, got %T", got)
|
||||||
|
}
|
||||||
|
if ae.Hint == "" || !strings.Contains(ae.Hint, "first") || !strings.Contains(ae.Hint, "after steps") {
|
||||||
|
t.Errorf("hint should append context, got %q", ae.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithStepContext_SetsHintWhenEmpty(t *testing.T) {
|
||||||
|
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom")
|
||||||
|
got := withStepContext(inner, "after steps %v", []string{"event"})
|
||||||
|
var ae *errs.APIError
|
||||||
|
if !errors.As(got, &ae) {
|
||||||
|
t.Fatalf("want *errs.APIError, got %T", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ae.Hint, "after steps") {
|
||||||
|
t.Errorf("hint should be set, got %q", ae.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithStepContext_UnclassifiedFallsBackToInternal(t *testing.T) {
|
||||||
|
// A plain, unclassified error is wrapped into a typed internal error so the
|
||||||
|
// envelope still tells the truth.
|
||||||
|
got := withStepContext(errors.New("raw failure"), "after steps %v", []string{"event"})
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(got, &ie) {
|
||||||
|
t.Fatalf("want *errs.InternalError, got %T", got)
|
||||||
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeSDKError {
|
||||||
|
t.Errorf("subtype=%q, want sdk_error", ie.Subtype)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ie.Message, "raw failure") {
|
||||||
|
t.Errorf("message should preserve original, got %q", ie.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// withParam helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWithParam_AttachesToValidationError(t *testing.T) {
|
||||||
|
inner := errs.NewValidationError(errs.SubtypeInvalidArgument, "boom")
|
||||||
|
got := withParam(inner, "--attendee-ids")
|
||||||
|
ve := assertValidationParam(t, got, "--attendee-ids")
|
||||||
|
if ve != inner {
|
||||||
|
t.Errorf("withParam should return the same underlying error, got a different pointer")
|
||||||
|
}
|
||||||
|
if ve.Message != "boom" {
|
||||||
|
t.Errorf("message mutated: got %q, want %q", ve.Message, "boom")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithParam_NonValidationPassesThrough(t *testing.T) {
|
||||||
|
inner := errs.NewInternalError(errs.SubtypeSDKError, "io failure")
|
||||||
|
got := withParam(inner, "--attendee-ids")
|
||||||
|
if got != inner {
|
||||||
|
t.Fatalf("non-validation error should pass through unchanged, got %v", got)
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if errors.As(got, &ve) {
|
||||||
|
t.Fatalf("non-validation error must not become a ValidationError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithParam_NilPassesThrough(t *testing.T) {
|
||||||
|
if got := withParam(nil, "--attendee-ids"); got != nil {
|
||||||
|
t.Fatalf("withParam(nil) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Part A — re-wrap sites: the parseAttendees error, attributed by the caller's
|
||||||
|
// flag, must be the inner typed error (not a re-wrapped nesting).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestParseAttendees_AttributedToCreateFlag(t *testing.T) {
|
||||||
|
_, err := parseAttendees("bad-id", "")
|
||||||
|
// create's add path: withParam(err, "--attendee-ids")
|
||||||
|
got := withParam(err, "--attendee-ids")
|
||||||
|
assertValidationParam(t, got, "--attendee-ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAttendees_AttributedToAddFlag(t *testing.T) {
|
||||||
|
_, err := parseAttendees("bad-id", "")
|
||||||
|
// update's add path: withParam(err, "--add-attendee-ids")
|
||||||
|
got := withParam(err, "--add-attendee-ids")
|
||||||
|
assertValidationParam(t, got, "--add-attendee-ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAttendees_InnerStaysFlagAgnostic(t *testing.T) {
|
||||||
|
// The shared inner parser must not pre-attribute a flag; callers do.
|
||||||
|
_, err := parseAttendees("bad-id", "")
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Param != "" {
|
||||||
|
t.Errorf("inner parseAttendees should stay flag-agnostic, got Param = %q", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Part B — direct attendee-id format validations carry their flag.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestParseRoomFindAttendees_FormatErrorParam(t *testing.T) {
|
||||||
|
_, _, err := parseRoomFindAttendees("bad-id", "")
|
||||||
|
assertValidationParam(t, err, "--"+flagAttendees)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRoomFindAttendees_RejectsRoomID(t *testing.T) {
|
||||||
|
// room find only supports ou_/oc_; omm_ rooms are not valid attendees.
|
||||||
|
_, _, err := parseRoomFindAttendees("omm_room", "")
|
||||||
|
assertValidationParam(t, err, "--"+flagAttendees)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCalendarAttendeeIDs_StaysFlagAgnostic(t *testing.T) {
|
||||||
|
// parseCalendarAttendeeIDs serves BOTH --add-attendee-ids and
|
||||||
|
// --remove-attendee-ids, so it must not pre-attribute a flag.
|
||||||
|
_, err := parseCalendarAttendeeIDs("bad-id")
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Param != "" {
|
||||||
|
t.Errorf("shared parser should stay flag-agnostic, got Param = %q", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCalendarUpdateAttendees_RemoveFormatParam(t *testing.T) {
|
||||||
|
// The remove path attributes its parser error to --remove-attendee-ids.
|
||||||
|
rt := newAttendeeValidateRuntime(t, "", "bad-id")
|
||||||
|
err := validateCalendarUpdateAttendees(rt)
|
||||||
|
assertValidationParam(t, err, "--remove-attendee-ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCalendarUpdateAttendees_AddFormatParam(t *testing.T) {
|
||||||
|
// The add path attributes its parser error to --add-attendee-ids.
|
||||||
|
rt := newAttendeeValidateRuntime(t, "bad-id", "")
|
||||||
|
err := validateCalendarUpdateAttendees(rt)
|
||||||
|
assertValidationParam(t, err, "--add-attendee-ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
// attendeeDeleteIDs's switch default is defensive: parseCalendarAttendeeIDs
|
||||||
|
// already rejects any non-ou_/oc_/omm_ id, so only a well-formed id reaches the
|
||||||
|
// switch and the valid branches map it. This asserts the happy path maps types.
|
||||||
|
func TestAttendeeDeleteIDs_MapsKnownTypes(t *testing.T) {
|
||||||
|
got, err := attendeeDeleteIDs("ou_a,oc_b,omm_c")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("expected 3 delete ids, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
wantTypes := map[string]string{"user": "user_id", "chat": "chat_id", "resource": "room_id"}
|
||||||
|
for _, m := range got {
|
||||||
|
key, ok := wantTypes[m["type"]]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected type %q in %v", m["type"], m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m[key] == "" {
|
||||||
|
t.Errorf("missing %s for type %q in %v", key, m["type"], m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCalendarAttendeeIDs_Valid(t *testing.T) {
|
||||||
|
ids, err := parseCalendarAttendeeIDs(" ou_a , oc_b , ou_a ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(ids) != 2 || ids[0] != "ou_a" || ids[1] != "oc_b" {
|
||||||
|
t.Errorf("dedup/trim failed: got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -198,3 +199,58 @@ func TestCallAPITyped_NonObjectJSON(t *testing.T) {
|
|||||||
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
|
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDoAPIJSONTyped_Success returns the data object on code 0, confirming the
|
||||||
|
// typed DoAPIJSON replacement preserves the success contract of DoAPIJSON.
|
||||||
|
func TestDoAPIJSONTyped_Success(t *testing.T) {
|
||||||
|
rt, reg := newCallAPITypedRuntime(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/x/z",
|
||||||
|
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"id": "z1"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if data["id"] != "z1" {
|
||||||
|
t.Errorf("data[id] = %v, want z1", data["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) {
|
||||||
|
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser)
|
||||||
|
rt.apiClientFunc = func() (*client.APIClient, error) {
|
||||||
|
return nil, errors.New("raw client construction error")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
|
||||||
|
var internalErr *errs.InternalError
|
||||||
|
if !errors.As(err, &internalErr) {
|
||||||
|
t.Fatalf("expected raw client errors to be lifted to typed internal errors, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if internalErr.Subtype != errs.SubtypeUnknown {
|
||||||
|
t.Errorf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed
|
||||||
|
// errs.* error (carrying log_id), never a legacy output.ExitError envelope.
|
||||||
|
func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
|
||||||
|
rt, reg := newCallAPITypedRuntime(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/x/z",
|
||||||
|
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "lz"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := rt.DoAPIJSONTyped("POST", "/open-apis/x/z", nil, map[string]any{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.LogID != "lz" {
|
||||||
|
t.Errorf("LogID = %q, want lz", p.LogID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
|
|||||||
body["with_url"] = true
|
body["with_url"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := runtime.CallAPI(
|
data, err := runtime.CallAPITyped(
|
||||||
"POST",
|
"POST",
|
||||||
"/open-apis/drive/v1/metas/batch_query",
|
"/open-apis/drive/v1/metas/batch_query",
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchDriveMetaTitle() expected error, got 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -492,6 +492,28 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark
|
|||||||
return ctx.doAPIJSON(method, apiPath, query, body, true)
|
return ctx.doAPIJSON(method, apiPath, query, body, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same
|
||||||
|
// larkcore.ApiReq request (identical method / path / query / body model) but
|
||||||
|
// classifies failures into typed errs.* errors via ClassifyAPIResponse instead
|
||||||
|
// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth
|
||||||
|
// error from the client boundary is already typed and passes through unchanged;
|
||||||
|
// a non-zero API code is classified with subtype / code / log_id.
|
||||||
|
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
|
||||||
|
req := &larkcore.ApiReq{
|
||||||
|
HttpMethod: method,
|
||||||
|
ApiPath: apiPath,
|
||||||
|
QueryParams: query,
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Body = body
|
||||||
|
}
|
||||||
|
resp, err := ctx.DoAPI(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, typedOrInternal(err)
|
||||||
|
}
|
||||||
|
return ctx.ClassifyAPIResponse(resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
|
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
|
||||||
req := &larkcore.ApiReq{
|
req := &larkcore.ApiReq{
|
||||||
HttpMethod: method,
|
HttpMethod: method,
|
||||||
@@ -603,27 +625,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapSaveError matches a FileIO.Save error against known categories and wraps
|
|
||||||
// it with the caller-provided message prefix, preserving backward-compatible
|
|
||||||
// error text per shortcut.
|
|
||||||
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var me *fileio.MkdirError
|
|
||||||
var we *fileio.WriteError
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, fileio.ErrPathValidation):
|
|
||||||
return fmt.Errorf("%s: %w", pathMsg, err)
|
|
||||||
case errors.As(err, &me):
|
|
||||||
return fmt.Errorf("%s: %w", mkdirMsg, err)
|
|
||||||
case errors.As(err, &we):
|
|
||||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
||||||
// caller-provided message prefix.
|
// caller-provided message prefix.
|
||||||
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||||
@@ -703,6 +704,9 @@ func WrapSaveErrorTyped(err error) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var me *fileio.MkdirError
|
var me *fileio.MkdirError
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fileio.ErrPathValidation):
|
case errors.Is(err, fileio.ErrPathValidation):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -107,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
|
|||||||
return MutuallyExclusiveTyped(rt, flags...)
|
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].
|
// 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.
|
// 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) {
|
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||||
@@ -177,25 +157,6 @@ func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectDangerousChars returns an error if value contains ASCII control
|
|
||||||
// characters or dangerous Unicode code points.
|
|
||||||
//
|
|
||||||
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
|
|
||||||
func RejectDangerousChars(paramName, value string) error {
|
|
||||||
for _, r := range value {
|
|
||||||
if r < 0x20 && r != '\t' && r != '\n' {
|
|
||||||
return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r)
|
|
||||||
}
|
|
||||||
if r == 0x7F {
|
|
||||||
return fmt.Errorf("parameter %q contains DEL character", paramName)
|
|
||||||
}
|
|
||||||
if IsDangerousUnicode(r) {
|
|
||||||
return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RejectDangerousCharsTyped returns an error if value contains ASCII control
|
// RejectDangerousCharsTyped returns an error if value contains ASCII control
|
||||||
// characters or dangerous Unicode code points.
|
// characters or dangerous Unicode code points.
|
||||||
func RejectDangerousCharsTyped(paramName, value string) error {
|
func RejectDangerousCharsTyped(paramName, value string) error {
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
|
|
||||||
// Also extracts token from URL if provided.
|
|
||||||
//
|
|
||||||
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
|
|
||||||
func ValidateChatID(input string) (string, error) {
|
|
||||||
chatID, msg := normalizeChatID(input)
|
|
||||||
if msg != "" {
|
|
||||||
return "", output.ErrValidation("%s", msg)
|
|
||||||
}
|
|
||||||
return chatID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||||
// Also extracts token from URL if provided. param names the flag being
|
// Also extracts token from URL if provided. param names the flag being
|
||||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||||
|
|||||||
@@ -194,6 +194,21 @@ func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) {
|
||||||
|
typed := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500: chunk failed").
|
||||||
|
WithCode(500)
|
||||||
|
err := WrapSaveErrorTyped(&fileio.WriteError{Err: typed})
|
||||||
|
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != 500 {
|
||||||
|
t.Fatalf("problem = category %q subtype %q code %d, want network/%s/500",
|
||||||
|
p.Category, p.Subtype, p.Code, errs.SubtypeNetworkServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAtLeastOne(t *testing.T) {
|
func TestAtLeastOne(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -5,35 +5,13 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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 {
|
func v1CreateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
|
||||||
{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") != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DocsCreate = common.Shortcut{
|
var DocsCreate = common.Shortcut{
|
||||||
@@ -43,213 +21,25 @@ var DocsCreate = common.Shortcut{
|
|||||||
Risk: "write",
|
Risk: "write",
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Scopes: []string{"docx:document:create"},
|
Scopes: []string{"docx:document:create"},
|
||||||
Tips: docsVersionSelectionTips,
|
PostMount: installDocsShortcutHelp("+create"),
|
||||||
Flags: concatFlags(
|
Flags: concatFlags(
|
||||||
[]common.Flag{
|
[]common.Flag{
|
||||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
docsAPIVersionCompatFlag(),
|
||||||
},
|
},
|
||||||
v1CreateFlags(),
|
|
||||||
v2CreateFlags(),
|
v2CreateFlags(),
|
||||||
|
v1CreateFlags(),
|
||||||
),
|
),
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Create(runtime) {
|
return validateCreateV2(ctx, runtime)
|
||||||
return validateCreateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return validateCreateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
if useV2Create(runtime) {
|
return dryRunCreateV2(ctx, runtime)
|
||||||
return dryRunCreateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return dryRunCreateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Create(runtime) {
|
return executeCreateV2(ctx, runtime)
|
||||||
return executeCreateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return executeCreateV1(ctx, runtime)
|
|
||||||
},
|
|
||||||
PostMount: func(cmd *cobra.Command) {
|
|
||||||
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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.
|
// concatFlags combines multiple flag slices into one.
|
||||||
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
||||||
var out []common.Flag
|
var out []common.Flag
|
||||||
@@ -258,15 +48,3 @@ func concatFlags(slices ...[]common.Flag) []common.Flag {
|
|||||||
}
|
}
|
||||||
return out
|
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{
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
"+create",
|
"+create",
|
||||||
"--api-version", "v2",
|
|
||||||
"--content", "<title>项目计划</title><h1>目标</h1>",
|
"--content", "<title>项目计划</title><h1>目标</h1>",
|
||||||
"--as", "bot",
|
"--as", "bot",
|
||||||
})
|
})
|
||||||
@@ -249,148 +248,63 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── V1 (MCP) tests ──
|
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||||
|
|
||||||
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) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||||
"doc_id": "doxcn_new_doc",
|
"document": map[string]interface{}{
|
||||||
"message": "文档创建成功",
|
"document_id": "doxcn_new_doc",
|
||||||
// "doc_url" deliberately omitted to exercise the fallback.
|
"revision_id": float64(1),
|
||||||
|
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
"+create",
|
"+create",
|
||||||
"--api-version", "v1",
|
"--api-version", "v1",
|
||||||
"--title", "项目计划",
|
"--content", "<title>项目计划</title>",
|
||||||
"--markdown", "## 目标",
|
|
||||||
"--as", "user",
|
"--as", "user",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data := decodeDocsCreateEnvelope(t, stdout)
|
data := decodeDocsCreateEnvelope(t, stdout)
|
||||||
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
|
doc, _ := data["document"].(map[string]interface{})
|
||||||
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
f, stdout, _, _ := 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": "文档创建成功",
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
"+create",
|
"+create",
|
||||||
"--api-version", "v1",
|
|
||||||
"--title", "项目计划",
|
"--title", "项目计划",
|
||||||
"--markdown", "## 目标",
|
"--markdown", "## 目标",
|
||||||
"--as", "user",
|
"--as", "user",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatal("expected legacy v1 flags to be rejected")
|
||||||
}
|
}
|
||||||
|
for _, want := range []string{
|
||||||
data := decodeDocsCreateEnvelope(t, stdout)
|
"docs +create is v2-only",
|
||||||
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
|
"the old v1 interface has been shut down",
|
||||||
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
"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 {
|
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ import (
|
|||||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||||
func v2CreateFlags() []common.Flag {
|
func v2CreateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return []common.Flag{
|
||||||
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
{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 (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
{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 or wiki-node token", Hidden: true},
|
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||||
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
|
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if runtime.Str("content") == "" {
|
if runtime.Str("content") == "" {
|
||||||
return common.FlagErrorf("--content is required")
|
return common.FlagErrorf("--content is required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,40 +5,13 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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 {
|
func v1FetchFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return docsLegacyFlagDefinitions(docsFetchLegacyFlags())
|
||||||
{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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DocsFetch = common.Shortcut{
|
var DocsFetch = common.Shortcut{
|
||||||
@@ -49,88 +22,22 @@ var DocsFetch = common.Shortcut{
|
|||||||
Scopes: []string{"docx:document:readonly"},
|
Scopes: []string{"docx:document:readonly"},
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Tips: docsVersionSelectionTips,
|
PostMount: installDocsShortcutHelp("+fetch"),
|
||||||
Flags: concatFlags(
|
Flags: concatFlags(
|
||||||
[]common.Flag{
|
[]common.Flag{
|
||||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
docsAPIVersionCompatFlag(),
|
||||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
},
|
},
|
||||||
v1FetchFlags(),
|
|
||||||
v2FetchFlags(),
|
v2FetchFlags(),
|
||||||
|
v1FetchFlags(),
|
||||||
),
|
),
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Fetch(runtime) {
|
return validateFetchV2(ctx, runtime)
|
||||||
return validateFetchV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
if useV2Fetch(runtime) {
|
return dryRunFetchV2(ctx, runtime)
|
||||||
return dryRunFetchV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return dryRunFetchV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Fetch(runtime) {
|
return executeFetchV2(ctx, runtime)
|
||||||
return executeFetchV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return executeFetchV1(ctx, runtime)
|
|
||||||
},
|
|
||||||
PostMount: func(cmd *cobra.Command) {
|
|
||||||
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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.
|
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||||
func v2FetchFlags() []common.Flag {
|
func v2FetchFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return []common.Flag{
|
||||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
{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: "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: "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 (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
{Name: "revision-id", Desc: "document revision id; -1 means latest", 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: "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 mode: start (anchor) block id"},
|
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||||
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
{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 mode: sibling blocks before match", Type: "int", Default: "0"},
|
{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 mode: sibling blocks after match", 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; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
{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
|
// --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".
|
// JSON envelope instead of slipping through dry-run as a "success".
|
||||||
func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
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 {
|
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||||
return common.FlagErrorf("invalid --doc: %v", err)
|
return common.FlagErrorf("invalid --doc: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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 {
|
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||||
cmd := &cobra.Command{Use: "+fetch"}
|
cmd := &cobra.Command{Use: "+fetch"}
|
||||||
cmd.Flags().String("doc-format", "xml", "")
|
cmd.Flags().String("doc-format", "xml", "")
|
||||||
@@ -73,6 +150,37 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
|||||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
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 {
|
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||||
cmd := &cobra.Command{Use: "+create"}
|
cmd := &cobra.Command{Use: "+create"}
|
||||||
cmd.Flags().String("doc-format", "xml", "")
|
cmd.Flags().String("doc-format", "xml", "")
|
||||||
|
|||||||
@@ -5,57 +5,13 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validModesV1 = map[string]bool{
|
// v1UpdateFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||||
"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.
|
|
||||||
func v1UpdateFlags() []common.Flag {
|
func v1UpdateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
|
||||||
{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") != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DocsUpdate = common.Shortcut{
|
var DocsUpdate = common.Shortcut{
|
||||||
@@ -65,225 +21,22 @@ var DocsUpdate = common.Shortcut{
|
|||||||
Risk: "write",
|
Risk: "write",
|
||||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Tips: docsVersionSelectionTips,
|
PostMount: installDocsShortcutHelp("+update"),
|
||||||
Flags: concatFlags(
|
Flags: concatFlags(
|
||||||
[]common.Flag{
|
[]common.Flag{
|
||||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
docsAPIVersionCompatFlag(),
|
||||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
},
|
},
|
||||||
v1UpdateFlags(),
|
|
||||||
v2UpdateFlags(),
|
v2UpdateFlags(),
|
||||||
|
v1UpdateFlags(),
|
||||||
),
|
),
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Update(runtime) {
|
return validateUpdateV2(ctx, runtime)
|
||||||
return validateUpdateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return validateUpdateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
if useV2Update(runtime) {
|
return dryRunUpdateV2(ctx, runtime)
|
||||||
return dryRunUpdateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return dryRunUpdateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Update(runtime) {
|
return executeUpdateV2(ctx, runtime)
|
||||||
return executeUpdateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return executeUpdateV1(ctx, runtime)
|
|
||||||
},
|
|
||||||
PostMount: func(cmd *cobra.Command) {
|
|
||||||
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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
|
package doc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── V2 tests ──
|
// ── 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) {
|
runtime := newUpdateShortcutTestRuntime(t, apiVersion, nil)
|
||||||
t.Parallel()
|
if err := validateUpdateV2(context.Background(), runtime); err != nil {
|
||||||
|
t.Fatalf("validateUpdateV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
msg := selectionRequiredMessageV1("replace_all")
|
dry := decodeDocDryRun(t, DocsUpdate.DryRun(context.Background(), runtime))
|
||||||
for _, needle := range []string{
|
if len(dry.API) != 1 {
|
||||||
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
|
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||||
"replace the entire document body",
|
}
|
||||||
"--mode overwrite",
|
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 !strings.Contains(msg, needle) {
|
}
|
||||||
t.Fatalf("message missing %q: %s", needle, msg)
|
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) {
|
func TestDocsUpdateRejectsLegacyFlags(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()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
markdown string
|
setFlags map[string]string
|
||||||
wantWarn bool
|
want []string
|
||||||
wantSubs []string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty markdown is clean",
|
name: "legacy mode",
|
||||||
markdown: "",
|
setFlags: map[string]string{"mode": "overwrite"},
|
||||||
wantWarn: false,
|
want: []string{
|
||||||
},
|
"docs +update is v2-only",
|
||||||
{
|
"the old v1 interface has been shut down",
|
||||||
name: "plain prose is clean",
|
"legacy v1 flag(s) --mode are no longer supported",
|
||||||
markdown: "## Heading\n\nsome text",
|
"--mode -> use --command",
|
||||||
wantWarn: false,
|
"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",
|
||||||
name: "single whiteboard triggers warning",
|
"follow the latest format rules",
|
||||||
markdown: `<whiteboard token="abc123"/>`,
|
"MUST NOT grep/open local SKILL.md files",
|
||||||
wantWarn: true,
|
"lark-cli docs +update --help",
|
||||||
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"},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got := checkOverwriteResourceBlocks(tt.markdown)
|
|
||||||
if (got != "") != tt.wantWarn {
|
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
|
||||||
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
|
err := validateUpdateV2(context.Background(), runtime)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected v2-only validation error")
|
||||||
}
|
}
|
||||||
for _, sub := range tt.wantSubs {
|
for _, want := range tt.want {
|
||||||
if !strings.Contains(got, sub) {
|
if !strings.Contains(err.Error(), want) {
|
||||||
t.Errorf("expected warning to contain %q, got: %s", sub, got)
|
t.Fatalf("error missing %q: %v", want, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
t.Helper()
|
||||||
result := map[string]interface{}{
|
|
||||||
"success": true,
|
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 {
|
for name, value := range setFlags {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
if err := cmd.Flags().Set(name, value); err != nil {
|
||||||
t.Parallel()
|
t.Fatalf("set %s: %v", name, err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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.
|
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||||
func v2UpdateFlags() []common.Flag {
|
func v2UpdateFlags() []common.Flag {
|
||||||
return []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: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
{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: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
{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: "regex pattern for str_replace", Hidden: true},
|
{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 for block_* operations", Hidden: true},
|
{Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"},
|
||||||
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
|
{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 (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
{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 {
|
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 {
|
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||||
return common.FlagErrorf("invalid --doc: %v", err)
|
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/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
const docsServiceHelpDefault = `Document and content operations.`
|
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{
|
func docsSkillReadCommandForShortcut(shortcut string) 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.",
|
switch strings.TrimPrefix(shortcut, "+") {
|
||||||
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
|
case "create":
|
||||||
}
|
return docsSkillReadCommand + " references/lark-doc-create.md"
|
||||||
|
case "fetch":
|
||||||
var docsV2VersionSelectionTips = []string{
|
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||||
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
case "update":
|
||||||
}
|
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||||
|
default:
|
||||||
func docsTipsForVersion(apiVersion string) []string {
|
return docsSkillReadCommand
|
||||||
if apiVersion == "v2" {
|
}
|
||||||
return docsV2VersionSelectionTips
|
}
|
||||||
|
|
||||||
|
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.
|
// Shortcuts returns all docs shortcuts.
|
||||||
@@ -48,45 +64,32 @@ func Shortcuts() []common.Shortcut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
// 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) {
|
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serviceCmd := cmd
|
cmd.Long = docsHelpLong(docsServiceHelpDefault, docsSkillReadCommand)
|
||||||
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)")
|
func installDocsShortcutHelp(command string) func(*cobra.Command) {
|
||||||
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command) {
|
||||||
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
|
cmd.Long = docsHelpLong(cmd.Short, docsSkillReadCommandForShortcut(command))
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultHelp := cmd.HelpFunc()
|
func docsHelpLong(summary, skillReadCommand string) string {
|
||||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
return strings.TrimSpace(fmt.Sprintf(`%s
|
||||||
if cmd != serviceCmd {
|
|
||||||
defaultHelp(cmd, args)
|
Start here (required for AI agents):
|
||||||
return
|
%s
|
||||||
}
|
|
||||||
|
AI agents MUST read the matching embedded skill before choosing flags
|
||||||
apiVersion, _ := cmd.Flags().GetString("api-version")
|
or running docs commands. Do not skip this step, and do not infer
|
||||||
previousLong := cmd.Long
|
workflows from --help alone. MUST NOT grep/open local SKILL.md files
|
||||||
if apiVersion == "v2" {
|
to discover this guidance; use %s so content stays version-matched
|
||||||
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
|
with this CLI. Skills ship with the CLI and include docs workflows,
|
||||||
} else {
|
selector/block-id usage, XML/Markdown formats, and copy-paste examples.
|
||||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
|
||||||
}
|
skills read lark-doc Docs workflow guide
|
||||||
defer func() {
|
skills read lark-doc <path> Read a referenced docs skill file`, strings.TrimSpace(summary), skillReadCommand, skillReadCommand))
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user