mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
70 Commits
feat/slide
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412dcba4b8 | ||
|
|
e5f66ce22e | ||
|
|
1d313a56b1 | ||
|
|
1864b7fae9 | ||
|
|
22ae7ab04d | ||
|
|
110107458a | ||
|
|
e28a00c2fe | ||
|
|
2f50e39203 | ||
|
|
b5d3e9896e | ||
|
|
a552aed3bc | ||
|
|
70aec2726b | ||
|
|
52894d095b | ||
|
|
7810a01eba | ||
|
|
b33fe32718 | ||
|
|
4229ea7735 | ||
|
|
72c61cc59e | ||
|
|
33458e6770 | ||
|
|
35446837a2 | ||
|
|
9fa28be312 | ||
|
|
bca7f7d30d | ||
|
|
6764949014 | ||
|
|
eb3ace1427 | ||
|
|
8f0d0725fc | ||
|
|
7121ff1e2a | ||
|
|
431160a204 | ||
|
|
490006ee7b | ||
|
|
4e2abab504 | ||
|
|
3e430dd821 | ||
|
|
9efa8b3b69 | ||
|
|
81c3736da2 | ||
|
|
6cbb9d68b8 | ||
|
|
0ff2957c6e | ||
|
|
f334cc9b34 | ||
|
|
41aefd63f0 | ||
|
|
09984fa92a | ||
|
|
de5de57ced | ||
|
|
d2452b7f9c | ||
|
|
911f584ab0 | ||
|
|
08340bf3aa | ||
|
|
a99dc33195 | ||
|
|
0552c5c595 | ||
|
|
bb891e0c50 | ||
|
|
d5f65d1aa4 | ||
|
|
5365cb97ab | ||
|
|
8037bd8037 | ||
|
|
0f88409ab8 | ||
|
|
2cfe090c1d | ||
|
|
dbc1c93b71 | ||
|
|
6ff02ea10c | ||
|
|
46c99cb878 | ||
|
|
8939bff9c5 | ||
|
|
736db1ce72 | ||
|
|
2beb110523 | ||
|
|
112183f447 | ||
|
|
9b9ac8759e | ||
|
|
fdcd9f6dde | ||
|
|
e9fde3e8f7 | ||
|
|
3b9ee1af67 | ||
|
|
8d061ea3bd | ||
|
|
a5386f6053 | ||
|
|
d6c37232e6 | ||
|
|
999ac4e7d6 | ||
|
|
a91f2cdd85 | ||
|
|
d80636d7da | ||
|
|
0a999171be | ||
|
|
1d9f102b36 | ||
|
|
d7820f7c1f | ||
|
|
b8f45c96d7 | ||
|
|
9dc032ca73 | ||
|
|
e9f2da086f |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,40 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
|
||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||
- **event**: Support VC meeting lifecycle events (#1632)
|
||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||
|
||||
### Tests
|
||||
|
||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Reduce public content false positives
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
@@ -1299,8 +1265,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Affordance
|
||||
|
||||
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
||||
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
||||
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
||||
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
|
||||
## Format
|
||||
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
Fetch the full content of a single message by id.
|
||||
|
||||
### Avoid when
|
||||
- Reading several at once → use [[messages batch_get]]
|
||||
|
||||
### Prerequisites
|
||||
- message_id from [[messages list]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Fetch one message**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
||||
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
@@ -1,19 +0,0 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
### Avoid when
|
||||
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
||||
|
||||
### Tips
|
||||
- Off by default — set include_personal_status / include_description to true under query_option
|
||||
- ids in user_ids must match --user-id-type (default open_id)
|
||||
|
||||
### Examples
|
||||
|
||||
**Bulk-query status and signature**
|
||||
```bash
|
||||
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
||||
```
|
||||
@@ -67,21 +67,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
||||
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
||||
|
||||
Prefer the typed domain command when one exists — it validates parameters,
|
||||
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
||||
guidance that this raw command does not. If a domain command covers your task
|
||||
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
||||
|
||||
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
||||
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
||||
|
||||
Examples:
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -171,10 +170,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
// Root-only usage template (curated Usage synopsis + skills footer); see
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
@@ -195,7 +190,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -211,8 +205,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
|
||||
@@ -10,22 +10,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -39,8 +27,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -71,15 +57,9 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if key, ok := row["key"].(string); ok {
|
||||
gotKeys[key] = row
|
||||
}
|
||||
}
|
||||
var foundTask bool
|
||||
for key, row := range gotKeys {
|
||||
if key == "task.task.update_user_access_v2" {
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
@@ -89,12 +69,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := gotKeys[want]; !ok {
|
||||
t.Errorf("JSON list output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,45 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["key"] != key {
|
||||
t.Errorf("key = %v, want %s", payload["key"], key)
|
||||
}
|
||||
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
||||
}
|
||||
properties, ok := resolved["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
||||
}
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["end_time"]; ok {
|
||||
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
127
cmd/root.go
127
cmd/root.go
@@ -11,11 +11,9 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
@@ -30,60 +28,43 @@ import (
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>
|
||||
|
||||
EXAMPLES (one per command style, in order of preference):
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
lark-cli calendar +agenda
|
||||
|
||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||
// cobra so the command groups and flags are untouched.
|
||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||
# List calendar events
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli -g -y
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
Issues: https://github.com/larksuite/cli/issues
|
||||
Docs: https://open.feishu.cn/document/
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
`
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
@@ -548,49 +529,6 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// Root command help groups, so an agent sees content domains, agent tooling, and
|
||||
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
||||
const (
|
||||
groupDomains = "lark-domains"
|
||||
groupTooling = "agent-tooling"
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// groupRootCommands classifies root's direct children into the help groups,
|
||||
// called once after all commands are registered. Unclassified commands fall to
|
||||
// cobra's "Additional Commands" section.
|
||||
func groupRootCommands(root *cobra.Command) {
|
||||
root.AddGroup(
|
||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
if c.GroupID != "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case tooling[c.Name()]:
|
||||
c.GroupID = groupTooling
|
||||
case management[c.Name()]:
|
||||
c.GroupID = groupManagement
|
||||
case isLarkDomain(c):
|
||||
c.GroupID = groupDomains
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||
func isLarkDomain(c *cobra.Command) bool {
|
||||
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
||||
return true
|
||||
}
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||
@@ -672,17 +610,6 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
}
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
|
||||
@@ -76,13 +76,11 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
}
|
||||
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,211 +4,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
|
||||
// skill pointer) to a top-level Lark domain's description, returning false for
|
||||
// anything that is not such a domain. Built lazily at help time because
|
||||
// shortcuts attach after service registration. skillFS (nil-safe) gates the
|
||||
// skill pointer.
|
||||
//
|
||||
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
|
||||
// consume <EventKey>'…"); service domains carry only a Short at this point, so
|
||||
// we fall back to it. The pristine base is captured once into an annotation so
|
||||
// re-rendering does not append the guidance twice.
|
||||
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if cmd.Annotations[schemaPathAnnotation] != "" {
|
||||
return false // a method command
|
||||
}
|
||||
// Direct child of root only — so Domain() reads this command's own tag, and
|
||||
// nested resource groups are excluded.
|
||||
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
|
||||
return false
|
||||
}
|
||||
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
|
||||
return false
|
||||
}
|
||||
if !cmd.HasAvailableSubCommands() {
|
||||
return false
|
||||
}
|
||||
|
||||
hasShortcuts, hasResources := false, false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(c.Name(), "+") {
|
||||
hasShortcuts = true
|
||||
} else {
|
||||
hasResources = true
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(domainHelpBase(cmd))
|
||||
if hasShortcuts && hasResources { // routing only matters when both styles exist
|
||||
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
||||
}
|
||||
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
||||
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
||||
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// domainHelpBase returns the description to seed domain help with — the
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
if base == "" {
|
||||
base = cmd.Short
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
return base
|
||||
}
|
||||
|
||||
// methodLong is the build-time Long (description + schema pointer +
|
||||
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
|
||||
// so command construction never parses the overlay.
|
||||
func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
// methodLong composes a method command's long help in one place: the
|
||||
// description, the affordance guidance block (when the method has one), the
|
||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||
// the flag list.
|
||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
}
|
||||
schemaPath, ok := ann[schemaPathAnnotation]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
|
||||
// it carries none.
|
||||
func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordance(meta.Method{Affordance: raw})
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block, or "" when it
|
||||
// has none. Sections are joined with blank lines so they scan as distinct groups.
|
||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||
// the method carries no affordance. It reads the single typed model
|
||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sections []string
|
||||
var b strings.Builder
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
@@ -219,18 +49,15 @@ func renderAffordance(m meta.Method) string {
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
var s strings.Builder
|
||||
fmt.Fprintf(&s, "%s:\n", title)
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&s, " • %s\n", it)
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
}
|
||||
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.AvoidWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
bullets("Tips", a.Tips)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
@@ -244,13 +71,10 @@ func renderAffordance(m meta.Method) string {
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
for _, ext := range a.Extensions {
|
||||
bullets(ext.Label, ext.Items)
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
@@ -8,18 +8,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"avoid_when": ["群已解散"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"tips": ["富文本用 msg_type=post"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
@@ -32,7 +29,6 @@ func TestRenderAffordance(t *testing.T) {
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Tips:", "富文本用 msg_type=post",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
@@ -52,12 +48,9 @@ func TestRenderAffordance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Affordance is rendered lazily (at --help time) rather than baked into the
|
||||
// command's Long, so building a command never carries the affordance block —
|
||||
// even for a method whose metadata happens to declare one.
|
||||
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
@@ -66,120 +59,14 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if strings.Contains(cmd.Long, "Examples:") {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||
}
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
|
||||
// lookup and renders it; commands without a ref render nothing.
|
||||
func TestRenderAffordanceForCmd(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service != "im" || methodID != "messages.create" {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
|
||||
block := RenderAffordanceForCmd(cmd)
|
||||
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
|
||||
// No overlay for this method id -> empty block.
|
||||
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
|
||||
if got := RenderAffordanceForCmd(cmd2); got != "" {
|
||||
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp composes the guidance into Long at the top: description,
|
||||
// then the affordance block, then the full-schema pointer — so an agent reads
|
||||
// when-to-use/examples before the flag list.
|
||||
func TestPrepareMethodHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
// Description leads; affordance block sits above the schema pointer.
|
||||
descAt := strings.Index(long, "发送消息")
|
||||
useAt := strings.Index(long, "When to use:")
|
||||
exAt := strings.Index(long, "Examples:")
|
||||
schemaAt := strings.Index(long, "Full parameter schema:")
|
||||
if descAt != 0 {
|
||||
t.Errorf("description should lead Long, got:\n%s", long)
|
||||
}
|
||||
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
|
||||
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "event", Short: short, Long: long}
|
||||
cmdmeta.SetDomain(dom, "event")
|
||||
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
return dom
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
|
||||
dom := domainCmd("Consume and manage real-time events", long)
|
||||
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, long) {
|
||||
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
|
||||
}
|
||||
if !strings.Contains(dom.Long, "Risk levels") {
|
||||
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
|
||||
}
|
||||
|
||||
// Re-rendering must not append the guidance a second time.
|
||||
PrepareDomainHelp(dom, nil)
|
||||
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
|
||||
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
|
||||
// A method with no affordance adds no guidance block.
|
||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||
if strings.Contains(cmd2.Long, "Examples:") {
|
||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,8 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
// The redundant "<name>, required|optional." prefix is gone: required-ness is
|
||||
// carried by the Required:/Optional: subheadings, and the snake-case --params
|
||||
// key by the schema envelope — so it isn't echoed on every flag line.
|
||||
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
|
||||
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
|
||||
@@ -30,11 +30,6 @@ func fieldFacts(f meta.Field) []string {
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if f.CanonicalType() == "boolean" {
|
||||
// cobra shows no type word for bools and swallows a separate value as a
|
||||
// positional, so spell out the presence-only contract.
|
||||
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
@@ -47,15 +42,20 @@ func fieldFacts(f meta.Field) []string {
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line: the field's facts
|
||||
// joined inline. Required/optional is not repeated here — the grouped help's
|
||||
// Required:/Optional: subheadings already partition the flags — and the
|
||||
// snake-case --params key is carried by the schema envelope (each param's
|
||||
// property + "flag") and the params-only addendum, so it isn't echoed on every
|
||||
// line either. Returns "" when the field has no facts (cobra then shows the bare
|
||||
// flag with its type).
|
||||
// paramFlagUsage renders the typed param flag's help line:
|
||||
//
|
||||
// <param_name>, required|optional[. <fact>]...
|
||||
//
|
||||
// It leads with the canonical underscore param name (the key this flag
|
||||
// overrides in --params) and required/optional, then joins the field's facts
|
||||
// inline.
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
return strings.Join(fieldFacts(f), ". ")
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
@@ -103,23 +103,8 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
|
||||
// cross-reference is dropped first (see cutDocRef).
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) }
|
||||
|
||||
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
|
||||
// On the compact flag line the markdown link's URL is stripped, so the
|
||||
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
|
||||
// so a subject that runs straight into the phrase isn't orphaned.
|
||||
var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
|
||||
|
||||
// cutDocRef truncates s at the first doc-reference breadcrumb.
|
||||
func cutDocRef(s string) string {
|
||||
if loc := docRefRe.FindStringIndex(s); loc != nil {
|
||||
return s[:loc[0]]
|
||||
}
|
||||
return s
|
||||
}
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -65,38 +64,15 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
refs := apicatalog.ServiceMethods(svc, nil)
|
||||
|
||||
// Collect each resource's verbs up front so resourceShort can summarize a
|
||||
// resource as its verb list from the first ensureChildCommand call.
|
||||
verbs := map[string][]string{}
|
||||
for _, ref := range refs {
|
||||
key := strings.Join(ref.ResourcePath, ".")
|
||||
verbs[key] = append(verbs[key], ref.Method.Name)
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
resCmd := svcCmd
|
||||
var path []string
|
||||
for _, seg := range ref.ResourcePath {
|
||||
path = append(path, seg)
|
||||
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// resourceShort summarizes a resource as its sorted verb list, or the
|
||||
// "<name> operations" placeholder for an intermediate group with no methods.
|
||||
func resourceShort(seg string, verbs []string) string {
|
||||
if len(verbs) == 0 {
|
||||
return seg + " operations"
|
||||
}
|
||||
sorted := append([]string(nil), verbs...)
|
||||
sort.Strings(sorted)
|
||||
return strings.Join(sorted, ", ")
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
@@ -201,19 +177,7 @@ type methodCommandSpec struct {
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
paginates bool // method accepts a page_token param (so --page-all is meaningful)
|
||||
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
|
||||
}
|
||||
|
||||
// methodPaginates reports whether a method takes a page_token param, the signal
|
||||
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
|
||||
func methodPaginates(m meta.Method) bool {
|
||||
for _, f := range m.Params() {
|
||||
if f.Name == "page_token" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
@@ -222,7 +186,6 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
serviceName: ref.Service.Name,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
@@ -230,7 +193,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
paginates: methodPaginates(m),
|
||||
affordance: renderAffordance(m),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,14 +254,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
// Keep the pagination flags registered (a harmless no-op if passed) but hide
|
||||
// them from help on non-paginating commands, so help doesn't imply a
|
||||
// get/write can paginate.
|
||||
if !spec.paginates {
|
||||
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
@@ -316,11 +271,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
|
||||
// (setMethodHelpData records the coordinates it needs).
|
||||
paramsOnly := opts.binder.paramsOnlyHelp()
|
||||
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
|
||||
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
|
||||
// Single composition point for Long: description, affordance, schema
|
||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||
// taken, reachable via --params only).
|
||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
@@ -338,11 +292,13 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// Keep the precedence rule on the flag's own one line (not a multi-line
|
||||
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
|
||||
// meaningful when typed flags exist to override.
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
if len(spec.params) > 0 {
|
||||
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Identity string `json:"identity"`
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. It is local-only:
|
||||
// no network calls are made.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Reject an explicit --as that does not resolve to a usable identity, so a
|
||||
// typo like `--as admin` fails clearly instead of echoing back a bogus
|
||||
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
|
||||
// matches how api/service/shortcut commands validate the resolved identity.
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
source := resolveSource(
|
||||
cmd.Flags().Changed("as"),
|
||||
flagAs,
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
formatPretty(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSource derives how the effective identity became effective.
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto-detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict-mode"
|
||||
}
|
||||
return "default-as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
res.OpenID = diag.User.OpenID
|
||||
res.UserName = diag.User.UserName
|
||||
res.TokenStatus = diag.User.TokenStatus
|
||||
if res.TokenStatus == "" {
|
||||
res.TokenStatus = "missing"
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = "No usable user token. Run `lark-cli auth login`."
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// formatPretty writes the human-readable one-glance summary.
|
||||
func formatPretty(w io.Writer, r *whoamiResult) {
|
||||
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
|
||||
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
|
||||
if r.Identity == string(core.AsUser) && r.UserName != "" {
|
||||
if r.OpenID != "" {
|
||||
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
|
||||
} else {
|
||||
fmt.Fprintf(w, "User: %s\n", r.UserName)
|
||||
}
|
||||
}
|
||||
token := r.TokenStatus
|
||||
if !r.Available && r.Hint != "" {
|
||||
token = r.TokenStatus + " — " + r.Hint
|
||||
}
|
||||
// Write the label and value as separate %s args rather than one combined
|
||||
// literal. A single label-colon-value literal trips the public-content
|
||||
// credential scanner as a false-positive credential assignment; splitting
|
||||
// the args avoids it while producing identical output.
|
||||
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changedAs bool
|
||||
flagAs core.Identity
|
||||
autoDetected bool
|
||||
strictForced core.Identity
|
||||
want string
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
|
||||
{"auto detected", false, "", true, "", "auto-detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
|
||||
{"default-as", false, "", false, "", "default-as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "valid" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "ou_x" || r.UserName != "Alice" {
|
||||
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||
t.Fatalf("app context = %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default-as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default-as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "" || r.UserName != "" {
|
||||
t.Fatalf("bot must not carry openId/userName: %#v", r)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_User(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"Profile: my-app (cli_x, lark)",
|
||||
"Identity: user (auto-detect)",
|
||||
"User: Alice (ou_x)",
|
||||
"Token: valid",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_BotNoUserLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
Identity: "bot", IdentitySource: "default-as",
|
||||
Available: true, TokenStatus: "ready",
|
||||
})
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "User:") {
|
||||
t.Errorf("bot output must not contain User: line\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
|
||||
t.Errorf("unexpected bot output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: false, TokenStatus: "missing",
|
||||
Hint: "No usable user token. Run `lark-cli auth login`.",
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Token: missing — No usable user token.") {
|
||||
t.Errorf("expected token line with hint, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_BotJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||
}
|
||||
if got.Profile != "test-profile" {
|
||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||
}
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OpenID != "" {
|
||||
t.Fatalf("bot must not carry openId: %q", got.OpenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||
t.Run("as="+bad, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", bad})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||
}
|
||||
// Lock in the typed validation contract: an unsupported identity must
|
||||
// surface as a *errs.ValidationError on --as, not just any error.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||
}
|
||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||
// command-execution error.
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
)
|
||||
|
||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
|
||||
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
|
||||
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
|
||||
// a new content type is omitted until added to the embed list. The embed must live
|
||||
// in this root package because go:embed cannot reach up out of a package's dir.
|
||||
//
|
||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
|
||||
var embeddedContentFS embed.FS
|
||||
|
||||
// init wires the embedded content into the CLI. It compiles into `go build .` but
|
||||
// not the single-file preview build (`go build ./main.go`), so that build stays
|
||||
// self-contained (shipping no embedded content). Assembly failures warn on stderr
|
||||
// rather than panicking — embedded content is nice-to-have, not load-bearing.
|
||||
func init() {
|
||||
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
||||
} else {
|
||||
cmd.SetEmbeddedSkillContent(sub)
|
||||
}
|
||||
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
||||
} else {
|
||||
affordance.SetSource(sub)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
|
||||
type VCParticipantMeetingJoinedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingJoinedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
schemaType reflect.Type
|
||||
}{
|
||||
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeMeetingStarted,
|
||||
process: processVCParticipantMeetingStarted,
|
||||
},
|
||||
{
|
||||
name: "joined",
|
||||
eventType: eventTypeMeetingJoined,
|
||||
process: processVCParticipantMeetingJoined,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_lifecycle_001",
|
||||
"event_type": "` + tc.eventType + `",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
|
||||
|
||||
if out["type"] != tc.eventType {
|
||||
t.Errorf("type = %q", out["type"])
|
||||
}
|
||||
if out["event_id"] != "ev_vc_lifecycle_001" {
|
||||
t.Errorf("event_id = %q", out["event_id"])
|
||||
}
|
||||
if out["timestamp"] != "1608725989000" {
|
||||
t.Errorf("timestamp = %q", out["timestamp"])
|
||||
}
|
||||
if out["meeting_id"] != "6911188411934433028" {
|
||||
t.Errorf("meeting_id = %q", out["meeting_id"])
|
||||
}
|
||||
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
|
||||
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
|
||||
}
|
||||
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
|
||||
t.Errorf("start_time = %q, want %q", out["start_time"], want)
|
||||
}
|
||||
if _, hasEndTime := out["end_time"]; hasEndTime {
|
||||
t.Error("end_time should not be present in started/joined output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_lifecycle_002",
|
||||
"event_type": "` + tc.eventType + `",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
|
||||
switch tc.eventType {
|
||||
case eventTypeMeetingStarted:
|
||||
var started VCParticipantMeetingStartedOutput
|
||||
if err := json.Unmarshal(out, &started); err != nil {
|
||||
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
|
||||
}
|
||||
if started.StartTime != "" {
|
||||
t.Errorf("StartTime = %q, want empty string", started.StartTime)
|
||||
}
|
||||
case eventTypeMeetingJoined:
|
||||
var joined VCParticipantMeetingJoinedOutput
|
||||
if err := json.Unmarshal(out, &joined); err != nil {
|
||||
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
|
||||
}
|
||||
if joined.StartTime != "" {
|
||||
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
|
||||
t.Helper()
|
||||
got := runMeetingLifecycleRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
|
||||
type VCParticipantMeetingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingStartedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
|
||||
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
@@ -32,38 +30,6 @@ const (
|
||||
// Keys returns all VC-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingStarted,
|
||||
DisplayName: "Participant meeting started",
|
||||
Description: "Triggered when a meeting the current user participates in has started",
|
||||
EventType: eventTypeMeetingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeMeetingJoined,
|
||||
DisplayName: "Participant meeting joined",
|
||||
Description: "Triggered when the current user joins a meeting",
|
||||
EventType: eventTypeMeetingJoined,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingJoined,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
|
||||
},
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package affordance is the lazily-loaded store of usage guidance for
|
||||
// service-API methods. The source of truth is one markdown file per service in
|
||||
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
|
||||
// domain owners maintain it next to skills/ and shortcuts/. A service is read
|
||||
// and parsed at most once, on first access, so normal command execution never
|
||||
// touches it.
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
byService = map[string]map[string]json.RawMessage{}
|
||||
tried = map[string]bool{}
|
||||
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
|
||||
)
|
||||
|
||||
// SetSource installs the markdown guidance tree (the top-level affordance/
|
||||
// directory) as the source. Called once at startup before any lookup; clears
|
||||
// the parse cache so re-sourcing (e.g. in tests) takes effect.
|
||||
func SetSource(fsys fs.FS) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
mdSource = fsys
|
||||
byService = map[string]map[string]json.RawMessage{}
|
||||
tried = map[string]bool{}
|
||||
}
|
||||
|
||||
// For returns the raw affordance overlay for one method, loading the owning
|
||||
// service on first access. ok is false when there is no entry (absent source,
|
||||
// parse failure, or unknown method all collapse to "no guidance").
|
||||
func For(service, methodID string) (json.RawMessage, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !tried[service] {
|
||||
tried[service] = true
|
||||
byService[service] = loadService(service)
|
||||
}
|
||||
raw, ok := byService[service][methodID]
|
||||
return raw, ok && len(raw) > 0
|
||||
}
|
||||
|
||||
// loadService parses a service's markdown guidance into per-method overlays,
|
||||
// marshalling each to JSON so downstream callers keep the same wire shape.
|
||||
func loadService(service string) map[string]json.RawMessage {
|
||||
if mdSource == nil {
|
||||
return nil
|
||||
}
|
||||
src, err := fs.ReadFile(mdSource, service+".md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
|
||||
if b, err := json.Marshal(a); err == nil {
|
||||
m[id] = b
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
|
||||
// list") to its method id ("user_mailbox.message.list") via the registry's
|
||||
// authoritative resource↔id table. Resource names are irregularly pluralised
|
||||
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
|
||||
// space→dot fallback covers domains where the two already coincide.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
return id
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||
// paragraph (use_when) and a fenced example.
|
||||
const fixtureMD = "# approval\n" +
|
||||
"> skill: lark-approval\n\n" +
|
||||
"## instances cc\n" +
|
||||
"把一个审批实例抄送给指定用户。\n\n" +
|
||||
"### Examples\n\n" +
|
||||
"**抄送给用户**\n" +
|
||||
"```bash\n" +
|
||||
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
|
||||
"```\n\n" +
|
||||
"## instances get\n" +
|
||||
"查询某审批实例详情。\n\n" +
|
||||
"### Examples\n\n" +
|
||||
"**按 code 查询**\n" +
|
||||
"```bash\n" +
|
||||
"lark-cli approval instances get --instance-code \"x\"\n" +
|
||||
"```\n"
|
||||
|
||||
func TestFor(t *testing.T) {
|
||||
prev := mdSource
|
||||
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
|
||||
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
|
||||
|
||||
// A seeded method in a seeded service resolves to its overlay.
|
||||
raw, ok := For("approval", "instances.cc")
|
||||
if !ok {
|
||||
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
|
||||
}
|
||||
var a struct {
|
||||
UseWhen []string `json:"use_when"`
|
||||
Examples []struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"examples"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("overlay is not valid affordance JSON: %v", err)
|
||||
}
|
||||
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
|
||||
t.Errorf("overlay missing use_when/examples: %s", raw)
|
||||
}
|
||||
|
||||
// Misses: unknown method in a known service, and an unknown service, both
|
||||
// resolve to ok=false (no panic, no error) so callers treat them as "no
|
||||
// guidance".
|
||||
if _, ok := For("approval", "instances.no_such_method"); ok {
|
||||
t.Error("unknown method should be ok=false")
|
||||
}
|
||||
if _, ok := For("no_such_service", "x.y"); ok {
|
||||
t.Error("unknown service should be ok=false")
|
||||
}
|
||||
|
||||
// A second lookup of the same service is served from cache (parsed at most
|
||||
// once) and stays consistent.
|
||||
if _, ok := For("approval", "instances.get"); !ok {
|
||||
t.Error("second lookup in a cached service should still resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// Non-bullet paragraph lines under any section are preserved as items, not
|
||||
// dropped (regression: they previously only updated pending, lost without a fence).
|
||||
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
||||
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
|
||||
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
|
||||
a, ok := got["foo.bar"]
|
||||
if !ok {
|
||||
t.Fatal("method not parsed")
|
||||
}
|
||||
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
|
||||
t.Errorf("Tips paragraph dropped: %v", a.Tips)
|
||||
}
|
||||
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
|
||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
|
||||
//
|
||||
// # domain optional `> skill: <name>` applied to every method
|
||||
// ## command e.g. `instances get`
|
||||
// <lead paragraph> -> use_when (when this command is right)
|
||||
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
|
||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||
// ### Tips -> tips
|
||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||
//
|
||||
// Parsing is lazy and cached (see For), so the constrained grammar is read at
|
||||
// most once per domain.
|
||||
|
||||
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
||||
|
||||
// standardSection maps a section heading to its typed Affordance field; any
|
||||
// other heading becomes an extension.
|
||||
var standardSection = map[string]string{
|
||||
"Avoid when": "avoid_when",
|
||||
"Prerequisites": "prerequisites",
|
||||
"Tips": "tips",
|
||||
"Examples": "examples",
|
||||
}
|
||||
|
||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||
|
||||
// headingToKey maps a command heading ("instances get") to its affordance key
|
||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||
func headingToKey(h string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
label string
|
||||
items []string
|
||||
cases []meta.AffordanceCase
|
||||
}
|
||||
|
||||
// parseDomainMD parses one domain's markdown into per-method Affordance values,
|
||||
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
|
||||
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
|
||||
// space→dot rule (valid only where the command form already equals the id).
|
||||
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
|
||||
if resolve == nil {
|
||||
resolve = headingToKey
|
||||
}
|
||||
out := map[string]meta.Affordance{}
|
||||
|
||||
var skill, curKey string
|
||||
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
|
||||
var secs []*mdSection
|
||||
var sec *mdSection
|
||||
var pending string
|
||||
var fence []string
|
||||
inFence := false
|
||||
|
||||
assemble := func() {
|
||||
if curKey == "" {
|
||||
return
|
||||
}
|
||||
if len(para) > 0 {
|
||||
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
|
||||
para = nil
|
||||
}
|
||||
var a meta.Affordance
|
||||
if len(useWhen) > 0 {
|
||||
a.UseWhen = useWhen
|
||||
}
|
||||
for _, s := range secs {
|
||||
switch standardSection[s.label] {
|
||||
case "avoid_when":
|
||||
a.AvoidWhen = s.items
|
||||
case "prerequisites":
|
||||
a.Prerequisites = s.items
|
||||
case "tips":
|
||||
a.Tips = s.items
|
||||
case "examples":
|
||||
a.Examples = s.cases
|
||||
default:
|
||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||
}
|
||||
}
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
|
||||
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
|
||||
|
||||
// flushPending appends a non-bullet paragraph line that was not consumed as
|
||||
// an example description (i.e. no fence followed) to the current section's
|
||||
// items, so prose under any section is preserved rather than dropped.
|
||||
flushPending := func() {
|
||||
if sec != nil && pending != "" {
|
||||
sec.items = append(sec.items, linkToBacktick(pending))
|
||||
pending = ""
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(string(src), "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "## "):
|
||||
flushPending()
|
||||
assemble()
|
||||
curKey = resolve(line[3:])
|
||||
reset()
|
||||
continue
|
||||
case strings.HasPrefix(line, "# "):
|
||||
continue
|
||||
case strings.HasPrefix(t, "> skill:"):
|
||||
skill = strings.TrimSpace(t[len("> skill:"):])
|
||||
continue
|
||||
case strings.HasPrefix(line, "### "):
|
||||
flushPending()
|
||||
sec = &mdSection{label: strings.TrimSpace(line[4:])}
|
||||
secs = append(secs, sec)
|
||||
pending, fence, inFence = "", nil, false
|
||||
continue
|
||||
}
|
||||
if curKey == "" {
|
||||
continue
|
||||
}
|
||||
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
|
||||
if t == "" {
|
||||
if len(para) > 0 {
|
||||
useWhen = append(useWhen, strings.Join(para, " "))
|
||||
para = nil
|
||||
}
|
||||
} else {
|
||||
para = append(para, t)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// inside a section: a fenced block is an example command; otherwise the
|
||||
// shape follows the writing (bullet item vs **description** before a fence).
|
||||
if strings.HasPrefix(t, "```") {
|
||||
if !inFence {
|
||||
inFence, fence = true, nil
|
||||
} else {
|
||||
inFence = false
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
fence = append(fence, line)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(t, "-") {
|
||||
flushPending()
|
||||
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
|
||||
} else if t != "" {
|
||||
flushPending()
|
||||
pending = strings.Trim(t, "* ")
|
||||
}
|
||||
}
|
||||
flushPending()
|
||||
assemble()
|
||||
return out
|
||||
}
|
||||
@@ -5,39 +5,30 @@ package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||
// model the envelope renderer and the command help both parse, so the
|
||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
// Affordance is the hand-authored usage guidance overlaid on a method: when to
|
||||
// use it, when not to, prerequisites, few-shot examples, and related methods.
|
||||
// It is the single typed model of the affordance shape; the envelope renderer
|
||||
// and the command help both parse through ParsedAffordance so the vocabulary
|
||||
// is defined once. The JSON tags double as the envelope's wire shape.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Tips []string `json:"tips,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Extensions []AffordanceSection `json:"extensions,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
|
||||
// AffordanceCase is one few-shot example: a one-line description and a
|
||||
// ready-to-run command.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// AffordanceSection is a custom guidance section: any heading beyond the
|
||||
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
|
||||
// here with its label preserved, so authors can add sections without code
|
||||
// changes.
|
||||
type AffordanceSection struct {
|
||||
Label string `json:"label"`
|
||||
Items []string `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
|
||||
// malformed, or wholly empty — callers treat all three as "no guidance".
|
||||
// ParsedAffordance decodes the method's raw affordance overlay into the typed
|
||||
// Affordance. ok is false when the method carries no affordance, the JSON is
|
||||
// malformed, or every section is empty — so callers can treat "no guidance"
|
||||
// uniformly.
|
||||
func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if len(m.Affordance) == 0 {
|
||||
return Affordance{}, false
|
||||
@@ -46,7 +37,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if json.Unmarshal(m.Affordance, &a) != nil {
|
||||
return Affordance{}, false
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
return Affordance{}, false
|
||||
}
|
||||
return a, true
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
notOK := map[string]string{
|
||||
"empty payload": ``,
|
||||
"empty object": `{}`,
|
||||
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
|
||||
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
|
||||
"malformed string": `"not an object"`,
|
||||
"malformed number": `42`,
|
||||
"nested type mismatch": `{"examples":"should be a list"}`,
|
||||
@@ -35,9 +35,8 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
// Populated affordance parses with all fields.
|
||||
raw := `{
|
||||
"use_when": ["需要拿到当前用户的主日历 ID"],
|
||||
"avoid_when": ["已知具体 calendar_id"],
|
||||
"do_not_use_when": ["已知具体 calendar_id"],
|
||||
"prerequisites": ["user 身份登录"],
|
||||
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
|
||||
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
||||
"related": ["calendars.list"]
|
||||
}`
|
||||
@@ -48,22 +47,10 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
|
||||
t.Errorf("Tips = %v", a.Tips)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
|
||||
// A method whose only guidance is Tips still parses as populated.
|
||||
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
|
||||
if !ok {
|
||||
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
|
||||
}
|
||||
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
|
||||
t.Errorf("Tips = %v", tipsOnly.Tips)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,7 @@ func namedPlaceholderValue(value string) bool {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") ||
|
||||
allXPlaceholder(value) ||
|
||||
conventionalNamedPlaceholderValue(value)
|
||||
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
@@ -83,41 +81,6 @@ func allXPlaceholder(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func conventionalNamedPlaceholderValue(value string) bool {
|
||||
if !delimitedPlaceholderIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ReplaceAll(value, "-", "_")
|
||||
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
|
||||
return conventionalCredentialPlaceholderName(rest)
|
||||
}
|
||||
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
|
||||
return conventionalCredentialPlaceholderName(rest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func conventionalCredentialPlaceholderName(value string) bool {
|
||||
switch value {
|
||||
case "api_key",
|
||||
"access_key",
|
||||
"private_key",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"token",
|
||||
"webhook",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"bearer_token",
|
||||
"session_token",
|
||||
"client_secret":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func urlWithAnglePlaceholder(value string) bool {
|
||||
if !strings.Contains(value, "://") ||
|
||||
!strings.Contains(value, "<") ||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -65,15 +63,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
if isSchemaDottedIdentifier(line, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||
}
|
||||
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
|
||||
if isPlaceholderBearerHeader(match) {
|
||||
continue
|
||||
}
|
||||
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
@@ -396,6 +391,10 @@ func credentialNameFragment(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isSchemaDottedIdentifier(line, match string) bool {
|
||||
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
@@ -405,40 +404,6 @@ func isNonSecretLiteralValue(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isJWTToken(value string) bool {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
header, err := decodeBase64URLSegment(parts[0])
|
||||
if err != nil || !json.Valid(header) {
|
||||
return false
|
||||
}
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal(header, &fields); err != nil {
|
||||
return false
|
||||
}
|
||||
alg, ok := fields["alg"].(string)
|
||||
return ok && alg != ""
|
||||
}
|
||||
|
||||
func decodeBase64URLSegment(value string) ([]byte, error) {
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func isPlaceholderBearerHeader(match string) bool {
|
||||
normalized := strings.ToLower(match)
|
||||
idx := strings.LastIndex(normalized, "bearer ")
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
value := strings.TrimSpace(match[idx+len("bearer "):])
|
||||
return isPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func isWebhookCredentialKey(key string) bool {
|
||||
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
||||
}
|
||||
@@ -776,12 +741,7 @@ func sanitizeSemanticExcerpt(text string) string {
|
||||
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
||||
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
||||
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
||||
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
if isJWTToken(match) {
|
||||
return "<jwt-like-token>"
|
||||
}
|
||||
return match
|
||||
})
|
||||
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
|
||||
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
|
||||
got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
||||
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
||||
if !findingRules(got)["public_content_detector_fingerprint"] {
|
||||
t.Fatalf("expected detector fingerprint finding, got %#v", got)
|
||||
}
|
||||
@@ -549,7 +549,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
|
||||
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
|
||||
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
|
||||
"inPrivateKey = true",
|
||||
"inPrivateKey = false",
|
||||
"hasPrivateKey: false",
|
||||
@@ -725,7 +725,7 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
@@ -734,7 +734,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -743,7 +743,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
||||
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
|
||||
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
|
||||
"AppSecret: stored,",
|
||||
"AccessToken: result.Token.AccessToken,",
|
||||
`token := runtime.Str("token")`,
|
||||
@@ -756,7 +756,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
|
||||
"def normalize_token(value: str) -> str:",
|
||||
" token = rest[index]",
|
||||
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
|
||||
@@ -771,7 +771,7 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
`<img token="..." url="https://..." width="..." height="..."/>`,
|
||||
`<sheet token="..." sheet-id="...">`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -783,7 +783,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
||||
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_jwt_like_token" {
|
||||
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
|
||||
@@ -791,38 +791,8 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{
|
||||
"| Method | Permission |",
|
||||
"| --- | --- |",
|
||||
"| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |",
|
||||
"| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |",
|
||||
"| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
|
||||
"| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |",
|
||||
"| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_jwt_like_token" {
|
||||
t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) {
|
||||
got := ScanFile("docs/api.md", []byte(strings.Join([]string{
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"corehr:employment.international_assignment.custom_field.apaas_id__c:read",
|
||||
"user_mailbox.sent_messages.get_recall_detail queries recall detail.",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_jwt_like_token" {
|
||||
t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"1704067200"}`,
|
||||
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -835,7 +805,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -851,7 +821,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "block_token": "boardXXXX" }`,
|
||||
`{ "resource_token": "doc_token_or_url" }`,
|
||||
`{ "token": "canonical_token" }`,
|
||||
@@ -871,7 +841,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -887,7 +857,7 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
}
|
||||
@@ -988,19 +958,6 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/auth.md", []byte(strings.Join([]string{
|
||||
"Authorization: Bearer YOUR_ACCESS_TOKEN",
|
||||
`{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`,
|
||||
"Authorization: Bearer <access-token>",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_bearer_header" {
|
||||
t.Fatalf("bearer placeholder should not be bearer finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
||||
token := "abcdefghijklmnopqrstuvwxyz"
|
||||
text := "private launch plan for internal rollout on Friday\n" +
|
||||
@@ -1018,22 +975,6 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) {
|
||||
text := "private launch plan for internal rollout on Friday\n" +
|
||||
"Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n"
|
||||
|
||||
got := semanticCandidate("docs/public.md", "file", text, 1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
|
||||
}
|
||||
if strings.Contains(got[0].Excerpt, "<jwt-like-token>") {
|
||||
t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0])
|
||||
}
|
||||
if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") {
|
||||
t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
|
||||
text := strings.Join([]string{
|
||||
"Generated with automated code assistant",
|
||||
@@ -1071,37 +1012,6 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: YOUR_CLIENT_SECRET",
|
||||
"api_key: YOUR_API_KEY",
|
||||
"password: YOUR_PASSWORD",
|
||||
"access_token: ACCESS_TOKEN_HERE",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
@@ -25,7 +22,7 @@ func Convert(f meta.Field) Property {
|
||||
if f.Type == "file" {
|
||||
p.Format = "binary"
|
||||
}
|
||||
p.Description = normalizeDesc(f.Description)
|
||||
p.Description = f.Description
|
||||
p.Default = f.CoercedDefault()
|
||||
p.Example = f.CoercedExample()
|
||||
p.Minimum = f.MinBound()
|
||||
@@ -55,24 +52,6 @@ func Convert(f meta.Field) Property {
|
||||
return p
|
||||
}
|
||||
|
||||
var (
|
||||
sepRunRe = regexp.MustCompile(`[;;]{2,}`)
|
||||
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
|
||||
)
|
||||
|
||||
// normalizeDesc de-crufts a meta_data description for the envelope — strips
|
||||
// markdown emphasis and collapses doubled separators/spaces — but keeps content
|
||||
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
|
||||
func normalizeDesc(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.ReplaceAll(s, "**", "")
|
||||
s = sepRunRe.ReplaceAllString(s, "; ")
|
||||
s = spaceRunRe.ReplaceAllString(s, " ")
|
||||
return strings.TrimRight(s, " ;;。.,,、\n")
|
||||
}
|
||||
|
||||
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
|
||||
// arrays for the envelope. enumDescriptions is nil unless at least one value
|
||||
// carries a description (so the bare-enum form stays values-only), keeping the
|
||||
@@ -107,18 +86,6 @@ func propsOf(fields []meta.Field) *OrderedProps {
|
||||
return op
|
||||
}
|
||||
|
||||
// paramPropsOf is propsOf for the params section: each property also carries
|
||||
// its CLI flag (--kebab-name).
|
||||
func paramPropsOf(fields []meta.Field) *OrderedProps {
|
||||
op := &OrderedProps{}
|
||||
for _, f := range fields {
|
||||
p := Convert(f)
|
||||
p.Flag = "--" + f.FlagName()
|
||||
op.Set(f.Name, p)
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
// requiredOf returns the alphabetized names of the required fields.
|
||||
func requiredOf(fields []meta.Field) []string {
|
||||
var required []string
|
||||
@@ -141,17 +108,16 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
||||
Properties: &OrderedProps{},
|
||||
}
|
||||
|
||||
addInputObject(is, "params", "", m.Params(), true, "")
|
||||
addInputObject(is, "data", "", m.Data(), false, "--data")
|
||||
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
|
||||
addInputObject(is, "params", "", m.Params())
|
||||
addInputObject(is, "data", "", m.Data())
|
||||
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
|
||||
|
||||
if m.Risk == core.RiskHighRiskWrite {
|
||||
falseVal := false
|
||||
is.Properties.Set("yes", Property{
|
||||
Type: "boolean",
|
||||
Flag: "--yes",
|
||||
Default: falseVal,
|
||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
|
||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,24 +125,20 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
||||
return is
|
||||
}
|
||||
|
||||
// addInputObject adds one section (params/data/file) when it has fields, marking
|
||||
// the section required at top level when any field is. asFlags tags each property
|
||||
// with its --flag (params only); carrier names the section's flag (--data/--file).
|
||||
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
|
||||
// addInputObject adds one named sub-object section (params/data/file) to the
|
||||
// input schema when it has fields: its Properties come from the fields, its
|
||||
// Required lists the mandatory keys, and the section itself is required at top
|
||||
// level when any field is required. Empty sections are skipped.
|
||||
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
props := propsOf(fields)
|
||||
if asFlags {
|
||||
props = paramPropsOf(fields)
|
||||
}
|
||||
req := requiredOf(fields)
|
||||
is.Properties.Set(name, Property{
|
||||
Type: "object",
|
||||
Description: description,
|
||||
Carrier: carrier,
|
||||
Required: req,
|
||||
Properties: props,
|
||||
Properties: propsOf(fields),
|
||||
})
|
||||
if len(req) > 0 {
|
||||
is.Required = append(is.Required, name)
|
||||
@@ -217,13 +179,7 @@ func buildMeta(m meta.Method) *Meta {
|
||||
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
|
||||
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
|
||||
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
|
||||
m := ref.Method
|
||||
// The affordance overlay lives in the CLI, not the metadata; look it up
|
||||
// lazily here (it takes precedence over any affordance the metadata carries).
|
||||
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
|
||||
m.Affordance = raw
|
||||
}
|
||||
return assemble(ref.Service.Name, ref.ResourcePath, m)
|
||||
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
|
||||
}
|
||||
|
||||
// Envelopes renders the given method refs into envelopes, sorted by name. The
|
||||
@@ -249,7 +205,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
|
||||
|
||||
return Envelope{
|
||||
Name: name,
|
||||
Description: normalizeDesc(m.Description),
|
||||
Description: m.Description,
|
||||
InputSchema: buildInputSchema(m),
|
||||
OutputSchema: buildOutputSchema(m),
|
||||
Meta: buildMeta(m),
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -506,31 +504,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
|
||||
// service + method id), so a method whose metadata carries none still gets
|
||||
// guidance in its envelope when an overlay entry exists.
|
||||
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
|
||||
// The overlay source is the top-level affordance/ tree, injected at startup;
|
||||
// inject a fixture so this unit test does not depend on the shipped content.
|
||||
// Reset afterwards (this binary installs no source by default) for isolation.
|
||||
t.Cleanup(func() { affordance.SetSource(nil) })
|
||||
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
|
||||
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
|
||||
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
|
||||
if env.Meta == nil || env.Meta.Affordance == nil {
|
||||
t.Fatal("expected affordance from the approval overlay, got none")
|
||||
}
|
||||
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
|
||||
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
|
||||
}
|
||||
|
||||
// A method id with no overlay entry carries no affordance.
|
||||
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
|
||||
if bare.Meta != nil && bare.Meta.Affordance != nil {
|
||||
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
)
|
||||
|
||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||
//
|
||||
// The REST route (httpMethod/path) is deliberately NOT exposed: every
|
||||
// schema-resolvable method already has a typed command, so the raw path would
|
||||
// only tempt an agent toward the `api` escape hatch.
|
||||
type Envelope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
@@ -48,15 +44,9 @@ type OutputSchema struct {
|
||||
// "params" / "data" sub-objects inside inputSchema): it lists which keys
|
||||
// inside that object's Properties are mandatory. Leaf fields ignore it.
|
||||
type Property struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
|
||||
// absent on body/file fields, which travel via the section's Carrier.
|
||||
Flag string `json:"flag,omitempty"`
|
||||
// Carrier names the flag a whole inputSchema section travels on ("--data" /
|
||||
// "--file"); empty on the params section, whose properties carry their Flag.
|
||||
Carrier string `json:"carrier,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
|
||||
// each allowed value, in the same order. Omitted when no value carries a
|
||||
// description. This is the widely-recognized JSON-Schema extension (VS Code,
|
||||
|
||||
@@ -16,14 +16,6 @@ import (
|
||||
const (
|
||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||
|
||||
// EnvNoProxyWarn suppresses the proxy-detected warning when set to any
|
||||
// non-empty value, while leaving proxy behavior unchanged. Unlike
|
||||
// EnvNoProxy (which both silences the warning AND disables the proxy), this
|
||||
// keeps proxy egress active. It exists so agents consuming --format json can
|
||||
// keep using the proxy without the human-oriented warning line landing in
|
||||
// the output stream and breaking JSON parsing.
|
||||
EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN"
|
||||
)
|
||||
|
||||
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
||||
@@ -81,11 +73,6 @@ func redactProxyURL(raw string) string {
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
func WarnIfProxied(w io.Writer) {
|
||||
proxyWarningOnce.Do(func() {
|
||||
// Explicit opt-out: silence the warning without touching proxy behavior.
|
||||
// Checked before the plugin and env-proxy branches so it suppresses both.
|
||||
if os.Getenv(EnvNoProxyWarn) != "" {
|
||||
return
|
||||
}
|
||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||
// Shared), so its warning and disable instructions take precedence.
|
||||
// Emitting the env-proxy warning here would be misleading: it tells the
|
||||
@@ -101,7 +88,7 @@ func WarnIfProxied(w io.Writer) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n",
|
||||
key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn)
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
||||
key, redactProxyURL(val), EnvNoProxy)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,47 +93,6 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN
|
||||
// suppresses the warning while the proxy stays configured (unlike
|
||||
// LARK_CLI_NO_PROXY, which also disables the proxy).
|
||||
func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
t.Setenv(EnvNoProxyWarn, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that
|
||||
// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning.
|
||||
func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
old := proxyPluginStatus
|
||||
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
t.Setenv(EnvNoProxyWarn, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.58",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, execFile } = require("child_process");
|
||||
|
||||
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
|
||||
// CommonJS script works on all supported Node versions (require() of an ESM
|
||||
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
|
||||
// point below before main() runs.
|
||||
let p;
|
||||
const p = require("@clack/prompts");
|
||||
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
@@ -379,12 +374,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
p = await import("@clack/prompts");
|
||||
await main();
|
||||
})().catch((err) => {
|
||||
const msg = "Unexpected error: " + (err.message || err);
|
||||
if (p) p.cancel(msg);
|
||||
else console.error(msg);
|
||||
main().catch((err) => {
|
||||
p.cancel("Unexpected error: " + (err.message || err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "filter by target table"},
|
||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
|
||||
@@ -61,9 +61,6 @@ var AppsDBDataExport = common.Shortcut{
|
||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||
}
|
||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
|
||||
@@ -84,8 +84,8 @@ var AppsDBExecute = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
|
||||
}
|
||||
// 仅本地校验非空;不把文件内容写回公开的 --sql flag(避免 SQL 内容进入
|
||||
// flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
@@ -297,29 +297,10 @@ func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
// 不被写回公开的 --sql flag(避免泄露进 flag dump / 结构化日志)。优先 --sql(内联或 stdin,
|
||||
// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。
|
||||
func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) {
|
||||
if strings.TrimSpace(rctx.Str("sql")) != "" {
|
||||
return rctx.Str("sql"), nil
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if file == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(由 resolveExecuteSQL 在用时解析,--file 不入 flag)。
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。
|
||||
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
sql, _ := resolveExecuteSQL(rctx)
|
||||
return map[string]interface{}{
|
||||
"sql": sql,
|
||||
"sql": rctx.Str("sql"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
@@ -165,7 +165,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
|
||||
@@ -83,7 +83,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
|
||||
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
||||
if err != nil {
|
||||
return withAppsHint(err, envPullAPIErrorHint(err, appID))
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
|
||||
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
|
||||
@@ -126,27 +126,6 @@ func envPullVarsBody() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func envPullAPIErrorHint(err error, appID string) string {
|
||||
if isEnvPullDevDBNotInitializedError(err) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
appID = "<app_id>"
|
||||
}
|
||||
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
|
||||
}
|
||||
return appIDListHint
|
||||
}
|
||||
|
||||
func isEnvPullDevDBNotInitializedError(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(p.Message)
|
||||
return strings.Contains(message, "multi-environment database is not initialized") ||
|
||||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
|
||||
@@ -592,38 +592,6 @@ func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": -1,
|
||||
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branch:dev",
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
p := requireAppsAPIProblem(t, err)
|
||||
if p.Code != -1 {
|
||||
t.Fatalf("code = %d, want -1", p.Code)
|
||||
}
|
||||
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Fatalf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
@@ -41,9 +41,6 @@ var AppsFileDownload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
|
||||
@@ -39,8 +39,8 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "type", Desc: "filter by MIME type"},
|
||||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
|
||||
@@ -136,14 +136,7 @@ func putFileBytes(ctx context.Context, url string, content []byte, contentType,
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// 用 mime.FormatMediaType 规范生成 Content-Disposition(自动按 RFC 2045 处理引号/转义),
|
||||
// 不手工拼接 header,杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName
|
||||
// 做 encodeURIComponent(控制字符/分隔符均 %XX 化),此处是第二道防线。
|
||||
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)})
|
||||
if disposition == "" {
|
||||
disposition = "attachment"
|
||||
}
|
||||
req.Header.Set("Content-Disposition", disposition)
|
||||
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
@@ -177,11 +170,6 @@ func sanitizeUploadFileName(name string) string {
|
||||
if enc == "" {
|
||||
return "download_file"
|
||||
}
|
||||
// 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件,
|
||||
// 前置下划线消除隐藏文件语义。
|
||||
if strings.HasPrefix(enc, ".") {
|
||||
enc = "_" + enc
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -144,10 +143,8 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
||||
}
|
||||
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
||||
// 断言按解析结果(format-agnostic):mime.FormatMediaType 对无 tspecial 的名不加引号,
|
||||
// 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。
|
||||
if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" {
|
||||
t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err)
|
||||
if putCD != `attachment; filename="logo.png"` {
|
||||
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
||||
|
||||
@@ -46,118 +46,13 @@ func redactKeyInfo(info map[string]interface{}) map[string]interface{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// allowedScopeAPIMethods is the HTTP method whitelist for --scope-api / request_scope.
|
||||
var allowedScopeAPIMethods = map[string]struct{}{
|
||||
"GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {},
|
||||
}
|
||||
|
||||
// validateScopeAPIMethod rejects methods outside the whitelist (e.g. TRACE, CONNECT, empty).
|
||||
func validateScopeAPIMethod(method string) error {
|
||||
if _, ok := allowedScopeAPIMethods[method]; !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http method %q not allowed; use one of GET, POST, PUT, PATCH, DELETE", method)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScopeAPIPath enforces basic openapi route hygiene as a first line of defense.
|
||||
func validateScopeAPIPath(p string) error {
|
||||
if p == "" || !strings.HasPrefix(p, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must start with '/', got %q", p)
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must not contain '..': %q", p)
|
||||
}
|
||||
if strings.Contains(p, "//") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must not contain '//': %q", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequestScopeFields constrains a request_scope object to the documented
|
||||
// schema: only allow_all (bool) and http_infos ([{http_method, http_path}]). This
|
||||
// closes the raw --scope escape hatch from injecting undocumented fields.
|
||||
func validateRequestScopeFields(rs map[string]interface{}) error {
|
||||
for k := range rs {
|
||||
switch k {
|
||||
case "allow_all", "http_infos":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown field %q; only allow_all and http_infos are allowed", k)
|
||||
}
|
||||
}
|
||||
if v, ok := rs["allow_all"]; ok {
|
||||
if _, isBool := v.(bool); !isBool {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "allow_all must be a boolean")
|
||||
}
|
||||
}
|
||||
if v, ok := rs["http_infos"]; ok {
|
||||
arr, isArr := v.([]interface{})
|
||||
if !isArr {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "http_infos must be an array")
|
||||
}
|
||||
for _, item := range arr {
|
||||
m, isMap := item.(map[string]interface{})
|
||||
if !isMap {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "each http_infos entry must be an object")
|
||||
}
|
||||
for k := range m {
|
||||
switch k {
|
||||
case "http_method", "http_path":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown field %q in http_infos entry; only http_method and http_path are allowed", k)
|
||||
}
|
||||
}
|
||||
method, _ := m["http_method"].(string)
|
||||
if err := validateScopeAPIMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
path, _ := m["http_path"].(string)
|
||||
if err := validateScopeAPIPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRawScope parses a raw --scope JSON value: it must be an object that
|
||||
// conforms to the request_scope schema (validated by validateRequestScopeFields).
|
||||
func parseRawScope(scopeRaw string) (map[string]interface{}, error) {
|
||||
var rs interface{}
|
||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, ok := rs.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope must be a JSON object")
|
||||
}
|
||||
if err := validateRequestScopeFields(obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case
|
||||
// httpInfo, validating the method against the whitelist and the path format.
|
||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo.
|
||||
func parseScopeAPI(s string) (map[string]interface{}, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(s))
|
||||
if len(fields) != 2 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s)
|
||||
}
|
||||
method := strings.ToUpper(fields[0])
|
||||
if err := validateScopeAPIMethod(method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fields[1]
|
||||
if err := validateScopeAPIPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"http_method": method, "http_path": path}, nil
|
||||
return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil
|
||||
}
|
||||
|
||||
// buildRequestScope assembles config.request_scope (snake_case) from the scope flags.
|
||||
@@ -170,7 +65,11 @@ func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (inte
|
||||
if hasFriendly {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope")
|
||||
}
|
||||
return parseRawScope(scopeRaw)
|
||||
var rs interface{}
|
||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
if !hasFriendly {
|
||||
return nil, nil
|
||||
@@ -212,21 +111,18 @@ func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllow
|
||||
// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update).
|
||||
func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error {
|
||||
scopeRaw := strings.TrimSpace(rctx.Str("scope"))
|
||||
scopeAPIs := rctx.StrArray("scope-api")
|
||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(scopeAPIs) > 0) {
|
||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) {
|
||||
return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api").
|
||||
WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both")
|
||||
}
|
||||
if scopeRaw != "" {
|
||||
if _, err := parseRawScope(scopeRaw); err != nil {
|
||||
return appsValidationParamError("--scope", "invalid --scope: %s", err).
|
||||
WithHint("--scope takes a JSON object with only allow_all (bool) and http_infos ([{http_method, http_path}]); methods: GET, POST, PUT, PATCH, DELETE")
|
||||
}
|
||||
if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) {
|
||||
return appsValidationParamError("--scope", "--scope must be valid JSON").
|
||||
WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'")
|
||||
}
|
||||
for _, a := range scopeAPIs {
|
||||
if _, err := parseScopeAPI(a); err != nil {
|
||||
return appsValidationParamError("--scope-api", "invalid --scope-api: %s", err).
|
||||
WithHint("format: 'METHOD /openapi/path'; method one of GET, POST, PUT, PATCH, DELETE; path starts with '/', no '..' or '//'")
|
||||
for _, a := range rctx.StrArray("scope-api") {
|
||||
if len(strings.Fields(strings.TrimSpace(a))) != 2 {
|
||||
return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a).
|
||||
WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -78,108 +78,6 @@ func TestParseScopeAPI(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateScopeAPIMethod(t *testing.T) {
|
||||
for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
|
||||
if err := validateScopeAPIMethod(m); err != nil {
|
||||
t.Errorf("validateScopeAPIMethod(%q) = %v, want nil", m, err)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"TRACE", "CONNECT", "OPTIONS", "HEAD", "", "get"} {
|
||||
if err := validateScopeAPIMethod(m); err == nil {
|
||||
t.Errorf("validateScopeAPIMethod(%q) = nil, want error", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopeAPIPath(t *testing.T) {
|
||||
for _, p := range []string{"/openapi/orders", "/openapi/v1/x"} {
|
||||
if err := validateScopeAPIPath(p); err != nil {
|
||||
t.Errorf("validateScopeAPIPath(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"", "openapi/x", "/openapi/../admin", "/..", "/openapi//x", "//x"} {
|
||||
if err := validateScopeAPIPath(p); err == nil {
|
||||
t.Errorf("validateScopeAPIPath(%q) = nil, want error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestScopeFields(t *testing.T) {
|
||||
ok := []map[string]interface{}{
|
||||
{"allow_all": true},
|
||||
{"allow_all": false, "http_infos": []interface{}{
|
||||
map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"},
|
||||
}},
|
||||
{},
|
||||
}
|
||||
for _, rs := range ok {
|
||||
if err := validateRequestScopeFields(rs); err != nil {
|
||||
t.Errorf("validateRequestScopeFields(%v) = %v, want nil", rs, err)
|
||||
}
|
||||
}
|
||||
bad := []map[string]interface{}{
|
||||
{"foo": 1}, // unknown top-level field
|
||||
{"allow_all": "yes"}, // wrong type
|
||||
{"http_infos": "x"}, // not an array
|
||||
{"http_infos": []interface{}{"x"}}, // entry not an object
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "TRACE", "http_path": "/x"}}}, // bad method
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "../x"}}}, // bad path
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "/x", "extra": 1}}}, // unknown entry field
|
||||
}
|
||||
for _, rs := range bad {
|
||||
if err := validateRequestScopeFields(rs); err == nil {
|
||||
t.Errorf("validateRequestScopeFields(%v) = nil, want error", rs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRawScope(t *testing.T) {
|
||||
if _, err := parseRawScope(`{"allow_all":true}`); err != nil {
|
||||
t.Errorf("valid object errored: %v", err)
|
||||
}
|
||||
for _, raw := range []string{`["x"]`, `"s"`, `123`, `{"foo":1}`, `{bad`} {
|
||||
if _, err := parseRawScope(raw); err == nil {
|
||||
t.Errorf("parseRawScope(%q) = nil, want error", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScopeAPI_Rejects(t *testing.T) {
|
||||
bad := []string{"TRACE /openapi/x", "CONNECT /x", "GET ../admin", "GET openapi/x", "GET /a//b"}
|
||||
for _, in := range bad {
|
||||
if _, err := parseScopeAPI(in); err == nil {
|
||||
t.Errorf("parseScopeAPI(%q) = nil, want error", in)
|
||||
}
|
||||
}
|
||||
// regression: legitimate input still parses (and lowercases the method)
|
||||
info, err := parseScopeAPI("get /openapi/orders")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info["http_method"] != "GET" || info["http_path"] != "/openapi/orders" {
|
||||
t.Errorf("info = %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestScope_RawValidation(t *testing.T) {
|
||||
// unknown field now rejected (HIGH-2)
|
||||
if _, err := buildRequestScope(false, nil, `{"foo":1}`); err == nil {
|
||||
t.Errorf("raw scope with unknown field must error")
|
||||
}
|
||||
// non-object rejected
|
||||
if _, err := buildRequestScope(false, nil, `["x"]`); err == nil {
|
||||
t.Errorf("non-object raw scope must error")
|
||||
}
|
||||
// nested bad method rejected
|
||||
if _, err := buildRequestScope(false, nil, `{"http_infos":[{"http_method":"TRACE","http_path":"/x"}]}`); err == nil {
|
||||
t.Errorf("raw scope with bad nested method must error")
|
||||
}
|
||||
// regression: documented fields pass
|
||||
if _, err := buildRequestScope(false, nil, `{"allow_all":true}`); err != nil {
|
||||
t.Errorf("valid raw scope errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestScope(t *testing.T) {
|
||||
t.Run("nothing set -> nil", func(t *testing.T) {
|
||||
rs, err := buildRequestScope(false, nil, "")
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRejectOutputTraversal pins HIGH-3: --output rejects absolute paths and
|
||||
// any .. traversal component; empty and ordinary relative paths pass.
|
||||
func TestRejectOutputTraversal(t *testing.T) {
|
||||
ok := []string{"", "out.csv", "./out.csv", "sub/dir/out.csv", "a..b.csv", "foo..bar/x.csv"}
|
||||
for _, p := range ok {
|
||||
if err := rejectOutputTraversal(p); err != nil {
|
||||
t.Errorf("rejectOutputTraversal(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
bad := []string{"/etc/passwd", "../x", "../../etc/cron.d/evil", "sub/../../x", "./../x"}
|
||||
for _, p := range bad {
|
||||
if err := rejectOutputTraversal(p); err == nil {
|
||||
t.Errorf("rejectOutputTraversal(%q) = nil, want validation error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName pins HIGH-4 / LOW-1: control & separator chars are
|
||||
// neutralized (percent-encoded, no raw CR/LF/TAB/NUL/quote) and the result never
|
||||
// starts with a dot (hidden-file overwrite guard).
|
||||
func TestSanitizeUploadFileName(t *testing.T) {
|
||||
// LOW-1: dotfiles get a leading underscore.
|
||||
for _, in := range []string{".bashrc", ".ssh", "..hidden"} {
|
||||
got := sanitizeUploadFileName(in)
|
||||
if strings.HasPrefix(got, ".") {
|
||||
t.Errorf("sanitizeUploadFileName(%q) = %q, must not start with '.'", in, got)
|
||||
}
|
||||
}
|
||||
// HIGH-4: header-breaking / control chars must not survive raw.
|
||||
raw := "a\r\nb\tc\x00d\"e.png"
|
||||
got := sanitizeUploadFileName(raw)
|
||||
for _, bad := range []string{"\r", "\n", "\t", "\x00", "\"", " "} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("sanitizeUploadFileName(%q) = %q, still contains raw %q", raw, got, bad)
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("sanitizeUploadFileName returned empty for non-empty input")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -40,28 +39,3 @@ func withAppsHint(err error, hint string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||||
// --output path. The authoritative guard is the local FileIO layer
|
||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||||
// symlinks), so traversal is already blocked at write time; this gives an
|
||||
// earlier, clearer validation error and pins the contract in the command layer.
|
||||
// Empty (use server-derived default) passes through. Absolute paths and any
|
||||
// ".." path component are rejected.
|
||||
func rejectOutputTraversal(output string) error {
|
||||
o := strings.TrimSpace(output)
|
||||
if o == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(o) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output must be a relative path within the current directory, got %q", o).WithParam("--output")
|
||||
}
|
||||
for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) {
|
||||
if seg == ".." {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output must not contain .. path traversal, got %q", o).WithParam("--output")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -488,7 +488,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
// handled locally.
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err))
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
detail := logIDDetail(resp)
|
||||
if resp == nil || len(resp.RawBody) == 0 {
|
||||
@@ -501,7 +501,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
|
||||
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
||||
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
||||
if cerr != nil {
|
||||
return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint))
|
||||
return nil, withAppsHint(cerr, gitCredentialIssueHint)
|
||||
}
|
||||
if data != nil {
|
||||
result = data
|
||||
@@ -536,7 +536,6 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
if message == "" {
|
||||
message = "Git credential API returned non-zero BaseResp status"
|
||||
}
|
||||
message = gitcred.RedactCredentialText(message)
|
||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
|
||||
if logID != "" {
|
||||
baseErr = baseErr.WithLogID(logID)
|
||||
@@ -546,17 +545,6 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactGitCredentialIssueError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = gitcred.RedactCredentialText(p.Message)
|
||||
p.Hint = gitcred.RedactCredentialText(p.Hint)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
|
||||
logID := logIDString(resp)
|
||||
if logID == "" {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1026,24 +1027,25 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseIssueCredentialDataRedactsCredentialErrorMessage verifies that the
|
||||
// git-credential boundary does not pass server-provided credential details into
|
||||
// the user-visible typed envelope message.
|
||||
func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
serverMsg := "permission denied: " +
|
||||
testCredentialAssignment("token", samplePAT) + " " +
|
||||
testCredentialAssignment("password", samplePassword) + " " +
|
||||
testCredentialURLWithUserInfo("example.com/repo.git", samplePAT)
|
||||
// TestParseIssueCredentialDataMessageAddsNoExtraSecret verifies the security
|
||||
// condition that apps does not ADDITIONALLY inject any token/secret into the
|
||||
// Git-credential error it builds. The server `msg` is passed through verbatim
|
||||
// into Problem.Message, and the only thing apps adds is the static
|
||||
// gitCredentialIssueHint — which itself contains no secret. We feed a benign
|
||||
// server msg and assert (a) Message equals that msg exactly, and (b) neither
|
||||
// Message nor Hint contains any token/secret-shaped string.
|
||||
//
|
||||
// Note: server msg passthrough is the shared classifier's responsibility;
|
||||
// apps adds only a static hint. There is no msg redaction in this path, so
|
||||
// this test does not assert a redaction that does not exist — it asserts
|
||||
// that apps injects nothing sensitive of its own.
|
||||
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
const serverMsg = "permission denied"
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resp *larkcore.ApiResp
|
||||
wantType errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantCode int
|
||||
name string
|
||||
resp *larkcore.ApiResp
|
||||
}{
|
||||
{
|
||||
name: "http error path",
|
||||
@@ -1052,9 +1054,6 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
|
||||
Header: header,
|
||||
},
|
||||
wantType: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
wantCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "business code path",
|
||||
@@ -1063,9 +1062,6 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
|
||||
Header: header,
|
||||
},
|
||||
wantType: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
wantCode: 999,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -1077,85 +1073,30 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != tc.wantType || p.Subtype != tc.wantSubtype || p.Code != tc.wantCode {
|
||||
t.Fatalf("problem metadata = %s/%s code=%d, want %s/%s code=%d",
|
||||
p.Category, p.Subtype, p.Code, tc.wantType, tc.wantSubtype, tc.wantCode)
|
||||
}
|
||||
if !strings.Contains(p.Message, "permission denied") {
|
||||
t.Fatalf("Message = %q, want it to retain non-secret server context", p.Message)
|
||||
// (a) The server msg survives into the message. The business-code
|
||||
// path passes it through verbatim; the HTTP-status path reports
|
||||
// "HTTP <status>: <body>" via the shared classifier, so assert
|
||||
// containment rather than equality.
|
||||
if !strings.Contains(p.Message, serverMsg) {
|
||||
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
|
||||
}
|
||||
// apps adds only the static hint — assert that exact static text,
|
||||
// proving apps injects no per-request secret into the hint either.
|
||||
if p.Hint != gitCredentialIssueHint {
|
||||
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
|
||||
}
|
||||
// (b) Neither field may contain a token/secret-shaped string that
|
||||
// apps could have added on top of the framework passthrough.
|
||||
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S|password\s*[=:]\s*\S)`)
|
||||
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
|
||||
for _, leaked := range []string{samplePAT, "user:" + samplePAT + "@", testCredentialAssignment("password", samplePassword)} {
|
||||
if strings.Contains(val, leaked) {
|
||||
t.Fatalf("%s leaks %q: %q", field, leaked, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
testRedactedAssignment("token"),
|
||||
testRedactedAssignment("password"),
|
||||
"https://***@example.com/repo.git",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Fatalf("Message missing %q after redaction: %q", want, p.Message)
|
||||
if secret.MatchString(val) {
|
||||
t.Fatalf("%s leaks a token/secret-shaped string: %q", field, val)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIssueCredentialDataRedactsSDKErrorPreservesCause(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
cause := errors.New("transport failed with " + testCredentialAssignment("token", samplePAT))
|
||||
|
||||
_, err := parseIssueCredentialData(nil, cause, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected SDK-boundary error, got nil")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("error does not preserve cause: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem metadata = %s/%s, want %s/%s",
|
||||
p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if strings.Contains(p.Message, samplePAT) {
|
||||
t.Fatalf("message leaks credential value: %q", p.Message)
|
||||
}
|
||||
if want := testRedactedAssignment("token"); !strings.Contains(p.Message, want) {
|
||||
t.Fatalf("message missing %q after redaction: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactGitCredentialIssueErrorNil(t *testing.T) {
|
||||
if err := redactGitCredentialIssueError(nil); err != nil {
|
||||
t.Fatalf("redactGitCredentialIssueError(nil) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicSafeJoin(parts ...string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func testCredentialAssignment(key, value string) string {
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func testRedactedAssignment(key string) string {
|
||||
return key + "=<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
||||
return "https://" + "user:" + credential + "@" + hostPath
|
||||
}
|
||||
|
||||
type errorReader struct{}
|
||||
|
||||
func (errorReader) Read(p []byte) (int, error) {
|
||||
|
||||
@@ -542,15 +542,7 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
||||
if err := manager.Store.Upsert(*record); err != nil {
|
||||
t.Fatalf("Upsert expired record returned error: %v", err)
|
||||
}
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
issuer.err = errs.NewAPIError(
|
||||
errs.SubtypeUnknown,
|
||||
"permission denied: "+
|
||||
testCredentialAssignment("token", samplePAT)+" "+
|
||||
testCredentialAssignment("password", samplePassword)+" "+
|
||||
testCredentialURLWithUserInfo("example.com/git/u/app.git", samplePAT),
|
||||
).WithHint("retry without " + testCredentialAssignment("token", samplePAT)).WithLogID("log_x")
|
||||
issuer.err = errors.New("permission denied")
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
@@ -560,22 +552,6 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", out.String())
|
||||
}
|
||||
stderr := errOut.String()
|
||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("password", samplePassword), "user:" + samplePAT + "@"} {
|
||||
if strings.Contains(stderr, leaked) {
|
||||
t.Fatalf("stderr leaks %q: %s", leaked, stderr)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
testRedactedAssignment("token"),
|
||||
testRedactedAssignment("password"),
|
||||
"https://***@example.com/git/u/app.git",
|
||||
"log_id=log_x",
|
||||
} {
|
||||
if !strings.Contains(stderr, want) {
|
||||
t.Fatalf("stderr missing %q in %s", want, stderr)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
|
||||
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
|
||||
}
|
||||
@@ -1435,36 +1411,10 @@ func TestSecretStoreBranches(t *testing.T) {
|
||||
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
|
||||
t.Fatal("SecretStore.Set empty ref returned nil error")
|
||||
}
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
kc.setErr = errors.New("keychain set failed with " + testCredentialAssignment("token", samplePAT))
|
||||
var setCfgErr *errs.ConfigError
|
||||
setErr := NewSecretStore(kc).Set("ref", samplePAT)
|
||||
if setErr == nil || !errors.As(setErr, &setCfgErr) {
|
||||
t.Fatalf("SecretStore.Set keychain error = %T %v, want ConfigError", setErr, setErr)
|
||||
}
|
||||
assertProblem(t, setErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
||||
if setCfgErr.Message != "save local Git credential PAT to keychain failed" {
|
||||
t.Fatalf("ConfigError message = %q, want static keychain failure", setCfgErr.Message)
|
||||
}
|
||||
if strings.Contains(setCfgErr.Message, samplePAT) {
|
||||
t.Fatalf("ConfigError message leaks credential value: %q", setCfgErr.Message)
|
||||
}
|
||||
if !errors.Is(setCfgErr, kc.setErr) {
|
||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
||||
}
|
||||
kc.setErr = nil
|
||||
kc.removeErr = errors.New("keychain remove failed")
|
||||
var cfgErr *errs.ConfigError
|
||||
removeErr := NewSecretStore(kc).Remove("ref")
|
||||
if removeErr == nil || !errors.As(removeErr, &cfgErr) {
|
||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", removeErr, removeErr)
|
||||
}
|
||||
assertProblem(t, removeErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
||||
if cfgErr.Message != "remove local Git credential PAT from keychain failed" {
|
||||
t.Fatalf("ConfigError message = %q, want static keychain failure", cfgErr.Message)
|
||||
}
|
||||
if !errors.Is(cfgErr, kc.removeErr) {
|
||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
||||
if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1546,56 +1496,6 @@ func TestLockAppHeldTimesOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetPreservesTypedLockAppError(t *testing.T) {
|
||||
now := time.Unix(1780000000, 0)
|
||||
store := NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename))
|
||||
kc := newFakeKeychain()
|
||||
record := CredentialRecord{
|
||||
AppID: "app_xxx",
|
||||
GitHTTPURL: "https://example.com/git/u/app.git",
|
||||
Profile: testProfile().Profile,
|
||||
ProfileAppID: testProfile().ProfileAppID,
|
||||
UserOpenID: testProfile().UserOpenID,
|
||||
Username: "x-access-token",
|
||||
PATRef: "ref",
|
||||
Status: StatusConfirmed,
|
||||
ExpiresAt: now.Add(-time.Minute).Unix(),
|
||||
UpdatedAt: now.Unix(),
|
||||
}
|
||||
if err := store.Upsert(record); err != nil {
|
||||
t.Fatalf("Upsert returned error: %v", err)
|
||||
}
|
||||
kc.values[record.PATRef] = "old-pat"
|
||||
|
||||
blocker := filepath.Join(t.TempDir(), "config-blocker")
|
||||
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
|
||||
t.Fatalf("write config blocker: %v", err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
|
||||
|
||||
manager := NewManager(store, NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
|
||||
GitHTTPURL: record.GitHTTPURL,
|
||||
PAT: "new-pat",
|
||||
ExpiresAt: now.Add(24 * time.Hour).Unix(),
|
||||
}})
|
||||
manager.Now = func() time.Time { return now }
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", out.String())
|
||||
}
|
||||
stderr := errOut.String()
|
||||
if !strings.Contains(stderr, "create Git credential lock dir") {
|
||||
t.Fatalf("stderr = %q, want typed lock-dir setup error", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "acquire Git credential lock") {
|
||||
t.Fatalf("stderr rewrapped typed lock error: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
|
||||
now := time.Unix(1780000000, 0)
|
||||
path := filepath.Join(t.TempDir(), MetadataFilename)
|
||||
@@ -1871,15 +1771,8 @@ func TestManagerGetBranches(t *testing.T) {
|
||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||
t.Fatalf("Get keychain set error returned error: %v", err)
|
||||
}
|
||||
stderr := errOut.String()
|
||||
if !strings.Contains(stderr, "save local Git credential PAT to keychain failed") {
|
||||
t.Fatalf("stderr = %q, want static keychain error", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli apps +git-credential-init") {
|
||||
t.Fatalf("stderr = %q, want init retry hint", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "keychain locked") {
|
||||
t.Fatalf("stderr leaks keychain cause: %q", stderr)
|
||||
if !strings.Contains(errOut.String(), "keychain locked") {
|
||||
t.Fatalf("stderr = %q, want keychain error", errOut.String())
|
||||
}
|
||||
|
||||
kc.setErr = nil
|
||||
@@ -2272,189 +2165,6 @@ func TestNilManagerUsesTimeNow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactCredentialText focuses on the redaction regex, asserting it
|
||||
// covers credential shapes across forms and does not over-match concatenated
|
||||
// words. JSON-quoted forms are common in server-provided error bodies and must
|
||||
// be covered; concatenated words like mytoken must not be treated as token.
|
||||
func TestRedactCredentialText(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
sampleSecret := "sample-secret"
|
||||
githubLikeToken := testPublicSafeJoin("gh", "p_") + strings.Repeat("x", 20)
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "bare assignment",
|
||||
in: "permission denied: " +
|
||||
testCredentialAssignment("token", samplePAT) + " " +
|
||||
testCredentialAssignment("password", samplePassword),
|
||||
want: "permission denied: " +
|
||||
testRedactedAssignment("token") + " " +
|
||||
testRedactedAssignment("password"),
|
||||
},
|
||||
{
|
||||
name: "json double-quoted value",
|
||||
in: "body={" +
|
||||
testDoubleQuotedAssignment("password", samplePassword) + "," +
|
||||
testDoubleQuotedAssignment("token", samplePAT) +
|
||||
"}",
|
||||
want: "body={" +
|
||||
testDoubleQuotedRedactedAssignment("password") + "," +
|
||||
testDoubleQuotedRedactedAssignment("token") +
|
||||
"}",
|
||||
},
|
||||
{
|
||||
name: "json single-quoted value",
|
||||
in: "body={" + testSingleQuotedAssignment("secret", sampleSecret) + "}",
|
||||
want: "body={" + testSingleQuotedRedactedAssignment("secret") + "}",
|
||||
},
|
||||
{
|
||||
name: "colon separator with quoted value",
|
||||
in: testCredentialColon("token", `"`+samplePAT+`"`),
|
||||
want: testRedactedColon("token"),
|
||||
},
|
||||
{
|
||||
name: "url userinfo",
|
||||
in: "clone " + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT),
|
||||
want: "clone https://***@example.com/repo.git",
|
||||
},
|
||||
{
|
||||
name: "bearer header",
|
||||
in: testAuthorizationBearer(githubLikeToken),
|
||||
want: testRedactedAuthorizationBearer(),
|
||||
},
|
||||
{
|
||||
name: "pat-like standalone",
|
||||
in: "issued " + samplePAT + " for app",
|
||||
want: "issued <redacted> for app",
|
||||
},
|
||||
{
|
||||
name: "concatenated key not redacted",
|
||||
in: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
||||
want: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactCredentialText(tc.in); got != tc.want {
|
||||
t.Fatalf("RedactCredentialText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeCredentialErrorMessageFallbacks(t *testing.T) {
|
||||
if got := safeCredentialErrorMessage(nil); got != "" {
|
||||
t.Fatalf("safeCredentialErrorMessage(nil) = %q, want empty", got)
|
||||
}
|
||||
|
||||
if got := safeCredentialErrorMessage(&errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
}}); got != "config/invalid_config" {
|
||||
t.Fatalf("safeCredentialErrorMessage typed fallback = %q, want config/invalid_config", got)
|
||||
}
|
||||
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
got := safeCredentialErrorMessage(errors.New("transport failed with " + testCredentialAssignment("token", samplePAT)))
|
||||
if strings.Contains(got, samplePAT) {
|
||||
t.Fatalf("safeCredentialErrorMessage leaks credential value: %q", got)
|
||||
}
|
||||
if want := testRedactedAssignment("token"); !strings.Contains(got, want) {
|
||||
t.Fatalf("safeCredentialErrorMessage missing %q after redaction: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCredentialErrorRedactsTypedMessageHint(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
err := errs.NewInternalError(errs.SubtypeStorage, "save failed with %s", testCredentialAssignment("token", samplePAT)).
|
||||
WithHint("retry without %s", testCredentialAssignment("password", samplePAT)).
|
||||
WithLogID("log_x")
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeCredentialError(&buf, "Git credential refresh failed", err)
|
||||
got := buf.String()
|
||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("token", samplePAT), testCredentialAssignment("password", samplePAT)} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("writeCredentialError leaks credential value %q in %q", leaked, got)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Git credential refresh failed: save failed with " + testRedactedAssignment("token"),
|
||||
"log_id=log_x",
|
||||
"hint: retry without " + testRedactedAssignment("password"),
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("writeCredentialError output missing %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
writeCredentialError(nil, "ignored", err)
|
||||
writeCredentialError(&buf, "ignored", nil)
|
||||
}
|
||||
|
||||
func assertProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory || p.Subtype != wantSubtype {
|
||||
t.Fatalf("problem metadata = %s/%s, want %s/%s", p.Category, p.Subtype, wantCategory, wantSubtype)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicSafeJoin(parts ...string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func testCredentialAssignment(key, value string) string {
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func testRedactedAssignment(key string) string {
|
||||
return key + "=<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialColon(key, value string) string {
|
||||
return key + ": " + value
|
||||
}
|
||||
|
||||
func testRedactedColon(key string) string {
|
||||
return key + ": <redacted>"
|
||||
}
|
||||
|
||||
func testDoubleQuotedAssignment(key, value string) string {
|
||||
return `"` + key + `"` + ":" + `"` + value + `"`
|
||||
}
|
||||
|
||||
func testDoubleQuotedRedactedAssignment(key string) string {
|
||||
return `"` + key + `"` + ":<redacted>"
|
||||
}
|
||||
|
||||
func testSingleQuotedAssignment(key, value string) string {
|
||||
return `'` + key + `'` + ":" + `'` + value + `'`
|
||||
}
|
||||
|
||||
func testSingleQuotedRedactedAssignment(key string) string {
|
||||
return `'` + key + `'` + ":<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
||||
return "https://" + "user:" + credential + "@" + hostPath
|
||||
}
|
||||
|
||||
func testAuthorizationBearer(value string) string {
|
||||
return "Authorization" + ": " + "Bearer " + value
|
||||
}
|
||||
|
||||
func testRedactedAuthorizationBearer() string {
|
||||
return "Authorization" + ": " + "Bearer <redacted>"
|
||||
}
|
||||
|
||||
func testProfile() ProfileContext {
|
||||
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,25 +27,6 @@ type Manager struct {
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// credentialKeys is the shared list of credential field names to redact; the
|
||||
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
|
||||
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
|
||||
|
||||
var (
|
||||
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
||||
// credentialAssignmentRE matches credential key assignments, including JSON
|
||||
// quoted forms. Capture group 1 is the key and separator; only the value is
|
||||
// replaced with <redacted>. The key is one of three forms — double-quoted,
|
||||
// single-quoted, or bare with a word boundary — so concatenated words like
|
||||
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
|
||||
// alternation does not bind the quote/boundary to only the first and last key.
|
||||
credentialAssignmentRE = regexp.MustCompile(
|
||||
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
|
||||
)
|
||||
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
|
||||
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
|
||||
)
|
||||
|
||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||
return &Manager{
|
||||
Store: store,
|
||||
@@ -192,12 +172,12 @@ func (m *Manager) List() (*ListResult, error) {
|
||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
record, pat, ok, err := m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -207,28 +187,18 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
|
||||
// Lock ordering convention (see lock.go package comment): always acquire
|
||||
// lockApp before lockURL. lockApp is a cross-process file lock with a
|
||||
// timeout and possible setup failure; acquiring it first avoids holding an
|
||||
// in-process mutex on the failure path, which would risk a deadlock.
|
||||
unlock := lockURL(url)
|
||||
defer unlock()
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
// lockApp may already return a typed error, for example when creating
|
||||
// the lock directory fails. Preserve those classifications and only wrap
|
||||
// raw lockfile errors to add app context.
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
|
||||
return nil
|
||||
}
|
||||
defer unlockApp()
|
||||
unlockURL := lockURL(url)
|
||||
defer unlockURL()
|
||||
|
||||
record, pat, ok, err = m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -243,17 +213,16 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
|
||||
return nil
|
||||
}
|
||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if urlErr != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
|
||||
return nil
|
||||
}
|
||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if issuedURL != url {
|
||||
@@ -263,7 +232,7 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
if issued.ExpiresAt < record.ExpiresAt {
|
||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||
if readErr != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", readErr)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
|
||||
return nil
|
||||
}
|
||||
if found && m.usable(latest, latestPAT) {
|
||||
@@ -278,64 +247,17 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
record.Status = StatusConfirmed
|
||||
oldPAT := pat
|
||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
return writeGitCredential(out, record.Username, issued.PAT)
|
||||
}
|
||||
|
||||
func writeCredentialError(w io.Writer, prefix string, err error) {
|
||||
if w == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
|
||||
}
|
||||
|
||||
func safeCredentialErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
message := RedactCredentialText(p.Message)
|
||||
if p.LogID != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "log_id=" + p.LogID
|
||||
}
|
||||
if p.Hint != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "hint: " + RedactCredentialText(p.Hint)
|
||||
}
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
if p.Category != "" || p.Subtype != "" {
|
||||
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
|
||||
}
|
||||
}
|
||||
return RedactCredentialText(err.Error())
|
||||
}
|
||||
|
||||
// RedactCredentialText masks credential fragments that may appear in free
|
||||
// text, covering URL userinfo, Authorization bearer headers, credential
|
||||
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
|
||||
// the gitcred and apps packages so the redaction logic does not fork.
|
||||
func RedactCredentialText(text string) string {
|
||||
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
|
||||
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
|
||||
return text
|
||||
}
|
||||
|
||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil || len(records) == 0 {
|
||||
|
||||
@@ -42,15 +42,7 @@ func (s *SecretStore) Set(ref, pat string) error {
|
||||
Message: "keychain PAT reference is empty",
|
||||
}}
|
||||
}
|
||||
if err := s.kc.Set(KeychainService, ref, pat); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "save local Git credential PAT to keychain failed",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
return s.kc.Set(KeychainService, ref, pat)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Remove(ref string) error {
|
||||
@@ -72,7 +64,7 @@ func (s *SecretStore) Remove(ref string) error {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "remove local Git credential PAT from keychain failed",
|
||||
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}, Cause: err}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitcred manages the lifecycle of app Git credentials.
|
||||
//
|
||||
// Lock ordering convention — read this before adding any new lock acquisition:
|
||||
//
|
||||
// ALWAYS acquire lockApp BEFORE lockURL. Never invert this order.
|
||||
//
|
||||
// Rationale:
|
||||
// - lockApp is a cross-process file lock with bounded timeout (2s) and a
|
||||
// possible setup error; acquiring it first keeps the failure surface
|
||||
// outside any in-process lock and avoids holding the in-process mutex
|
||||
// while waiting on I/O / another process.
|
||||
// - lockURL is an in-process sync.Mutex that never fails and blocks
|
||||
// indefinitely; holding it while waiting on lockApp would risk
|
||||
// deadlocking with a concurrent goroutine that held lockApp first.
|
||||
//
|
||||
// Paths that only manipulate per-app state (Init, Remove, Erase) only need
|
||||
// lockApp. Get() is the only path that touches per-URL state in addition to
|
||||
// per-app state, so it is the only caller that takes both locks.
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
@@ -38,11 +20,6 @@ var urlLocks sync.Map
|
||||
|
||||
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// lockURL acquires an in-process, per-URL mutex. It never returns an error
|
||||
// and blocks until the mutex is available.
|
||||
//
|
||||
// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package
|
||||
// comment for the full convention.
|
||||
func lockURL(url string) func() {
|
||||
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
|
||||
mu := actual.(*sync.Mutex)
|
||||
@@ -50,12 +27,6 @@ func lockURL(url string) func() {
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// lockApp acquires a cross-process file lock scoped to the given appID. It
|
||||
// returns an unlock function or an error if the lock directory cannot be
|
||||
// created or the lock cannot be acquired within the 2s timeout.
|
||||
//
|
||||
// Lock ordering: when both lockApp and lockURL are needed, lockApp must be
|
||||
// taken FIRST. See package comment for the full convention.
|
||||
func lockApp(appID string) (func(), error) {
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -51,48 +50,13 @@ func pluginCheckProjectDir(projectPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePluginKey validates a plugin key for use in filesystem paths.
|
||||
// Rejects empty, ".", "..", absolute paths, path traversal, and control characters.
|
||||
func validatePluginKey(key string) error {
|
||||
if key == "" || key == "." || key == ".." {
|
||||
return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"")
|
||||
}
|
||||
if filepath.IsAbs(key) {
|
||||
return appsValidationError("invalid plugin key: must not be an absolute path: %q", key)
|
||||
}
|
||||
if strings.Contains(key, "..") {
|
||||
return appsValidationError("invalid plugin key: must not contain path traversal: %q", key)
|
||||
}
|
||||
for _, r := range key {
|
||||
if r < 32 || r == 127 {
|
||||
return appsValidationError("invalid plugin key: contains control character (code %d)", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secureModulePath validates the plugin key and joins it with
|
||||
// projectPath/node_modules, asserting the result stays within node_modules.
|
||||
func secureModulePath(projectPath, key string) (string, error) {
|
||||
if err := validatePluginKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
nodeModules := filepath.Join(projectPath, "node_modules")
|
||||
resolved := filepath.Clean(filepath.Join(nodeModules, key))
|
||||
expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator)
|
||||
if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) {
|
||||
return "", appsValidationError("plugin key %q resolves outside node_modules", key)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
|
||||
// 1. MIAODA_CAPABILITIES_DIR env var
|
||||
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
|
||||
// 2.5 Read .env.local for MIAODA_APP_TYPE
|
||||
// 3. Detect by checking which directories exist under projectPath
|
||||
func pluginResolveCapDir(projectPath string) (string, error) {
|
||||
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" {
|
||||
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
@@ -100,16 +64,10 @@ func pluginResolveCapDir(projectPath string) (string, error) {
|
||||
}
|
||||
|
||||
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
|
||||
appType := os.Getenv("MIAODA_APP_TYPE")
|
||||
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
|
||||
if appType == "" {
|
||||
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
|
||||
}
|
||||
if appType != "" {
|
||||
if _, err := strconv.Atoi(appType); err != nil {
|
||||
return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType).
|
||||
WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local")
|
||||
}
|
||||
}
|
||||
if appType == "6" {
|
||||
return filepath.Join(projectPath, "shared", "capabilities"), nil
|
||||
}
|
||||
@@ -199,11 +157,13 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
|
||||
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
||||
capDir, err := pluginResolveCapDir(projectPath)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict
|
||||
// No capabilities directory → no instances can exist → no conflict.
|
||||
return nil
|
||||
}
|
||||
caps, err := pluginListCapabilities(capDir)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // best-effort: scan failure should not block uninstall
|
||||
// Cannot scan → best-effort, don't block.
|
||||
return nil
|
||||
}
|
||||
var deps []string
|
||||
for _, cap := range caps {
|
||||
@@ -221,6 +181,26 @@ func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
||||
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
|
||||
}
|
||||
|
||||
// pluginCheckInstalled verifies that the plugin package is installed in node_modules
|
||||
// with a valid manifest.json.
|
||||
func pluginCheckInstalled(projectPath, pluginKey string) error {
|
||||
pluginDir := filepath.Join(projectPath, "node_modules", pluginKey)
|
||||
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check.
|
||||
if os.IsNotExist(err) {
|
||||
if pluginDirExists(pluginDir) {
|
||||
return appsFailedPreconditionError(
|
||||
"plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey,
|
||||
).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey)
|
||||
}
|
||||
return appsFailedPreconditionError("plugin %q is not installed", pluginKey).
|
||||
WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey)
|
||||
}
|
||||
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── package.json helpers ──
|
||||
|
||||
// pluginReadPackageJSON reads and parses the project's package.json.
|
||||
@@ -344,15 +324,13 @@ func pluginInstalledVersion(projectPath, pluginKey string) string {
|
||||
|
||||
// ── tgz extraction ──
|
||||
|
||||
const pluginExtractMaxBytes = 10 * 1024 * 1024
|
||||
|
||||
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
|
||||
// first path component (npm convention: tarballs contain a "package/" prefix).
|
||||
// Path traversal entries are silently skipped.
|
||||
func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
||||
return fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
@@ -364,7 +342,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
||||
return fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
|
||||
name := pluginStripFirstComponent(hdr.Name)
|
||||
@@ -382,8 +360,6 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeSymlink, tar.TypeLink:
|
||||
continue
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
|
||||
return err
|
||||
@@ -396,15 +372,11 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil {
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
cwd, _ := os.Getwd() //nolint:forbidigo
|
||||
if got != cwd {
|
||||
t.Errorf("got %q, want cwd %q", got, cwd)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
|
||||
|
||||
func TestPluginCheckProjectDir_OK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pluginCheckProjectDir(dir); err != nil {
|
||||
@@ -99,7 +99,7 @@ func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
@@ -113,7 +113,7 @@ func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
@@ -127,7 +127,7 @@ func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
@@ -141,10 +141,10 @@ func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||
|
||||
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := pluginResolveCapDir(dir)
|
||||
@@ -210,7 +210,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
||||
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
|
||||
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
|
||||
// non-JSON file should be skipped
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
||||
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interf
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,11 @@ var AppsPluginInstall = common.Shortcut{
|
||||
Description: "Install a plugin package (download, extract, update package.json)",
|
||||
Risk: "write",
|
||||
ConditionalScopes: []string{"spark:app:read"},
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate (install or update to latest)",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0 (install or update to specific version)",
|
||||
"Example: lark-cli apps +plugin-install (batch install all declared plugins from package.json actionPlugins)",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0",
|
||||
"Example: lark-cli apps +plugin-install (install all declared plugins in package.json)",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
|
||||
@@ -69,11 +67,6 @@ var AppsPluginInstall = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key := strings.TrimSpace(rctx.Str("name")); key != "" {
|
||||
if err := validatePluginKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -140,10 +133,7 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP
|
||||
}
|
||||
|
||||
// Extract to node_modules
|
||||
destDir, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
@@ -205,7 +195,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP
|
||||
continue
|
||||
}
|
||||
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err)
|
||||
return fmt.Errorf("install %s: %w", key, err)
|
||||
}
|
||||
installed++
|
||||
}
|
||||
@@ -227,7 +217,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
|
||||
}
|
||||
|
||||
// Extract to a temp dir first to read package.json
|
||||
tmpDir, err := os.MkdirTemp(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
|
||||
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "cannot create temp dir")
|
||||
}
|
||||
@@ -256,10 +246,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
|
||||
}
|
||||
|
||||
// Move to node_modules
|
||||
destDir, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
@@ -367,9 +354,6 @@ func pluginFindVersionInItems(data map[string]interface{}, key, version string)
|
||||
|
||||
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
|
||||
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
|
||||
|
||||
const pluginDownloadMaxBytes = 10 * 1024 * 1024
|
||||
|
||||
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
|
||||
apiPath := apiBasePath + "/plugin/versions/download_package"
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
@@ -395,7 +379,7 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
|
||||
WithRetryable()
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
hint := "check plugin key and version spelling"
|
||||
if resp.StatusCode == 403 {
|
||||
hint = "download token may have expired; retry the install to get a fresh token"
|
||||
@@ -405,16 +389,5 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
|
||||
WithHint(hint)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
|
||||
WithHint("check network connectivity and retry").
|
||||
WithRetryable().
|
||||
WithCause(err)
|
||||
}
|
||||
if len(data) > pluginDownloadMaxBytes {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)).
|
||||
WithHint("contact plugin maintainer to reduce package size")
|
||||
}
|
||||
return data, nil
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) {
|
||||
|
||||
// Verify file extracted
|
||||
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil {
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) {
|
||||
})
|
||||
// Create an existing installed plugin with package.json containing version
|
||||
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pkgDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
||||
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -126,7 +126,7 @@ func TestPluginExtractTGZ(t *testing.T) {
|
||||
t.Fatalf("extract error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json"))
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
|
||||
if err != nil {
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
|
||||
if err := pluginExtractTGZ(&buf, destDir); err != nil {
|
||||
t.Fatalf("extract should not error, but skip bad entries: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
|
||||
t.Error("path traversal should have been blocked")
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,9 @@ import (
|
||||
var AppsPluginList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-list",
|
||||
Description: "List locally installed plugin packages and their installation status",
|
||||
Description: "List declared plugin packages and their installation status",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-list",
|
||||
"Example: lark-cli apps +plugin-list --format pretty",
|
||||
},
|
||||
@@ -40,8 +40,8 @@ func TestPluginList_Installed(t *testing.T) {
|
||||
},
|
||||
})
|
||||
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(manifestDir, 0o755)
|
||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
|
||||
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -99,14 +99,14 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) {
|
||||
|
||||
func chdirTest(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
prev, err := os.Getwd()
|
||||
prev, err := os.Getwd() //nolint:forbidigo
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
|
||||
}
|
||||
|
||||
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||
@@ -115,7 +115,7 @@ func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -20,9 +21,7 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
Command: "+plugin-uninstall",
|
||||
Description: "Uninstall a plugin package (remove from node_modules and package.json)",
|
||||
Risk: "write",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
@@ -38,10 +37,8 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
Set("update_file", "package.json actionPlugins")
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if key := strings.TrimSpace(rctx.Str("name")); key == "" {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
} else if err := validatePluginKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
@@ -61,10 +58,7 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir, err := secureModulePath(projectPath, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
|
||||
return appsFileIOError(err, "cannot remove %s", pkgDir)
|
||||
}
|
||||
@@ -20,8 +20,8 @@ func TestPluginUninstall_Basic(t *testing.T) {
|
||||
},
|
||||
})
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -34,7 +34,7 @@ func TestPluginUninstall_Basic(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify node_modules removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
t.Error("node_modules plugin dir should be removed")
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
|
||||
})
|
||||
// Install plugin
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
|
||||
// Create a capability that references this plugin
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755)
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{
|
||||
"id": "my-instance",
|
||||
"pluginKey": "@test/my-plugin",
|
||||
@@ -100,7 +100,7 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify plugin directory still exists (blocked)
|
||||
if _, err := os.Stat(pluginDir); err != nil {
|
||||
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
|
||||
t.Errorf("plugin directory should still exist after blocked uninstall: %v", err)
|
||||
}
|
||||
|
||||
@@ -125,12 +125,12 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
|
||||
},
|
||||
})
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
|
||||
// Create a capability that references a DIFFERENT plugin — should not block
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755)
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{
|
||||
"id": "other-instance",
|
||||
"pluginKey": "@test/other-plugin",
|
||||
@@ -148,7 +148,7 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify plugin was removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
t.Error("plugin directory should be removed")
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
@@ -90,8 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
"extra_param": docsFetchExtraParam,
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -488,44 +487,6 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
extraParam, ok := body["extra_param"].(string)
|
||||
if !ok || extraParam == "" {
|
||||
t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"])
|
||||
}
|
||||
var got map[string]bool
|
||||
if err := json.Unmarshal([]byte(extraParam), &got); err != nil {
|
||||
t.Fatalf("decode extra_param %q: %v", extraParam, err)
|
||||
}
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, flag := range v2FetchFlags() {
|
||||
if flag.Name == "reference-map" {
|
||||
t.Fatal("fetch should not expose reference-map flag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -844,48 +805,20 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
|
||||
cmd.Flags().String("detail", fetchDefault("detail"), "")
|
||||
cmd.Flags().String("lang", fetchDefault("lang"), "")
|
||||
cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "")
|
||||
cmd.Flags().String("scope", fetchDefault("scope"), "")
|
||||
cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "")
|
||||
cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "")
|
||||
cmd.Flags().String("keyword", fetchDefault("keyword"), "")
|
||||
cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "")
|
||||
cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "")
|
||||
cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
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, "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
// fetchDefault returns the declared default for a flag from the real
|
||||
// v2FetchFlags definition so tests don't hardcode a stale default.
|
||||
// It panics if the flag is not found, since a missing flag indicates
|
||||
// a test setup error rather than a runtime condition.
|
||||
func fetchDefault(name string) string {
|
||||
for _, fl := range v2FetchFlags() {
|
||||
if fl.Name == name {
|
||||
return fl.Default
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("fetchDefault: flag %q not found in v2FetchFlags", name))
|
||||
}
|
||||
|
||||
// fetchDefaultInt returns the declared default for an int flag from
|
||||
// v2FetchFlags, parsed as an int. It panics if the flag is not found
|
||||
// or its default cannot be parsed as an int.
|
||||
func fetchDefaultInt(name string) int {
|
||||
s := fetchDefault(name)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
var d int
|
||||
if _, err := fmt.Sscanf(s, "%d", &d); err != nil {
|
||||
panic(fmt.Sprintf("fetchDefaultInt: flag %q default %q is not an int", name, s))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -900,17 +833,17 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("api-version", "", "")
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
|
||||
cmd.Flags().String("detail", fetchDefault("detail"), "")
|
||||
cmd.Flags().String("lang", fetchDefault("lang"), "")
|
||||
cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "")
|
||||
cmd.Flags().String("scope", fetchDefault("scope"), "")
|
||||
cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "")
|
||||
cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "")
|
||||
cmd.Flags().String("keyword", fetchDefault("keyword"), "")
|
||||
cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "")
|
||||
cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "")
|
||||
cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
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 != "" {
|
||||
@@ -942,7 +875,6 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("reference-map", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
|
||||
@@ -4,11 +4,9 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -63,116 +61,6 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag common.Flag
|
||||
for _, candidate := range v2UpdateFlags() {
|
||||
if candidate.Name == "reference-map" {
|
||||
flag = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if flag.Name == "" {
|
||||
t.Fatal("reference-map flag not found")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("reference-map flag should be public")
|
||||
}
|
||||
if flag.Type != "" {
|
||||
t.Fatalf("reference-map flag Type = %q, want default string", flag.Type)
|
||||
}
|
||||
if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if flag.Desc != docsUpdateReferenceMapFlagDesc {
|
||||
t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"command": "append",
|
||||
"content": `<p><widget data-ref="r1"></widget></p>`,
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
})
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
refMap, ok := body["reference_map"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
|
||||
}
|
||||
widget, _ := refMap["widget"].(map[string]interface{})
|
||||
r1, _ := widget["r1"].(map[string]interface{})
|
||||
if got := r1["label"]; got != "widget-ref-value" {
|
||||
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
|
||||
}
|
||||
if got, want := body["command"], "block_insert_after"; got != want {
|
||||
t.Fatalf("command = %#v, want %q", got, want)
|
||||
}
|
||||
if got, want := body["block_id"], "-1"; got != want {
|
||||
t.Fatalf("block_id = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "invalid json",
|
||||
setFlags: map[string]string{
|
||||
"reference-map": "{",
|
||||
},
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
setFlags: map[string]string{
|
||||
"reference-map": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "without content",
|
||||
setFlags: map[string]string{
|
||||
"content": "",
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported command",
|
||||
setFlags: map[string]string{
|
||||
"command": "block_move_after",
|
||||
"block-id": "blk_anchor",
|
||||
"src-block-ids": "blk_src",
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateUpdateV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateUpdateV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map")
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatal("validateUpdateV2() error lost underlying JSON cause")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -215,15 +103,6 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func hasUpdateTestInput(flag common.Flag, input string) bool {
|
||||
for _, candidate := range flag.Input {
|
||||
if candidate == input {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -234,7 +113,6 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("reference-map", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
|
||||
@@ -5,9 +5,7 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -24,15 +22,12 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
@@ -59,9 +54,6 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
|
||||
}
|
||||
content := runtime.Str("content")
|
||||
if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil {
|
||||
return err
|
||||
}
|
||||
pattern := runtime.Str("pattern")
|
||||
blockID := runtime.Str("block-id")
|
||||
srcBlockIDs := runtime.Str("src-block-ids")
|
||||
@@ -121,7 +113,7 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
body := buildUpdateBody(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
@@ -134,10 +126,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body, err := buildUpdateBodyWithReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "PUT", apiPath, body)
|
||||
if err != nil {
|
||||
@@ -149,24 +138,6 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
return body
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildUpdateBodyBase(runtime)
|
||||
if !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
}
|
||||
refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
body["reference_map"] = refMap
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cmd := runtime.Str("command")
|
||||
|
||||
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
|
||||
@@ -198,40 +169,3 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error {
|
||||
if !runtime.Changed("reference-map") {
|
||||
return nil
|
||||
}
|
||||
if !updateCommandAcceptsReferenceMap(command) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map")
|
||||
}
|
||||
if content == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map")
|
||||
}
|
||||
_, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
|
||||
return err
|
||||
}
|
||||
|
||||
func updateCommandAcceptsReferenceMap(command string) bool {
|
||||
switch command {
|
||||
case "str_replace", "block_insert_after", "block_replace", "overwrite", "append":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map")
|
||||
}
|
||||
var refMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err)
|
||||
}
|
||||
if refMap == nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map")
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
@@ -162,9 +162,6 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
@@ -216,9 +213,6 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
@@ -289,9 +283,6 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
if !strings.Contains(out, `"output_dir": "./exports"`) {
|
||||
t.Fatalf("stdout missing output_dir metadata: %s", out)
|
||||
}
|
||||
if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) {
|
||||
t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -342,9 +333,6 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -15,25 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
)
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
@@ -68,7 +49,7 @@ var SheetMediaUpload = common.Shortcut{
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetMediaParentType(parentNode),
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
@@ -90,7 +71,7 @@ var SheetMediaUpload = common.Shortcut{
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetMediaParentType(parentNode),
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
@@ -160,14 +141,13 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
|
||||
}
|
||||
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
parentType := sheetMediaParentType(parentNode)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: parentType,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
@@ -175,7 +155,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: parentType,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,39 +91,6 @@ func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadDryRunSmallFileOfficeParentType pins the small-file
|
||||
// upload_all dry-run preview to the token-derived parent_type so the preview
|
||||
// agents/users will copy matches what Execute actually sends. Without this the
|
||||
// multipart dry-run branch could drift back to a hard-coded "sheet_image".
|
||||
func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"office_sheet_file"`) {
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
@@ -238,47 +205,6 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image.
|
||||
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
const officeToken = "fake_office_abc123"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
"--file", "img.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeSheetsMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != officeSheetFileParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, officeSheetFileParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != officeToken {
|
||||
t.Fatalf("parent_node = %q, want %q", got, officeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
|
||||
@@ -50,42 +50,6 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
)
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
// uploadSheetImage uploads a local image file as a spreadsheet media asset and
|
||||
// returns its file_token. It funnels every sheets image upload through one
|
||||
// place so the parent_type selection (see sheetMediaParentType) is never
|
||||
// duplicated or forgotten at a call site. Callers are expected to have already
|
||||
// resolved spreadsheetToken (the upload's parent_node) and stat'd the file.
|
||||
func uploadSheetImage(runtime *common.RuntimeContext, spreadsheetToken, filePath, fileName string, fileSize int64) (string, error) {
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetMediaParentType(spreadsheetToken),
|
||||
ParentNode: &spreadsheetToken,
|
||||
})
|
||||
}
|
||||
|
||||
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
|
||||
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
|
||||
// wiki node that must be resolved to its backing spreadsheet at Execute time.
|
||||
|
||||
@@ -861,10 +861,10 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
|
||||
manageBody, _ := buildToolBody("manage_float_image_object", input)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||||
Desc("upload local image to drive (parent_type=sheet_image)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": floatImageName(runtime),
|
||||
"parent_type": sheetMediaParentType(token),
|
||||
"parent_type": "sheet_image",
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + img,
|
||||
@@ -918,7 +918,13 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
|
||||
if err != nil {
|
||||
return "", sheetsInputStatError("image", err)
|
||||
}
|
||||
return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size())
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: img,
|
||||
FileName: floatImageName(runtime),
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &spreadsheetToken,
|
||||
})
|
||||
}
|
||||
|
||||
func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) {
|
||||
|
||||
@@ -791,10 +791,10 @@ var CellsSetImage = common.Shortcut{
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||||
Desc("upload local image to drive (parent_type=sheet_image)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetMediaParentType(token),
|
||||
"parent_type": "sheet_image",
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + imgPath,
|
||||
@@ -832,7 +832,13 @@ var CellsSetImage = common.Shortcut{
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
}
|
||||
fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size())
|
||||
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: imgPath,
|
||||
FileName: fileName,
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -496,31 +496,6 @@ func TestCellsSetImage_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRunOfficeParentType confirms that an imported "office"
|
||||
// spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image, and that the
|
||||
// preview's parent_node carries the same token.
|
||||
func TestCellsSetImage_DryRunOfficeParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
const officeToken = "fake_office_abc123"
|
||||
calls := parseDryRunAPI(t, CellsSetImage, []string{
|
||||
"--spreadsheet-token", officeToken, "--sheet-id", testSheetID,
|
||||
"--range", "A1",
|
||||
"--image", "./README.md", // any existing-shaped path; dry-run skips stat
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
|
||||
}
|
||||
upload := calls[0].(map[string]interface{})
|
||||
ubody, _ := upload["body"].(map[string]interface{})
|
||||
if ubody["parent_type"] != officeSheetFileParentType {
|
||||
t.Errorf("parent_type = %v, want %s", ubody["parent_type"], officeSheetFileParentType)
|
||||
}
|
||||
if ubody["parent_node"] != officeToken {
|
||||
t.Errorf("parent_node = %v, want %s", ubody["parent_node"], officeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
|
||||
// synthetic token and must upload with "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
want string
|
||||
}{
|
||||
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
|
||||
{"empty token", "", sheetImageParentType},
|
||||
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sheetMediaParentType(tc.token); got != tc.want {
|
||||
t.Fatalf("sheetMediaParentType(%q) = %q, want %q", tc.token, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadSheetImage_ParentType exercises the uploadSheetImage collector end
|
||||
// to end (the Execute path the dry-run tests don't reach), asserting the
|
||||
// parent_type that actually goes out on the wire is derived from the token: a
|
||||
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
wantParentType string
|
||||
}{
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime, reg := newSheetMediaTestRuntime(t)
|
||||
// UploadDriveMediaAllTyped opens the file via the runtime's FileIO,
|
||||
// which sandboxes paths to the current working directory; chdir to a
|
||||
// temp dir and pass a relative name so the open is allowed.
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
fileToken, err := uploadSheetImage(runtime, tc.token, "img.png", "img.png", 9)
|
||||
if err != nil {
|
||||
t.Fatalf("uploadSheetImage() error: %v", err)
|
||||
}
|
||||
if fileToken != "boxTOK123" {
|
||||
t.Fatalf("file_token = %q, want boxTOK123", fileToken)
|
||||
}
|
||||
|
||||
body := decodeSheetMediaMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != tc.wantParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, tc.wantParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != tc.token {
|
||||
t.Fatalf("parent_node = %q, want %q", got, tc.token)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "img.png" {
|
||||
t.Fatalf("file_name = %q, want img.png", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadSheetImage_FileOpenError confirms a missing image surfaces as a
|
||||
// typed validation error (category=validation, subtype=invalid_argument) with
|
||||
// the original os-level cause preserved for errors.Is, and proves the upload
|
||||
// endpoint is never hit. No httpmock stub is registered, so if uploadSheetImage
|
||||
// ever tried to POST upload_all the RoundTrip would return a
|
||||
// "no stub for POST ..." network failure — that would surface as a
|
||||
// non-validation category and fail the metadata assertion below. The
|
||||
// category=validation + fs.ErrNotExist cause therefore strictly implies the
|
||||
// short-circuit happened before the wire.
|
||||
func TestUploadSheetImage_FileOpenError(t *testing.T) {
|
||||
runtime, _ := newSheetMediaTestRuntime(t)
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, err := uploadSheetImage(runtime, "shtcnTOK123", "missing.png", "missing.png", 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v; want typed problem carrier", err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q (non-validation implies the upload endpoint was reached)", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("err = %v; want wrapped fs.ErrNotExist cause to be preserved", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newSheetMediaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-sheets-media-" + t.Name(),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "sheets"}, cfg, f, core.AsBot)
|
||||
return runtime, reg
|
||||
}
|
||||
|
||||
type sheetMediaCapturedMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeSheetMediaMultipartBody(t *testing.T, stub *httpmock.Stub) sheetMediaCapturedMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := sheetMediaCapturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(part); err != nil {
|
||||
t.Fatalf("read multipart body for %q: %v", part.FormName(), err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = buf.Bytes()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = buf.String()
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -11,8 +11,6 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +204,13 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the URL returned by presentation.create. Fall back to a local
|
||||
// brand-standard URL only when the API omits it.
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_abc123",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -55,8 +54,10 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -646,12 +647,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
|
||||
// constructed locally from the token when presentation.create omits url — no
|
||||
// drive metas/batch_query call is made, so creation works for users who only
|
||||
// authorized slides scopes. The httpmock registry has no batch_query stub
|
||||
// registered; if the shortcut tried to call it, the request would fail the test.
|
||||
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -664,7 +665,6 @@ func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
|
||||
// It deliberately creates the new page before deleting the old one so a create
|
||||
// failure cannot remove existing user content. The operation is not atomic.
|
||||
const replacePagesInitialRevisionID = -1
|
||||
|
||||
var SlidesReplacePages = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+replace-pages",
|
||||
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReplacePagesInput(pages)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
appendReplacePagesDryRunCalls(dry, resolved)
|
||||
return dry.
|
||||
Set("xml_presentation_id", resolved.PresentationID).
|
||||
Set("pages_count", len(resolved.Plan)).
|
||||
Set("plan", replacePagesPlanOutput(resolved.Plan)).
|
||||
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("validate-only") {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"plan": replacePagesPlanOutput(resolved.Plan),
|
||||
"status": "validated",
|
||||
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
revisionID := replacePagesInitialRevisionID
|
||||
results := make([]replacePageResult, 0, len(resolved.Plan))
|
||||
for i, item := range resolved.Plan {
|
||||
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
|
||||
results = append(results, result)
|
||||
if result.RevisionID != nil {
|
||||
revisionID = *result.RevisionID
|
||||
}
|
||||
if err != nil {
|
||||
if runtime.Bool("continue-on-error") {
|
||||
continue
|
||||
}
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"results": replacePageResultsOutput(results),
|
||||
"status": "completed",
|
||||
"summary": replacePagesSummaryOutput(results),
|
||||
"note": "batch replace is not atomic; each page was created before its old page was deleted",
|
||||
}
|
||||
if revisionID != replacePagesInitialRevisionID {
|
||||
out["revision_id"] = revisionID
|
||||
}
|
||||
if hasReplacePageFailures(results) {
|
||||
out["status"] = "partial_failure"
|
||||
return runtime.OutPartialFailure(out, nil)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type replacePageInput struct {
|
||||
SlideID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type replacePagePlanItem struct {
|
||||
OldSlideID string
|
||||
Content string
|
||||
Locator string
|
||||
}
|
||||
|
||||
type replacePagesPrepared struct {
|
||||
PresentationID string
|
||||
Plan []replacePagePlanItem
|
||||
}
|
||||
|
||||
type replacePageResult struct {
|
||||
OldSlideID string
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateReplacePagesInput(pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := buildReplacePagesPlan(pages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func parseReplacePages(raw string) ([]replacePageInput, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
out := make([]replacePageInput, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
p := replacePageInput{}
|
||||
if v, ok := m["slide_number"]; ok {
|
||||
_ = v
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
|
||||
}
|
||||
if v, ok := m["slide_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.SlideID = s
|
||||
}
|
||||
if v, ok := m["content"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.Content = s
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateReplacePagesInput(pages []replacePageInput) error {
|
||||
if len(pages) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, p := range pages {
|
||||
id := strings.TrimSpace(p.SlideID)
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
|
||||
}
|
||||
if err := validateCompleteSlideXML(p.Content); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteSlideXML(content string) error {
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
depth := 0
|
||||
seenRoot := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return invalidSlideXMLStructureError("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
|
||||
}
|
||||
seenRoot = true
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && strings.TrimSpace(string(t)) != "" {
|
||||
return invalidSlideXMLStructureError("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return invalidSlideXMLStructureError("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return invalidSlideXMLStructureError("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
|
||||
plan := make([]replacePagePlanItem, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
id := strings.TrimSpace(page.SlideID)
|
||||
plan = append(plan, replacePagePlanItem{
|
||||
OldSlideID: id,
|
||||
Content: page.Content,
|
||||
Locator: "slide_id",
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
|
||||
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
|
||||
for i, item := range resolved.Plan {
|
||||
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
})
|
||||
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": "<revision_returned_by_create>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
|
||||
result := replacePageResult{
|
||||
OldSlideID: item.OldSlideID,
|
||||
Status: "pending",
|
||||
}
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
createData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": revisionID},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
newSlideID := common.GetString(createData, "slide_id")
|
||||
if newSlideID == "" {
|
||||
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
|
||||
deleteData, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
slideURL,
|
||||
map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": revisionID,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "delete_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if rev, ok := revisionFromData(deleteData); ok {
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
result.Status = "replaced"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func revisionFromData(data map[string]interface{}) (int, bool) {
|
||||
if _, ok := data["revision_id"]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(common.GetFloat(data, "revision_id")), true
|
||||
}
|
||||
|
||||
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
out = append(out, map[string]interface{}{
|
||||
"old_slide_id": item.OldSlideID,
|
||||
"insert_before_slide_id": item.OldSlideID,
|
||||
"locator": item.Locator,
|
||||
"action": "create_before_then_delete_old",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(results))
|
||||
for _, result := range results {
|
||||
m := map[string]interface{}{
|
||||
"old_slide_id": result.OldSlideID,
|
||||
"status": result.Status,
|
||||
}
|
||||
if result.NewSlideID != "" {
|
||||
m["new_slide_id"] = result.NewSlideID
|
||||
}
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
|
||||
replaced := countReplacedPages(results)
|
||||
return map[string]interface{}{
|
||||
"replaced": replaced,
|
||||
"failed": len(results) - replaced,
|
||||
"total": len(results),
|
||||
}
|
||||
}
|
||||
|
||||
func countReplacedPages(results []replacePageResult) int {
|
||||
n := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "replaced" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasReplacePageFailures(results []replacePageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Status == "create_failed" || result.Status == "delete_failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestReplacePagesDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var requestOrder []string
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
var deleteQuery map[string][]string
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
deleteQuery = req.URL.Query()
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody struct {
|
||||
Slide struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"slide"`
|
||||
BeforeSlideID string `json:"before_slide_id"`
|
||||
}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
|
||||
}
|
||||
if createBody.BeforeSlideID != "old2" {
|
||||
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
|
||||
}
|
||||
if !strings.Contains(createBody.Slide.Content, "<slide") {
|
||||
t.Fatalf("create content = %q", createBody.Slide.Content)
|
||||
}
|
||||
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
|
||||
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
|
||||
t.Fatalf("delete slide_id = %#v, want old2", got)
|
||||
}
|
||||
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
|
||||
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(12) {
|
||||
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["failed"] != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[
|
||||
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
|
||||
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
|
||||
]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
data := env.Data
|
||||
if data["status"] != "partial_failure" {
|
||||
t.Fatalf("status = %v, want partial_failure", data["status"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
|
||||
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if first["status"] != "create_failed" {
|
||||
t.Fatalf("first status = %v, want create_failed", first["status"])
|
||||
}
|
||||
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
|
||||
t.Fatalf("second result = %#v, want replaced with new2", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
results, _ := env.Data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["status"] != "delete_failed" {
|
||||
t.Fatalf("status = %v, want delete_failed", first["status"])
|
||||
}
|
||||
if first["new_slide_id"] != "new1" {
|
||||
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if out["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
|
||||
}
|
||||
plan, _ := out["plan"].([]interface{})
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan len = %d, want 1", len(plan))
|
||||
}
|
||||
item, _ := plan[0].(map[string]interface{})
|
||||
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
|
||||
t.Fatalf("plan item = %#v", item)
|
||||
}
|
||||
api, _ := out["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("api len = %d, want create/delete plan", len(api))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesValidationParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pages string
|
||||
}{
|
||||
{"empty pages", `[]`},
|
||||
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
|
||||
{"no locator", `[{"content":"<slide/>"}]`},
|
||||
{"empty content", `[{"slide_id":"s1","content":" "}]`},
|
||||
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
|
||||
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", tt.pages,
|
||||
"--as", "user",
|
||||
})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %v, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--pages" {
|
||||
t.Fatalf("Param = %q, want --pages", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type replacePagesEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
|
||||
t.Helper()
|
||||
var env replacePagesEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("missing data: %#v", env)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -43,10 +43,8 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
Command: "+replace-slide",
|
||||
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
|
||||
@@ -55,15 +53,9 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,21 +15,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestReplaceSlideDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
|
||||
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
|
||||
// onto the root before sending. The backend returns 3350001 otherwise.
|
||||
|
||||
@@ -34,9 +34,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,23 +17,11 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"presentation_id": "pres_abc",
|
||||
"revision_id": 7,
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "readback.xml",
|
||||
"--revision-id", "7",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "readback.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
t.Fatalf("revision_id query = %q, want 7", got)
|
||||
}
|
||||
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
|
||||
t.Fatalf("remove_attr_id query = %q, want true", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "readback.xml") {
|
||||
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "pres_real",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": `<presentation/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--output", "wiki.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_real" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "../readback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", validationErr.Param)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
|
||||
@@ -150,8 +150,6 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
||||
- 日期时间 `2026-04-15T10:00:00`
|
||||
- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00`
|
||||
|
||||
> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC)。CI(UTC)与本地(如 UTC+8)跑同一条命令,时间边界会差几小时;要精确锁定时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。`--target`(PITR 恢复)尤其建议带时区,避免恢复到非预期时间点。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
||||
|
||||
@@ -57,7 +57,7 @@ lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --out
|
||||
```
|
||||
|
||||
### +file-upload
|
||||
上传一个本地文件。文件名沿用本地文件名(特殊字符做 URL 编码透传;以 `.` 开头的隐藏文件名会加 `_` 前缀,避免下载回本地时覆盖隐藏文件),远端路径由平台分配。单文件上限 100 MB。
|
||||
上传一个本地文件。文件名沿用本地文件名,远端路径由平台分配。单文件上限 100 MB。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf
|
||||
@@ -86,8 +86,6 @@ lark-cli apps +file-quota-get --app-id app_xxx
|
||||
- 日期时间 `2026-04-15T10:00:00`
|
||||
- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00`
|
||||
|
||||
> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC 发给服务端)。CI(UTC)与本地(如 UTC+8)跑同一条命令,过滤边界会差几小时;要精确到某时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# apps +plugin-install
|
||||
|
||||
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
|
||||
|
||||
安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# apps +plugin-list
|
||||
|
||||
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
|
||||
|
||||
列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# apps +plugin-uninstall
|
||||
|
||||
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
|
||||
|
||||
卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
@@ -22,7 +22,7 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
|
||||
## 输出契约
|
||||
|
||||
- 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min,整体超时时间大约 5min。
|
||||
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。
|
||||
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
@@ -8,6 +8,10 @@ metadata:
|
||||
cliHelp: "lark-cli contact --help"
|
||||
---
|
||||
|
||||
# contact (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# +search-user
|
||||
|
||||
仅支持 user 身份。
|
||||
仅 user 身份。需要 scope `contact:user:search`。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- ✅ 已知姓名 / 邮箱 / 「聊过的人」想找出 open_id
|
||||
- ✅ 已知一组 open_id 想批量校验或回填字段(`--user-ids`,最多 100,支持 `me`)
|
||||
- ✅ 按聊天关系 / 在职状态 / 租户边界 / 企业邮箱等维度筛选员工
|
||||
- ❌ 已知 open_id 想拿完整 profile → 用 `+get-user --as bot`
|
||||
- ❌ 已知 open_id 想发消息 → 直接走 `lark-im`,不经过本命令
|
||||
|
||||
## 关键 flag
|
||||
|
||||
@@ -33,7 +33,6 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
|
||||
@@ -34,9 +34,9 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
|
||||
- 图片:`<img token="..." .../>`
|
||||
- 文件:`<source token="..." name="..."/>`
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
- 画板:`<whiteboard token="..."/>`
|
||||
|
||||
## 排障
|
||||
|
||||
@@ -30,9 +30,9 @@ lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
|
||||
- 图片:`<img token="..." .../>`
|
||||
- 文件:`<source token="..." name="..."/>`
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user