mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
36 Commits
sun/docs-u
...
feat/embed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f23badfea | ||
|
|
097bccf060 | ||
|
|
d418e25281 | ||
|
|
e32fdb8c7f | ||
|
|
37d490a198 | ||
|
|
4e44e51bef | ||
|
|
e79d49e7e4 | ||
|
|
83352fe00b | ||
|
|
21bfa84edd | ||
|
|
fc8d212a4f | ||
|
|
2aab338374 | ||
|
|
b9d4dce92a | ||
|
|
9ead47e525 | ||
|
|
15d0254b91 | ||
|
|
a5c2a45ecf | ||
|
|
fc630ba8f9 | ||
|
|
70cee047a2 | ||
|
|
0d3ecdb990 | ||
|
|
35049e8d30 | ||
|
|
d8782e715a | ||
|
|
7675185f9d | ||
|
|
1ab853023a | ||
|
|
e96c4fa581 | ||
|
|
4847f06ca8 | ||
|
|
452734f824 | ||
|
|
0dd844c2c5 | ||
|
|
4a4cc1e0cf | ||
|
|
e967571829 | ||
|
|
b1205b68d2 | ||
|
|
519a600b62 | ||
|
|
d87d9b458a | ||
|
|
1173179b10 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -86,8 +86,10 @@ jobs:
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
- name: Run source-contract lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
- name: Run lint module tests
|
||||
run: go test -C lint ./... -count=1
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -105,6 +105,20 @@ Signatures that are easy to guess wrong:
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Typed data over loose maps
|
||||
|
||||
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
|
||||
|
||||
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
|
||||
|
||||
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
|
||||
|
||||
### Transcribe faithfully — no silent fallbacks
|
||||
|
||||
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
|
||||
|
||||
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
@@ -116,6 +130,7 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -2,6 +2,77 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
|
||||
- **slides**: add slides chart demo reference
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- register and consume --json shorthand for custom-format shortcuts (#1737)
|
||||
- **drive**: abort push on parent sibling limit (#1813)
|
||||
|
||||
### Documentation
|
||||
|
||||
- require native charts in slide planning
|
||||
- register knowledge organize workflow (#1828)
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
@@ -1398,6 +1469,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
|
||||
@@ -130,6 +130,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
if opts.Method == "" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"HTTP method must not be empty").
|
||||
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
||||
WithParam("<method>")
|
||||
}
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
@@ -243,9 +250,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
return apiDryRun(f, request, config, opts)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -297,8 +304,19 @@ func apiRun(opts *APIOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
@@ -79,12 +79,42 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Dry Run") {
|
||||
t.Error("expected dry run output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/test") {
|
||||
t.Error("expected path in dry run output")
|
||||
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", got["data"])
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
if strings.Contains(stdout.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +182,22 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty HTTP method")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "method") {
|
||||
t.Fatalf("error should name the method argument, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -1000,11 +1046,23 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
20
cmd/build.go
20
cmd/build.go
@@ -25,8 +25,10 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -42,6 +44,18 @@ type buildConfig struct {
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
// any command registration touches the runtime catalog. Without it the
|
||||
// registry's sync.Once locks onto the Feishu default at first catalog access,
|
||||
// long before the lazily-resolved config brand is known — see
|
||||
// ResolveStartupBrand for the caller-side resolution.
|
||||
func WithStartupBrand(brand core.LarkBrand) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.startupBrand = brand
|
||||
}
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
@@ -154,6 +168,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
}
|
||||
|
||||
// Initialize the registry brand before anything touches the runtime
|
||||
// catalog (its sync.Once would otherwise lock onto the Feishu default).
|
||||
if cfg.startupBrand != "" {
|
||||
registry.InitWithBrand(cfg.startupBrand)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
|
||||
@@ -916,25 +916,6 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBrand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "feishu"},
|
||||
{"feishu", "feishu"},
|
||||
{"lark", "lark"},
|
||||
{"LARK", "lark"},
|
||||
{" lark ", "lark"},
|
||||
{"Lark", "lark"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeBrand(tt.input); got != tt.want {
|
||||
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
|
||||
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
|
||||
custom := filepath.Join(t.TempDir(), "custom.json")
|
||||
|
||||
@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
Brand: core.ParseBrand(selected.Brand),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -350,16 +350,6 @@ func sourceDisplayName(source string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBrand applies .strip().lower() and defaults to "feishu".
|
||||
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
|
||||
func normalizeBrand(raw string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return "feishu"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveHermesEnvPath returns the path to Hermes's .env file.
|
||||
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
|
||||
//
|
||||
|
||||
@@ -5,7 +5,9 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -180,9 +182,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
return nil, classifyRegistrationBeginError(err)
|
||||
}
|
||||
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
@@ -208,33 +210,17 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
// Step 4: Poll for credentials (brand discovery lives in internal/auth);
|
||||
// this layer only classifies the terminal error and saves the result.
|
||||
result, finalBrand, err := larkauth.RegisterAppWithDiscovery(ctx, httpClient, authResp, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
return nil, classifyRegistrationError(err)
|
||||
}
|
||||
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
finalBrand := larkBrand
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
|
||||
|
||||
@@ -245,3 +231,40 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
AppSecret: result.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// classifyRegistrationBeginError keeps transport/cancellation failures out of
|
||||
// the invalid-client category: the begin request sends no app credentials.
|
||||
func classifyRegistrationBeginError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration cancelled").WithCause(err)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "app registration begin timed out: %v", err).WithCause(err)
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if netErr.Timeout() {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "app registration begin failed: %v", err).WithCause(err)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "app registration begin failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// classifyRegistrationError maps registration terminal outcomes to typed
|
||||
// errors, preserving causes.
|
||||
func classifyRegistrationError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, larkauth.ErrRegistrationDenied):
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).
|
||||
WithHint("re-run `lark-cli config init --new` and approve the authorization request").
|
||||
WithCause(err)
|
||||
case errors.Is(err, larkauth.ErrRegistrationExpired), errors.Is(err, larkauth.ErrRegistrationTimedOut):
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenExpired, "%v", err).
|
||||
WithHint("re-run `lark-cli config init --new` and complete the scan before the code expires").
|
||||
WithCause(err)
|
||||
default:
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
70
cmd/config/init_interactive_test.go
Normal file
70
cmd/config/init_interactive_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
)
|
||||
|
||||
func assertRegistrationProblem(t *testing.T, got, cause error, category errs.Category, subtype errs.Subtype) *errs.Problem {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("error %T is not typed: %v", got, got)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Errorf("problem = (%q, %q), want (%q, %q)", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
if !errors.Is(got, cause) {
|
||||
t.Errorf("error %v does not preserve cause %v", got, cause)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestClassifyRegistrationBeginError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{"cancelled", context.Canceled, errs.CategoryAuthentication, errs.SubtypeUnknown},
|
||||
{"deadline", context.DeadlineExceeded, errs.CategoryNetwork, errs.SubtypeNetworkTimeout},
|
||||
{"transport", &net.DNSError{Err: "lookup failed", Name: "accounts.example"}, errs.CategoryNetwork, errs.SubtypeNetworkTransport},
|
||||
{"response", errors.New("response not JSON"), errs.CategoryAPI, errs.SubtypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assertRegistrationProblem(t, classifyRegistrationBeginError(tt.err), tt.err, tt.category, tt.subtype)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyRegistrationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
hint bool
|
||||
}{
|
||||
{"denied", larkauth.ErrRegistrationDenied, errs.SubtypeUnknown, true},
|
||||
{"expired", larkauth.ErrRegistrationExpired, errs.SubtypeTokenExpired, true},
|
||||
{"timed-out", larkauth.ErrRegistrationTimedOut, errs.SubtypeTokenExpired, true},
|
||||
{"cancelled", context.Canceled, errs.SubtypeUnknown, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := assertRegistrationProblem(t, classifyRegistrationError(tt.err), tt.err, errs.CategoryAuthentication, tt.subtype)
|
||||
if (p.Hint != "") != tt.hint {
|
||||
t.Errorf("hint = %q, want non-empty=%v", p.Hint, tt.hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
70
cmd/dispatch_golden_test.go
Normal file
70
cmd/dispatch_golden_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/envelope"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestDispatchErrorGoldenParity asserts the public envelope.DispatchError
|
||||
// triple is byte-identical to what the real root dispatcher
|
||||
// (handleRootError) writes to stderr, with the same exit code, for every
|
||||
// error class (spec G1-G6). Comparison happens at the dispatch boundary:
|
||||
// need_user_authorization hint folding runs before dispatch and is not part
|
||||
// of the public contract, so cases here must not depend on it.
|
||||
func TestDispatchErrorGoldenParity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"G1_typed_validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing --id")},
|
||||
{"G2_typed_extension_fields", &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypePermissionDenied,
|
||||
Code: 99991679,
|
||||
Message: "missing required scopes",
|
||||
Hint: "re-auth with the listed scopes",
|
||||
},
|
||||
MissingScopes: []string{"im:message", "docs:doc"},
|
||||
Identity: "user",
|
||||
}},
|
||||
{"G3_confirmation_required", errs.NewConfirmationRequiredError(
|
||||
"high-risk-write", "drive +delete", "drive +delete requires confirmation")},
|
||||
{"G4_partial_failure", output.PartialFailure(1)},
|
||||
{"G5_cobra_usage", fmt.Errorf(`required flag(s) "values" not set`)},
|
||||
{"G6_leaked_untyped", errors.New("boom")},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
realExit := handleRootError(f, tc.err)
|
||||
env, code, has := envelope.DispatchError(tc.err, string(f.ResolvedIdentity))
|
||||
|
||||
if code != realExit {
|
||||
t.Errorf("exit code: public %d, real dispatcher %d", code, realExit)
|
||||
}
|
||||
if has != (errOut.Len() > 0) {
|
||||
t.Errorf("hasEnvelope=%v but real stderr len=%d", has, errOut.Len())
|
||||
}
|
||||
if !bytes.Equal(env, errOut.Bytes()) {
|
||||
t.Errorf("envelope bytes differ\npublic: %s\nreal: %s", env, errOut.Bytes())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,40 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", 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())
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
for _, field := range []string{
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"reply_to",
|
||||
"sender_type",
|
||||
"mentions",
|
||||
} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("receive schema missing field %q", field)
|
||||
}
|
||||
}
|
||||
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
|
||||
if !strings.Contains(msgDesc, "Recommended idempotency key") {
|
||||
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
|
||||
}
|
||||
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
|
||||
if strings.Contains(eventDesc, "safe for deduplication") {
|
||||
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
|
||||
112
cmd/root.go
112
cmd/root.go
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -107,6 +106,7 @@ func Execute() int {
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -228,109 +228,25 @@ func configureFlagCompletions(args []string) {
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
||||
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
|
||||
// render via the typed envelope writer, which lifts extension fields
|
||||
// (missing_scopes, console_url, challenge_url, ...) to the top level.
|
||||
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
|
||||
// constructed typed at their origin (internal/auth, internal/core), so the
|
||||
// dispatcher no longer promotes any legacy shape here.
|
||||
// 2. PartialFailure / BareError signals: the result envelope is already on
|
||||
// stdout; honor the exit code and write nothing to stderr.
|
||||
// 3. Residual cobra usage errors (missing required flag, unknown command,
|
||||
// argument validation): typed as an invalid_argument envelope (exit 2),
|
||||
// matching the explicit flag/subcommand guards. Flag parse errors are
|
||||
// already typed upstream by the root FlagErrorFunc.
|
||||
// and returns the process exit code. The classification itself (typed
|
||||
// envelope vs. PartialFailure/Bare signal vs. cobra-usage vs. leaked-untyped)
|
||||
// lives in output.DispatchError so the public extension/envelope facade
|
||||
// shares the exact same branches — see its doc comment for the dispatch
|
||||
// order.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
// current command's declared scopes as a Hint. The hint depends on the
|
||||
// Factory, so it stays in the cmd layer — it is applied before dispatch
|
||||
// and is not part of the public DispatchError contract.
|
||||
if !errs.IsRaw(err) {
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
}
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
|
||||
// (partial-write still returns true) so the exit code we read here is
|
||||
// preserved even if stderr is torn — torn stderr must not downgrade
|
||||
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the signal / plain-text paths.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
env, code, has := output.DispatchError(err, string(f.ResolvedIdentity))
|
||||
if has {
|
||||
// Best-effort write: a torn stderr must not downgrade the typed exit.
|
||||
_, _ = f.IOStreams.ErrOut.Write(env)
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
|
||||
// stdout already carries the result; honor the requested exit code and
|
||||
// write nothing to stderr.
|
||||
var bareErr *output.BareError
|
||||
if errors.As(err, &bareErr) {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// Errors reaching here are untyped: every RunE returns a typed errs.* error
|
||||
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
|
||||
// is either a cobra usage mistake (missing required flag, unknown command,
|
||||
// wrong arg count), which cobra surfaces as a plain error identified by its
|
||||
// stable text — the same external contract unknownFlagName relies on — or an
|
||||
// untyped error that leaked past the typed boundary. Classify the former as
|
||||
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
|
||||
// internal fault (exit 5) rather than blaming the user's input. The message
|
||||
// is preserved either way, and the typed envelope still carries any pending
|
||||
// deprecation notice.
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
||||
return output.ExitCodeOf(fallback)
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return code
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
|
||||
@@ -403,9 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
return serviceDryRun(f, request, config, opts)
|
||||
}
|
||||
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -667,8 +667,19 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
|
||||
@@ -224,13 +224,39 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), tt.wantInURL) {
|
||||
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
if call["url"] != tt.wantInURL {
|
||||
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"file_token":"boxcn123abc"}`,
|
||||
"--dry-run",
|
||||
"--jq", ".data.api[0].url",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
|
||||
t.Fatalf("jq output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -318,8 +344,12 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Dry Run") {
|
||||
t.Error("expected dry-run output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,11 +1111,23 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
cmd/startup_brand.go
Normal file
28
cmd/startup_brand.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// ResolveStartupBrand resolves the brand before the command tree is built, so
|
||||
// the registry's remote metadata overlay uses the configured brand from the
|
||||
// first catalog access. It mirrors the credential chain's brand precedence —
|
||||
// environment, then the active profile's raw config entry — without touching
|
||||
// the keychain (no secrets are needed to know the brand).
|
||||
func ResolveStartupBrand(profile string) core.LarkBrand {
|
||||
if raw := os.Getenv(envvars.CliBrand); raw != "" {
|
||||
return core.ParseBrand(raw)
|
||||
}
|
||||
if cfg, err := core.LoadMultiAppConfig(); err == nil {
|
||||
if app := cfg.CurrentAppConfig(profile); app != nil {
|
||||
return core.ParseBrand(string(app.Brand))
|
||||
}
|
||||
}
|
||||
return core.BrandFeishu
|
||||
}
|
||||
87
cmd/startup_brand_test.go
Normal file
87
cmd/startup_brand_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "")
|
||||
os.Unsetenv("LARKSUITE_CLI_BRAND")
|
||||
|
||||
// No config at all → default brand.
|
||||
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
|
||||
t.Errorf("empty state brand = %q, want feishu", got)
|
||||
}
|
||||
|
||||
// Raw config supplies the active profile's brand — no keychain involved.
|
||||
raw := `{"currentApp":"feishu-app","apps":[` +
|
||||
`{"name":"feishu-app","appId":"cli_f","appSecret":"test-secret","brand":"feishu","users":[]},` +
|
||||
`{"name":"lark-prof","appId":"cli_l","appSecret":"test-secret","brand":"LARK","users":[]}]}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
|
||||
t.Errorf("default profile brand = %q, want feishu", got)
|
||||
}
|
||||
if got := ResolveStartupBrand("lark-prof"); got != core.BrandLark {
|
||||
t.Errorf("lark profile brand = %q, want lark (normalized)", got)
|
||||
}
|
||||
|
||||
// Environment wins over the config file.
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "lark")
|
||||
if got := ResolveStartupBrand(""); got != core.BrandLark {
|
||||
t.Errorf("env brand = %q, want lark", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartupBrandReachesRegistry_RealStartupOrder proves the fix for the
|
||||
// production startup sequence: building the command tree locks the registry's
|
||||
// sync.Once, so the brand must be injected before the first catalog access.
|
||||
// It runs in a subprocess because the registry is process-global.
|
||||
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
WithIO(strings.NewReader(""), os.Stdout, os.Stderr),
|
||||
WithStartupBrand(ResolveStartupBrand("")),
|
||||
)
|
||||
fmt.Printf("CONFIGURED_BRAND=%s\n", registry.ConfiguredBrand())
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
raw := `{"apps":[{"appId":"cli_l","appSecret":"test-secret","brand":"lark","users":[]}]}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_STARTUP_BRAND_HELPER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
|
||||
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("subprocess failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "CONFIGURED_BRAND=lark") {
|
||||
t.Errorf("registry brand after real startup order = %s, want lark", out)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cmdupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
stdio "io"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
@@ -125,13 +127,15 @@ func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
// Brand only steers skills sync. updateRun skips that resolution in --check,
|
||||
// where the Updater's zero-value brand retains the Feishu default.
|
||||
if !opts.Check {
|
||||
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
// 1. Fetch latest version.
|
||||
latest, err := fetchLatest()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "network",
|
||||
@@ -153,7 +157,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
|
||||
// 4. Detect installation method
|
||||
// 4. Detect installation method.
|
||||
detect := updater.DetectInstallMethod()
|
||||
|
||||
// 5. --check
|
||||
@@ -168,6 +172,22 @@ func updateRun(opts *UpdateOptions) error {
|
||||
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
|
||||
// resolveSkillsBrand returns the skills-source brand: resolved config first,
|
||||
// then the active profile's raw config entry (the brand is not a secret; a
|
||||
// locked keychain must not flip the source), then the default with a notice.
|
||||
func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) core.LarkBrand {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
return core.ParseBrand(string(cfg.Brand))
|
||||
}
|
||||
if raw, err := core.LoadMultiAppConfig(); err == nil {
|
||||
if app := raw.CurrentAppConfig(f.Invocation.Profile); app != nil {
|
||||
return core.ParseBrand(string(app.Brand))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "note: could not resolve the configured brand; syncing skills from the default source\n")
|
||||
return core.BrandFeishu
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
|
||||
// reportError emits the failure on the requested surface: JSON mode prints the
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1731,3 +1733,64 @@ func containsString(values []string, target string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
|
||||
// Layer 1: resolved config wins.
|
||||
var errBuf bytes.Buffer
|
||||
f := &cmdutil.Factory{Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{Brand: core.LarkBrand(" LARK ")}, nil
|
||||
}}
|
||||
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
|
||||
t.Errorf("resolved-config brand = %q, want lark", got)
|
||||
}
|
||||
|
||||
// Layer 2: credential resolution fails, raw config file still supplies the
|
||||
// brand (a locked keychain must not flip a Lark profile to Feishu).
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
raw := `{"apps":[{"appId":"cli_x","appSecret":"test-secret","brand":"lark","users":[]}]}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f = &cmdutil.Factory{Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") }}
|
||||
errBuf.Reset()
|
||||
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
|
||||
t.Errorf("raw-config brand = %q, want lark", got)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("unexpected notice when raw config supplied the brand: %q", errBuf.String())
|
||||
}
|
||||
|
||||
// Layer 3: nothing readable → default brand with a notice.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
errBuf.Reset()
|
||||
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandFeishu {
|
||||
t.Errorf("fallback brand = %q, want feishu", got)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "could not resolve the configured brand") {
|
||||
t.Errorf("expected fallback notice, got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The raw-config fallback must read the active profile, not the default one.
|
||||
func TestResolveSkillsBrand_RespectsActiveProfile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
raw := `{"currentApp":"feishu-app","apps":[` +
|
||||
`{"name":"feishu-app","appId":"cli_f","appSecret":"test-secret","brand":"feishu","users":[]},` +
|
||||
`{"name":"lark-prof","appId":"cli_l","appSecret":"test-secret","brand":"lark","users":[]}]}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Invocation: cmdutil.InvocationContext{Profile: "lark-prof"},
|
||||
Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") },
|
||||
}
|
||||
var errBuf bytes.Buffer
|
||||
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
|
||||
t.Errorf("brand = %q, want lark (the active profile's brand)", got)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("unexpected notice: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,29 @@ import (
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_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); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
|
||||
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
|
||||
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
|
||||
}
|
||||
|
||||
type MentionOutput struct {
|
||||
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
|
||||
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
|
||||
Name string `json:"name,omitempty" desc:"Mentioned display name"`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -36,15 +48,20 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -81,7 +98,54 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
SenderType: envelope.Event.Sender.SenderType,
|
||||
RootID: msg.RootID,
|
||||
ThreadID: msg.ThreadID,
|
||||
ReplyTo: msg.ParentID,
|
||||
Content: content,
|
||||
Mentions: compactMentions(msg.Mentions),
|
||||
}
|
||||
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
|
||||
out.UpdateTime = msg.UpdateTime
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func compactMentions(mentions []interface{}) []MentionOutput {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MentionOutput, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
mention := MentionOutput{
|
||||
Key: stringField(item, "key"),
|
||||
ID: mentionOpenID(item["id"]),
|
||||
Name: stringField(item, "name"),
|
||||
}
|
||||
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
|
||||
out = append(out, mention)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +84,32 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"root_id": "om_root_001",
|
||||
"parent_id": "om_parent_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
"update_time": "1776409469999",
|
||||
"content": "{\"text\":\"hello @_user_1\"}",
|
||||
"mentions": [
|
||||
{
|
||||
"key": "@_user_1",
|
||||
"id": {"open_id": "ou_mentioned"},
|
||||
"name": "Alice"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
@@ -110,12 +123,69 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
if out.Content != "hello @Alice" {
|
||||
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"sender_type": "user",
|
||||
"root_id": "om_root_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"reply_to": "om_parent_001",
|
||||
"update_time": "1776409469999",
|
||||
} {
|
||||
if got, _ := outMap[field].(string); got != want {
|
||||
t.Errorf("%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
mentions, _ := outMap["mentions"].([]interface{})
|
||||
if len(mentions) != 1 {
|
||||
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
|
||||
}
|
||||
mention, _ := mentions[0].(map[string]interface{})
|
||||
for field, want := range map[string]string{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_mentioned",
|
||||
"name": "Alice",
|
||||
} {
|
||||
if got, _ := mention[field].(string); got != want {
|
||||
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if _, ok := outMap["update_time"]; ok {
|
||||
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
@@ -188,3 +258,22 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
42
extension/apimeta/apimeta.go
Normal file
42
extension/apimeta/apimeta.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package apimeta lets Go module integrators install embedded API metadata
|
||||
// for this process, equivalent to the meta_data.json that official lark-cli
|
||||
// builds compile in via go:embed.
|
||||
//
|
||||
// Binaries built from the bare Go module embed only an empty metadata stub,
|
||||
// so schema resolution and generated service commands have nothing to work
|
||||
// with offline. Integrators that ship their own metadata (typically embedded
|
||||
// into their binary with their own go:embed directive) call SetEmbedded at
|
||||
// process start to make the CLI treat those bytes as the compiled-in
|
||||
// metadata, with no behavioral difference from an official build.
|
||||
package apimeta
|
||||
|
||||
import "github.com/larksuite/cli/internal/registry"
|
||||
|
||||
// ErrAlreadyLoaded reports that SetEmbedded was called after the embedded
|
||||
// metadata had already been parsed, so the injection was rejected. Use
|
||||
// errors.Is(err, ErrAlreadyLoaded) to detect it.
|
||||
var ErrAlreadyLoaded = registry.ErrMetaAlreadyLoaded
|
||||
|
||||
// SetEmbedded installs data as this process's embedded API metadata.
|
||||
//
|
||||
// It must be called before any registry consumption — cmd.Build, cmd.Execute,
|
||||
// schema resolution, or scope discovery — typically at the top of main() or
|
||||
// from an init() function — early enough provided no other init in the process
|
||||
// has already triggered registry consumption. Calling it after the metadata has
|
||||
// been parsed returns ErrAlreadyLoaded.
|
||||
//
|
||||
// data must parse as lark-cli API metadata and declare at least one service;
|
||||
// otherwise an error is returned and the existing state (the empty stub, or
|
||||
// the compiled-in metadata of an official build) is left unchanged.
|
||||
//
|
||||
// data is copied on success; the caller may reuse or modify the buffer afterwards.
|
||||
//
|
||||
// Calling SetEmbedded multiple times before the first parse is allowed: the last
|
||||
// successful call wins, mirroring ordinary Go process-init trust — whoever
|
||||
// links code into the binary controls its metadata.
|
||||
func SetEmbedded(data []byte) error {
|
||||
return registry.SetEmbeddedMeta(data)
|
||||
}
|
||||
30
extension/apimeta/apimeta_test.go
Normal file
30
extension/apimeta/apimeta_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apimeta_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/apimeta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// A1: 非法数据经门脸返回错误,且不是 ErrAlreadyLoaded
|
||||
func TestSetEmbedded_InvalidDataRejected(t *testing.T) {
|
||||
err := apimeta.SetEmbedded([]byte(`{"broken`))
|
||||
if err == nil {
|
||||
t.Fatalf("SetEmbedded(invalid) = nil, want error")
|
||||
}
|
||||
if errors.Is(err, apimeta.ErrAlreadyLoaded) {
|
||||
t.Fatalf("SetEmbedded(invalid) = ErrAlreadyLoaded, want parse error")
|
||||
}
|
||||
}
|
||||
|
||||
// A2: sentinel 与 internal/registry 同值,errors.Is 语义成立
|
||||
func TestErrAlreadyLoaded_AliasesRegistrySentinel(t *testing.T) {
|
||||
if !errors.Is(apimeta.ErrAlreadyLoaded, registry.ErrMetaAlreadyLoaded) {
|
||||
t.Fatalf("apimeta.ErrAlreadyLoaded is not registry.ErrMetaAlreadyLoaded")
|
||||
}
|
||||
}
|
||||
11
extension/credential/env/env.go
vendored
11
extension/credential/env/env.go
vendored
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -41,10 +42,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
@@ -91,6 +89,11 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
}
|
||||
}
|
||||
|
||||
if openID := os.Getenv(envvars.CliUserOpenID); openID != "" && hasUAT {
|
||||
acct.OpenID = openID
|
||||
acct.OpenIDVerified = true
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
|
||||
48
extension/credential/env/env_test.go
vendored
48
extension/credential/env/env_test.go
vendored
@@ -22,13 +22,13 @@ func TestProvider_Name(t *testing.T) {
|
||||
func TestResolveAccount_BothSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliBrand, "feishu")
|
||||
t.Setenv(envvars.CliBrand, " LARK ")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "lark" {
|
||||
t.Errorf("unexpected: %+v", acct)
|
||||
}
|
||||
}
|
||||
@@ -280,3 +280,47 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccountOpenIDAssertedWithUAT(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-token")
|
||||
t.Setenv(envvars.CliUserOpenID, "ou_injected")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.OpenID != "ou_injected" {
|
||||
t.Errorf("OpenID = %q, want %q", acct.OpenID, "ou_injected")
|
||||
}
|
||||
if !acct.OpenIDVerified {
|
||||
t.Error("OpenIDVerified = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccountOpenIDIgnoredWithoutUAT(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-token") // 仅 TAT,无 UAT
|
||||
t.Setenv(envvars.CliUserOpenID, "ou_injected")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.OpenID != "" || acct.OpenIDVerified {
|
||||
t.Errorf("OpenID/OpenIDVerified = %q/%v, want empty/false (no UAT)", acct.OpenID, acct.OpenIDVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccountNoOpenIDEnvUnchanged(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-token")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.OpenID != "" || acct.OpenIDVerified {
|
||||
t.Errorf("OpenID/OpenIDVerified = %q/%v, want empty/false (env not set)", acct.OpenID, acct.OpenIDVerified)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
@@ -58,10 +59,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestResolveAccount_Active(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test123")
|
||||
setEnv(t, envvars.CliBrand, "lark")
|
||||
setEnv(t, envvars.CliBrand, " LARK ")
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
|
||||
|
||||
@@ -46,13 +46,28 @@ func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
// OpenID is the optional user open_id hint. If a UAT is available, the
|
||||
// user_info API result takes precedence unless OpenIDVerified is set.
|
||||
OpenID string
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
// OpenIDVerified marks OpenID as an identity assertion the provider has
|
||||
// already verified. When true and OpenID is non-empty, the CLI skips the
|
||||
// startup user_info verification call and uses OpenID as-is wherever the
|
||||
// resolved user identity is consumed (identity selection, whoami display,
|
||||
// stored-token lookup). The CLI does NOT re-verify the asserted value:
|
||||
// a mismatched assertion is the responsibility of the integrator that
|
||||
// manages the token supply, and whoami will display the injected,
|
||||
// unverified value on this path. Setting OpenIDVerified with an empty
|
||||
// OpenID is treated as unasserted and falls back to normal verification.
|
||||
//
|
||||
// Appended at the end of the struct so adding it does not shift the
|
||||
// positions of existing fields for unkeyed struct literals.
|
||||
OpenIDVerified bool
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
|
||||
35
extension/envelope/envelope.go
Normal file
35
extension/envelope/envelope.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package envelope exposes lark-cli's error-dispatch decision to embedders.
|
||||
//
|
||||
// Integrators that call cmd.Build and drive Execute themselves must render
|
||||
// errors like the official binary so agents can parse stderr uniformly.
|
||||
// DispatchError is the same function the official root dispatcher consumes,
|
||||
// so error classification, exit codes, and envelope bytes match for every
|
||||
// error the dispatcher receives.
|
||||
//
|
||||
// One narrow exception: the official root dispatcher enriches a
|
||||
// need_user_authorization error with the current command's declared scopes
|
||||
// (via a cmdutil.Factory it holds) before calling DispatchError. That
|
||||
// enrichment depends on command context an embedder does not have, so a
|
||||
// direct DispatchError call on a raw need_user_authorization error produces
|
||||
// an otherwise-identical envelope without the folded-in scope hint. All other
|
||||
// error categories are unaffected.
|
||||
package envelope
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
// DispatchError classifies err exactly like lark-cli's own root dispatcher
|
||||
// and returns the stderr envelope bytes (if any) together with the process
|
||||
// exit code. identity is the resolved identity string ("user", "bot", or ""
|
||||
// to omit the field). Typical embedder epilogue:
|
||||
//
|
||||
// env, code, has := envelope.DispatchError(err, "user")
|
||||
// if has {
|
||||
// _, _ = os.Stderr.Write(env)
|
||||
// }
|
||||
// os.Exit(code)
|
||||
func DispatchError(err error, identity string) (envelope []byte, exitCode int, hasEnvelope bool) {
|
||||
return output.DispatchError(err, identity)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -16,6 +17,46 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Terminal registration outcomes, exposed for typed classification by callers.
|
||||
var (
|
||||
ErrRegistrationDenied = errors.New("app registration denied by user")
|
||||
ErrRegistrationExpired = errors.New("device code expired, please try again")
|
||||
ErrRegistrationTimedOut = errors.New("app registration timed out, please try again")
|
||||
)
|
||||
|
||||
// Protocol defaults, mirroring the official SDK registration flow.
|
||||
const (
|
||||
registrationBootstrapBrand = core.BrandFeishu
|
||||
defaultPollIntervalSeconds = 5
|
||||
defaultExpireInSeconds = 600
|
||||
beginRequestTimeout = 30 * time.Second
|
||||
maxPollIntervalSeconds = 60
|
||||
)
|
||||
|
||||
// normalizedInterval clamps a non-positive poll interval to the protocol default.
|
||||
func normalizedInterval(v int) int {
|
||||
if v <= 0 {
|
||||
return defaultPollIntervalSeconds
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizedExpireIn clamps a non-positive expiry budget to the protocol default.
|
||||
func normalizedExpireIn(v int) int {
|
||||
if v <= 0 {
|
||||
return defaultExpireInSeconds
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// registrationContextError maps a done context to its terminal reason, keeping the cause.
|
||||
func registrationContextError(ctx context.Context) error {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return fmt.Errorf("%w: %w", ErrRegistrationTimedOut, ctx.Err())
|
||||
}
|
||||
return fmt.Errorf("app registration cancelled: %w", ctx.Err())
|
||||
}
|
||||
|
||||
// AppRegistrationResponse is the response from the app registration begin endpoint.
|
||||
type AppRegistrationResponse struct {
|
||||
DeviceCode string
|
||||
@@ -39,15 +80,24 @@ type AppRegUserInfo struct {
|
||||
TenantBrand string // "feishu" or "lark"
|
||||
}
|
||||
|
||||
// RequestAppRegistration initiates the app registration device flow.
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
// appRegistrationEndpoint returns the brand's accounts registration endpoint.
|
||||
func appRegistrationEndpoint(brand core.LarkBrand) string {
|
||||
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
|
||||
}
|
||||
|
||||
// RequestAppRegistration initiates the device flow. The registration protocol
|
||||
// always bootstraps on Feishu; brand selects the user-facing verification host.
|
||||
// The request is bounded by ctx and a begin timeout.
|
||||
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
@@ -55,7 +105,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
form.Set("auth_method", "client_secret")
|
||||
form.Set("request_user_info", "open_id tenant_brand")
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +120,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app registration failed: read body: %v", err)
|
||||
return nil, fmt.Errorf("app registration failed: read body: %w", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -90,15 +140,26 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
return nil, fmt.Errorf("app registration failed: %s", msg)
|
||||
}
|
||||
|
||||
expiresIn := getInt(data, "expires_in", 300)
|
||||
interval := getInt(data, "interval", 5)
|
||||
// The protocol field is expire_in; accept the legacy expires_in spelling,
|
||||
// then normalize to protocol defaults.
|
||||
expiresIn := getInt(data, "expire_in", 0)
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = getInt(data, "expires_in", 0)
|
||||
}
|
||||
expiresIn = normalizedExpireIn(expiresIn)
|
||||
interval := normalizedInterval(getInt(data, "interval", 0))
|
||||
|
||||
deviceCode := getStr(data, "device_code")
|
||||
if deviceCode == "" {
|
||||
return nil, fmt.Errorf("app registration failed: response missing device_code")
|
||||
}
|
||||
|
||||
userCode := getStr(data, "user_code")
|
||||
verificationUri := getStr(data, "verification_uri")
|
||||
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
|
||||
|
||||
return &AppRegistrationResponse{
|
||||
DeviceCode: getStr(data, "device_code"),
|
||||
DeviceCode: deviceCode,
|
||||
UserCode: getStr(data, "user_code"),
|
||||
VerificationUri: verificationUri,
|
||||
VerificationUriComplete: verificationUriComplete,
|
||||
@@ -118,72 +179,97 @@ func BuildVerificationURL(baseURL, cliVersion string) string {
|
||||
"&from=cli"
|
||||
}
|
||||
|
||||
// PollAppRegistration polls the app registration endpoint until the app is created or the flow times out.
|
||||
// If the result has ClientSecret == "" and UserInfo.TenantBrand == "lark", the caller should
|
||||
// retry with BrandLark to get the secret from accounts.larksuite.com.
|
||||
func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) (*AppRegistrationResult, error) {
|
||||
// pollOnce performs one ctx-bound poll request and decodes the payload.
|
||||
func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string) (map[string]interface{}, error) {
|
||||
form := url.Values{}
|
||||
form.Set("action", "poll")
|
||||
form.Set("device_code", deviceCode)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", appRegistrationEndpoint(brand), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll network error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll read error: %w", err)
|
||||
}
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("poll parse error: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// RegisterAppWithDiscovery polls for credentials, mirroring the official SDK
|
||||
// flow: the first poll and the (at most one) cross-brand switch are immediate,
|
||||
// non-error responses without complete credentials keep polling, and one
|
||||
// deadline from the begin expiry bounds all waits and in-flight requests.
|
||||
// The returned brand is the one the credentials were issued on.
|
||||
func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, core.LarkBrand, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 200
|
||||
// Interval and expiry arrive normalized from begin-response parsing
|
||||
// (normalizedInterval floors them there); the loop trusts them as-is.
|
||||
interval := resp.Interval
|
||||
ctx, cancel := context.WithDeadline(ctx,
|
||||
time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second))
|
||||
defer cancel()
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + PathAppRegistration
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
currentInterval := interval
|
||||
attempts := 0
|
||||
currentBrand := registrationBootstrapBrand
|
||||
effectiveBrand := currentBrand
|
||||
switched := false
|
||||
waitBeforePoll := false
|
||||
|
||||
for time.Now().Before(deadline) && attempts < maxPollAttempts {
|
||||
attempts++
|
||||
for {
|
||||
if waitBeforePoll {
|
||||
select {
|
||||
case <-time.After(time.Duration(interval) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, effectiveBrand, registrationContextError(ctx)
|
||||
}
|
||||
}
|
||||
waitBeforePoll = true
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
return nil, effectiveBrand, registrationContextError(ctx)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(currentInterval) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "poll")
|
||||
form.Set("device_code", deviceCode)
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll network error: %v\n", err)
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
}
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll read error: %v\n", err)
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err)
|
||||
interval = minInt(interval+1, maxPollIntervalSeconds)
|
||||
continue
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll parse error: %v\n", err)
|
||||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||||
continue
|
||||
// A cross-brand tenant report switches the polled domain (once,
|
||||
// immediately) regardless of the accompanying status — the signal can
|
||||
// arrive alongside authorization_pending, mirroring the official SDK.
|
||||
if !switched {
|
||||
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
|
||||
if tb := getStr(userInfoRaw, "tenant_brand"); tb != "" {
|
||||
if actual := core.ParseBrand(tb); actual != currentBrand {
|
||||
currentBrand = actual
|
||||
effectiveBrand = actual
|
||||
switched = true
|
||||
waitBeforePoll = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errStr := getStr(data, "error")
|
||||
|
||||
// Success: client_id present
|
||||
if errStr == "" && getStr(data, "client_id") != "" {
|
||||
if errStr == "" {
|
||||
result := &AppRegistrationResult{
|
||||
ClientID: getStr(data, "client_id"),
|
||||
ClientSecret: getStr(data, "client_secret"),
|
||||
@@ -194,34 +280,37 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
TenantBrand: getStr(userInfoRaw, "tenant_brand"),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
|
||||
if result.ClientID != "" && result.ClientSecret != "" {
|
||||
// The issuing domain is authoritative; a contradictory final
|
||||
// tenant report is a protocol violation, not a brand override.
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&
|
||||
core.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand {
|
||||
return nil, effectiveBrand, fmt.Errorf("app registration returned credentials with a contradictory tenant brand %q", result.UserInfo.TenantBrand)
|
||||
}
|
||||
return result, effectiveBrand, nil
|
||||
}
|
||||
// Incomplete credentials without an error: keep polling.
|
||||
continue
|
||||
}
|
||||
|
||||
switch errStr {
|
||||
case "authorization_pending":
|
||||
continue
|
||||
case "slow_down":
|
||||
currentInterval = minInt(currentInterval+5, maxPollInterval)
|
||||
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
|
||||
interval = minInt(interval+5, maxPollIntervalSeconds)
|
||||
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", interval)
|
||||
continue
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("app registration denied by user")
|
||||
return nil, effectiveBrand, ErrRegistrationDenied
|
||||
case "expired_token", "invalid_grant":
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
return nil, effectiveBrand, ErrRegistrationExpired
|
||||
}
|
||||
|
||||
desc := getStr(data, "error_description")
|
||||
if desc == "" {
|
||||
desc = errStr
|
||||
}
|
||||
if desc == "" {
|
||||
desc = "Unknown error"
|
||||
}
|
||||
return nil, fmt.Errorf("app registration failed: %s", desc)
|
||||
return nil, effectiveBrand, fmt.Errorf("app registration failed: %s", desc)
|
||||
}
|
||||
|
||||
if attempts >= maxPollAttempts {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
|
||||
}
|
||||
return nil, fmt.Errorf("app registration timed out, please try again")
|
||||
}
|
||||
|
||||
@@ -4,11 +4,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// jsonResponse builds a canned registration response (transport fakes reuse
|
||||
// roundTripFunc from device_flow_test.go).
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
|
||||
func Test_BuildVerificationURL(t *testing.T) {
|
||||
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
|
||||
@@ -31,3 +48,358 @@ func Test_BuildVerificationURL(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppRegistrationEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
want string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
|
||||
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := appRegistrationEndpoint(c.brand); got != c.want {
|
||||
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
verificationHost string
|
||||
}{
|
||||
{core.BrandFeishu, "open.feishu.cn"},
|
||||
{core.BrandLark, "open.larksuite.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(string(c.brand), func(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
|
||||
t.Errorf("begin host = %q, want bootstrap host %q", got, want)
|
||||
}
|
||||
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
|
||||
})}
|
||||
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
|
||||
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Full Lark routing contract: Lark selects the Lark verification page, while
|
||||
// registration bootstraps on Feishu and switches only after the tenant signal.
|
||||
// The Lark credential response omits user_info, so the effective domain must
|
||||
// still determine the saved brand.
|
||||
func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
|
||||
var calls []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("parse form: %v", err)
|
||||
}
|
||||
action := r.Form.Get("action")
|
||||
calls = append(calls, action+"@"+r.URL.Host)
|
||||
if action == "begin" {
|
||||
return jsonResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":60,"interval":0}`), nil
|
||||
}
|
||||
switch r.URL.Host {
|
||||
case "accounts.feishu.cn":
|
||||
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
|
||||
case "accounts.larksuite.com":
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
}
|
||||
t.Errorf("unexpected host polled: %s", r.URL.Host)
|
||||
return jsonResponse(`{}`), nil
|
||||
})}
|
||||
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestAppRegistration error = %v", err)
|
||||
}
|
||||
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
|
||||
t.Errorf("verification URL = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if finalBrand != core.BrandLark {
|
||||
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark)
|
||||
}
|
||||
if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret)
|
||||
}
|
||||
want := []string{"begin@accounts.feishu.cn", "poll@accounts.feishu.cn", "poll@accounts.larksuite.com"}
|
||||
if len(calls) != len(want) {
|
||||
t.Fatalf("calls = %v, want %v", calls, want)
|
||||
}
|
||||
for i := range want {
|
||||
if calls[i] != want[i] {
|
||||
t.Errorf("calls = %v, want %v", calls, want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plain path: the bootstrap domain can return complete Feishu credentials in
|
||||
// one poll, even when user_info is absent.
|
||||
func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) {
|
||||
polls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polls++
|
||||
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
|
||||
t.Errorf("poll host = %q, want %q", got, want)
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
_, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if finalBrand != core.BrandFeishu {
|
||||
t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu)
|
||||
}
|
||||
if polls != 1 {
|
||||
t.Errorf("polls = %d, want 1", polls)
|
||||
}
|
||||
}
|
||||
|
||||
// The discovery deadline must cancel in-flight requests: the fake transport
|
||||
// hangs until the request context is done.
|
||||
func TestRegisterAppWithDiscovery_DeadlineBoundsInFlightRequests(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
<-r.Context().Done()
|
||||
return nil, r.Context().Err()
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 1}
|
||||
|
||||
start := time.Now()
|
||||
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out") {
|
||||
t.Errorf("error = %v, want a timed-out terminal reason", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Errorf("discovery not bounded by its deadline: took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty payloads and incomplete same-brand responses are not terminal.
|
||||
func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
|
||||
responses := []string{
|
||||
`{}`,
|
||||
`{"client_id":"cli_x","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
|
||||
`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
|
||||
}
|
||||
polls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := responses[polls]
|
||||
polls++
|
||||
return jsonResponse(body), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if polls != 3 {
|
||||
t.Errorf("polls = %d, want 3", polls)
|
||||
}
|
||||
if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu {
|
||||
t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither the first poll nor the cross-brand switch waits out the interval
|
||||
// (a 5s interval would blow the elapsed bound).
|
||||
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
|
||||
var polledHosts []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polledHosts = append(polledHosts, r.URL.Host)
|
||||
if r.URL.Host == "accounts.feishu.cn" {
|
||||
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 5, ExpiresIn: 60}
|
||||
|
||||
start := time.Now()
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("discovery waited an interval somewhere: took %v", elapsed)
|
||||
}
|
||||
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
|
||||
}
|
||||
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
|
||||
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
|
||||
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Denial and expiry map to sentinels; cancellation preserves its cause.
|
||||
func TestRegisterAppWithDiscovery_TerminalSentinels(t *testing.T) {
|
||||
deny := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(`{"error":"access_denied"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
_, _, err := RegisterAppWithDiscovery(context.Background(), deny, resp, io.Discard)
|
||||
if !errors.Is(err, ErrRegistrationDenied) {
|
||||
t.Errorf("denied err = %v, want ErrRegistrationDenied", err)
|
||||
}
|
||||
|
||||
expired := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(`{"error":"expired_token"}`), nil
|
||||
})}
|
||||
_, _, err = RegisterAppWithDiscovery(context.Background(), expired, resp, io.Discard)
|
||||
if !errors.Is(err, ErrRegistrationExpired) {
|
||||
t.Errorf("expired err = %v, want ErrRegistrationExpired", err)
|
||||
}
|
||||
|
||||
cancelledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _, err = RegisterAppWithDiscovery(cancelledCtx, deny, resp, io.Discard)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("cancelled err = %v, want a context.Canceled cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Begin parsing: expire_in (legacy expires_in fallback), normalization, and
|
||||
// required device_code.
|
||||
func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
|
||||
serve := func(body string) *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(body), nil
|
||||
})}
|
||||
}
|
||||
|
||||
resp, err := RequestAppRegistration(context.Background(),
|
||||
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("begin error = %v", err)
|
||||
}
|
||||
if resp.ExpiresIn != 60 || resp.Interval != 3 {
|
||||
t.Errorf("parsed (expire=%d, interval=%d), want (60, 3)", resp.ExpiresIn, resp.Interval)
|
||||
}
|
||||
|
||||
resp, err = RequestAppRegistration(context.Background(),
|
||||
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy begin error = %v", err)
|
||||
}
|
||||
if resp.ExpiresIn != 45 || resp.Interval != 5 {
|
||||
t.Errorf("legacy parsed (expire=%d, interval=%d), want (45, 5 — normalized default)", resp.ExpiresIn, resp.Interval)
|
||||
}
|
||||
|
||||
resp, err = RequestAppRegistration(context.Background(),
|
||||
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("defaults begin error = %v", err)
|
||||
}
|
||||
if resp.ExpiresIn != 600 || resp.Interval != 5 {
|
||||
t.Errorf("defaults parsed (expire=%d, interval=%d), want (600, 5)", resp.ExpiresIn, resp.Interval)
|
||||
}
|
||||
|
||||
if _, err := RequestAppRegistration(context.Background(),
|
||||
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
|
||||
t.Error("missing device_code: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// A tenant signal arriving alongside authorization_pending must still switch
|
||||
// the polled domain (the official SDK checks the signal before the error).
|
||||
func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T) {
|
||||
var polledHosts []string
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polledHosts = append(polledHosts, r.URL.Host)
|
||||
if r.URL.Host == "accounts.feishu.cn" {
|
||||
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
|
||||
}
|
||||
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
|
||||
}
|
||||
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
|
||||
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
|
||||
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Polling has no attempt cap: only the expiry budget terminates the loop.
|
||||
func TestRegisterAppWithDiscovery_NoAttemptCap(t *testing.T) {
|
||||
polls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
polls++
|
||||
if polls <= 250 {
|
||||
return jsonResponse(`{"error":"authorization_pending"}`), nil
|
||||
}
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 30}
|
||||
|
||||
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil (no attempts cap)", err)
|
||||
}
|
||||
if polls != 251 || result.ClientSecret != "test-secret" {
|
||||
t.Errorf("polls = %d (want 251), secret = %q", polls, result.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// A final tenant report contradicting the issuing domain is a protocol
|
||||
// violation, not a brand override: the saved brand must never diverge from
|
||||
// the domain that issued the credentials.
|
||||
func TestRegisterAppWithDiscovery_ContradictoryFinalBrandFails(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Host == "accounts.feishu.cn" {
|
||||
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
|
||||
}
|
||||
// The lark domain issues credentials but reports a feishu tenant.
|
||||
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"tenant_brand":"feishu"}}`), nil
|
||||
})}
|
||||
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
|
||||
|
||||
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "contradictory tenant brand") {
|
||||
t.Errorf("err = %v, want contradictory-tenant-brand protocol error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled body read during begin must keep its context cause so the
|
||||
// command layer classifies it as a cancellation, not an API failure.
|
||||
func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(&errReader{err: context.Canceled}),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err = %v, want a context.Canceled cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errReader struct{ err error }
|
||||
|
||||
func (r *errReader) Read([]byte) (int, error) { return 0, r.err }
|
||||
|
||||
@@ -8,15 +8,29 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
|
||||
type DryRunOutputOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
CommandPath string
|
||||
Identity core.Identity
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// DryRunAPICall describes a single API call in dry-run output.
|
||||
type DryRunAPICall struct {
|
||||
Desc string `json:"desc,omitempty"`
|
||||
@@ -26,12 +40,21 @@ type DryRunAPICall struct {
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunContext is the execution context shared by every dry-run preview:
|
||||
// which app would make the call and, when known, as which user. The identity
|
||||
// itself lives at the envelope top level, not here.
|
||||
type DryRunContext struct {
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
UserOpenID string `json:"user_open_id,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunAPI is the builder and result type for dry-run output.
|
||||
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
|
||||
type DryRunAPI struct {
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
extra map[string]interface{}
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
context *DryRunContext
|
||||
extra map[string]interface{}
|
||||
}
|
||||
|
||||
func NewDryRunAPI() *DryRunAPI {
|
||||
@@ -40,30 +63,22 @@ func NewDryRunAPI() *DryRunAPI {
|
||||
|
||||
// --- HTTP method builders (add a call, return self for chaining) ---
|
||||
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
|
||||
// call appends a request with the method transcribed verbatim, so previews
|
||||
// never misreport what the real client would send.
|
||||
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
|
||||
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
|
||||
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
|
||||
return d
|
||||
}
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
|
||||
|
||||
// Body sets the request body on the last added call.
|
||||
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
|
||||
@@ -98,12 +113,26 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
|
||||
return d
|
||||
}
|
||||
|
||||
// Context records the calling app/user under data.context; empty values are
|
||||
// omitted, and a fully empty context is not emitted at all.
|
||||
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
|
||||
if appID == "" && userOpenID == "" {
|
||||
return d
|
||||
}
|
||||
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
|
||||
func (d *DryRunAPI) resolveURL(rawURL string) string {
|
||||
for k, v := range d.extra {
|
||||
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
|
||||
}
|
||||
return rawURL
|
||||
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
|
||||
name := token[1:]
|
||||
value, ok := d.extra[name]
|
||||
if !ok {
|
||||
return token
|
||||
}
|
||||
return url.PathEscape(fmt.Sprintf("%v", value))
|
||||
})
|
||||
}
|
||||
|
||||
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
|
||||
@@ -118,13 +147,17 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
|
||||
Body: c.Body,
|
||||
}
|
||||
}
|
||||
m := make(map[string]interface{}, len(d.extra)+2)
|
||||
m := make(map[string]interface{}, len(d.extra)+3)
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
}
|
||||
// Typed fields win over same-named extra keys.
|
||||
if d.desc != "" {
|
||||
m["description"] = d.desc
|
||||
}
|
||||
m["api"] = resolved
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
if d.context != nil {
|
||||
m["context"] = d.context
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -154,11 +187,7 @@ func (d *DryRunAPI) Format() string {
|
||||
u += "?" + encodeParams(c.Params)
|
||||
}
|
||||
|
||||
method := c.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
b.WriteString(method)
|
||||
b.WriteString(c.Method)
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(u)
|
||||
b.WriteByte('\n')
|
||||
@@ -215,83 +244,74 @@ func encodeParams(params map[string]interface{}) string {
|
||||
return vals.Encode()
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
|
||||
// query params, and the app/user context common to every dry-run.
|
||||
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
|
||||
dr := NewDryRunAPI().call(request.Method, request.URL)
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
filePathDisplay := filePath
|
||||
// Identity is reported at the envelope top level, not duplicated here.
|
||||
dr.Context(config.AppID, config.UserOpenId)
|
||||
return dr
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
filePathDisplay := file.FilePath
|
||||
if filePathDisplay == "" {
|
||||
filePathDisplay = "<stdin>"
|
||||
}
|
||||
fileInfo := map[string]any{
|
||||
"file": map[string]string{"field": fileField, "path": filePathDisplay},
|
||||
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
|
||||
}
|
||||
if formFields != nil {
|
||||
fileInfo["form_fields"] = formFields
|
||||
if file.FormFields != nil {
|
||||
fileInfo["form_fields"] = file.FormFields
|
||||
}
|
||||
fileInfo["options"] = []string{"WithFileUpload"}
|
||||
dr.Body(fileInfo)
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
|
||||
// When format is "pretty", outputs human-readable text; otherwise JSON.
|
||||
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
if !util.IsNil(request.Data) {
|
||||
dr.Body(request.Data)
|
||||
}
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
|
||||
// Identity may be empty; the envelope omits it rather than guessing.
|
||||
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
|
||||
if dr == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
|
||||
}
|
||||
// The JqExpr guard is defensive: every entry point already rejects --jq
|
||||
// combined with --format pretty via output.ValidateJqFlags.
|
||||
if opts.Format == "pretty" && opts.JqExpr == "" {
|
||||
// A nil ErrOut only skips the banner decoration (mirroring
|
||||
// WriteSuccessEnvelope's warning path); the payload write to Out
|
||||
// must fail loudly rather than be silently discarded.
|
||||
if opts.ErrOut != nil {
|
||||
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
|
||||
}
|
||||
// stdout carries its own marker so logs that drop stderr still show
|
||||
// this was a preview, not an executed request.
|
||||
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
|
||||
fmt.Fprint(opts.Out, dr.Format())
|
||||
return nil
|
||||
}
|
||||
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(opts.Identity),
|
||||
DryRun: true,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ package cmdutil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -66,11 +69,31 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/:assignee_id").
|
||||
Set("assignee", "ou_bot")
|
||||
|
||||
text := dr.Format()
|
||||
if strings.Contains(text, "ou_bot_id") {
|
||||
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, ":assignee_id") {
|
||||
t.Fatalf("missing unresolved placeholder, got: %s", text)
|
||||
}
|
||||
|
||||
dr.Set("assignee_id", "ou_abc/123")
|
||||
text = dr.Format()
|
||||
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
|
||||
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
Desc("test api").
|
||||
GET("/open-apis/test").
|
||||
Set("as", "user")
|
||||
Set("note", "audit")
|
||||
|
||||
data, err := json.Marshal(dr)
|
||||
if err != nil {
|
||||
@@ -83,8 +106,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
if m["description"] != "test api" {
|
||||
t.Errorf("expected description, got: %v", m["description"])
|
||||
}
|
||||
if m["as"] != "user" {
|
||||
t.Errorf("expected as=user, got: %v", m["as"])
|
||||
if m["note"] != "audit" {
|
||||
t.Errorf("expected note=audit, got: %v", m["note"])
|
||||
}
|
||||
api, ok := m["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
@@ -123,31 +146,67 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
|
||||
|
||||
func TestPrintDryRun_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "user",
|
||||
}, &core.CliConfig{AppID: "app123"}, "json")
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
CommandPath: "lark-cli api",
|
||||
Identity: core.AsUser,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Errorf("expected header, got: %s", out)
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "app123") {
|
||||
t.Errorf("expected appId in output, got: %s", out)
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected envelope: %#v", env)
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("unexpected data: %#v", env["data"])
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
if _, exists := data["as"]; exists {
|
||||
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/test",
|
||||
Data: map[string]interface{}{"key": "val"},
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app456"}, "pretty")
|
||||
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
|
||||
Format: "pretty",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
@@ -155,6 +214,136 @@ func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
if !strings.Contains(out, "POST /open-apis/test") {
|
||||
t.Errorf("expected POST line in pretty output, got: %s", out)
|
||||
}
|
||||
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
|
||||
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
JqExpr: ".data.api[0].url",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRunWithFile(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRunWithFile failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["path"] != "report.txt" {
|
||||
t.Fatalf("file body = %#v", body)
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
for _, legacy := range []string{"as", "appId", "userOpenId"} {
|
||||
if _, exists := data[legacy]; exists {
|
||||
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "OPTIONS",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
|
||||
if call["method"] != "OPTIONS" {
|
||||
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
}, &core.CliConfig{}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
if _, exists := data["context"]; exists {
|
||||
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
|
||||
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
|
||||
if err == nil {
|
||||
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
|
||||
}
|
||||
var internal *errs.InternalError
|
||||
if !errors.As(err, &internal) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunFormatValue(t *testing.T) {
|
||||
|
||||
@@ -268,7 +268,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
ProfileName: app.ProfileName(),
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
Brand: ParseBrand(string(app.Brand)),
|
||||
Lang: app.Lang,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
|
||||
@@ -230,3 +230,20 @@ func TestCliConfig_CanBot(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime configs must never carry raw brand casing: the config ingress
|
||||
// normalizes it, so downstream equality checks see canonical values.
|
||||
func TestResolveConfigFromMulti_NormalizesBrand(t *testing.T) {
|
||||
multi := &MultiAppConfig{Apps: []AppConfig{{
|
||||
AppId: "cli_x",
|
||||
AppSecret: PlainSecret("test-secret"),
|
||||
Brand: LarkBrand(" LARK "),
|
||||
}}}
|
||||
cfg, err := ResolveConfigFromMulti(multi, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfigFromMulti error = %v", err)
|
||||
}
|
||||
if cfg.Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q (normalized at ingress)", cfg.Brand, BrandLark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// Any other string is treated as a custom base URL.
|
||||
// ParseBrand and ResolveEndpoints map unrecognized values to BrandFeishu.
|
||||
type LarkBrand string
|
||||
|
||||
const (
|
||||
@@ -13,10 +15,10 @@ const (
|
||||
BrandLark LarkBrand = "lark"
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string to a LarkBrand constant.
|
||||
// Unrecognized values default to BrandFeishu.
|
||||
// ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant);
|
||||
// anything other than "lark" normalizes to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if value == "lark" {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
|
||||
return BrandLark
|
||||
}
|
||||
return BrandFeishu
|
||||
@@ -36,9 +38,10 @@ type Endpoints struct {
|
||||
AppLink string // e.g. "https://applink.feishu.cn"
|
||||
}
|
||||
|
||||
// ResolveEndpoints resolves endpoint URLs based on brand.
|
||||
// ResolveEndpoints resolves endpoint URLs for the brand, normalizing its
|
||||
// input so stored values with unusual casing still resolve correctly.
|
||||
func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
switch brand {
|
||||
switch ParseBrand(string(brand)) {
|
||||
case BrandLark:
|
||||
return Endpoints{
|
||||
Open: "https://open.larksuite.com",
|
||||
|
||||
@@ -57,3 +57,37 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want LarkBrand
|
||||
}{
|
||||
{"", BrandFeishu},
|
||||
{"feishu", BrandFeishu},
|
||||
{"lark", BrandLark},
|
||||
{"LARK", BrandLark},
|
||||
{" lark ", BrandLark},
|
||||
{"Lark", BrandLark},
|
||||
{"xyz", BrandFeishu},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseBrand(c.in); got != c.want {
|
||||
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveEndpoints_NormalizesBrand locks the boundary invariant: the
|
||||
// resolver normalizes its brand input, so historical config values with
|
||||
// unusual casing or whitespace still resolve to their intended endpoints.
|
||||
func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
for _, raw := range []string{"LARK", " lark ", "Lark"} {
|
||||
if got := ResolveEndpoints(LarkBrand(raw)).Open; got != "https://open.larksuite.com" {
|
||||
t.Errorf("ResolveEndpoints(%q).Open = %q, want the lark endpoint", raw, got)
|
||||
}
|
||||
}
|
||||
if got := ResolveEndpoints(LarkBrand("unexpected")).Open; got != "https://open.feishu.cn" {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,13 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if acct.OpenIDVerified && acct.OpenID != "" {
|
||||
// Provider asserted a verified identity (e.g. env provider's
|
||||
// LARKSUITE_CLI_USER_OPEN_ID): skip the startup user_info
|
||||
// verification. UserOpenId is already populated by
|
||||
// convertAccount; the token's validity is still enforced by
|
||||
// the first real API call.
|
||||
} else if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -491,3 +492,99 @@ func TestActiveExtensionProviderName_SkipsNilProvider(t *testing.T) {
|
||||
t.Errorf("got %q, want empty string", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_AssertedOpenIDSkipsUserInfo(t *testing.T) {
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "asserted",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_a", Brand: extcred.BrandFeishu,
|
||||
OpenID: "ou_injected", OpenIDVerified: true,
|
||||
},
|
||||
token: &extcred.Token{Value: "u-token", Source: "test"},
|
||||
}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("user_info must not be fetched on asserted path")
|
||||
},
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() called %d times, want 0 (verification skipped)", httpClientCalls)
|
||||
}
|
||||
if acct.UserOpenId != "ou_injected" {
|
||||
t.Errorf("UserOpenId = %q, want %q", acct.UserOpenId, "ou_injected")
|
||||
}
|
||||
if acct.UserName != "" {
|
||||
t.Errorf("UserName = %q, want empty on asserted path", acct.UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_VerifiedFlagWithoutOpenIDFallsBack(t *testing.T) {
|
||||
// 非法组合:OpenIDVerified=true 但 OpenID 空 → 视为未断言,照常尝试验证。
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "misconfigured",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_a", Brand: extcred.BrandFeishu, OpenIDVerified: true,
|
||||
},
|
||||
token: &extcred.Token{Value: "u-token", Source: "test"},
|
||||
}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("fail verification")
|
||||
},
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if httpClientCalls == 0 {
|
||||
t.Fatal("httpClient() not called, want verification attempt (invalid combination must fall back)")
|
||||
}
|
||||
// enrich 失败 → 现有防御逻辑清空未验证身份
|
||||
if acct.UserOpenId != "" {
|
||||
t.Errorf("UserOpenId = %q, want empty after failed verification", acct.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_UnassertedOpenIDStillOverridden(t *testing.T) {
|
||||
// 现有语义保持:OpenID 非空但未断言 → 有 UAT 时 API 结果覆盖。
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open-apis/authen/v1/user_info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","data":{"open_id":"ou_from_api","name":"API User"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// Endpoint injection mirrors tat_fetch_test.go's TestFetchTAT_ContextCanceled:
|
||||
// there is no env-var endpoint override in this repo, so we rewrite the
|
||||
// request host to the test server via a custom RoundTripper instead.
|
||||
hc := &http.Client{Transport: &urlRewriteRT{base: srv.URL}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "hint-only",
|
||||
account: &extcred.Account{
|
||||
AppID: "cli_a", Brand: extcred.BrandFeishu, OpenID: "ou_hint",
|
||||
},
|
||||
token: &extcred.Token{Value: "u-token", Source: "test"},
|
||||
}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return hc, nil },
|
||||
)
|
||||
acct, err := cp.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount: %v", err)
|
||||
}
|
||||
if acct.UserOpenId != "ou_from_api" {
|
||||
t.Errorf("UserOpenId = %q, want %q (API result takes precedence)", acct.UserOpenId, "ou_from_api")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
|
||||
}
|
||||
}
|
||||
|
||||
// ToCliConfig copies the credential-layer account into the downstream config shape.
|
||||
// ToCliConfig copies the credential-layer account into the downstream config
|
||||
// shape, normalizing the brand so runtime consumers never see raw casing.
|
||||
func (a *Account) ToCliConfig() *core.CliConfig {
|
||||
if a == nil {
|
||||
return nil
|
||||
@@ -81,7 +82,7 @@ func (a *Account) ToCliConfig() *core.CliConfig {
|
||||
ProfileName: a.ProfileName,
|
||||
AppID: a.AppID,
|
||||
AppSecret: normalizeAccountAppSecret(a.AppSecret),
|
||||
Brand: a.Brand,
|
||||
Brand: core.ParseBrand(string(a.Brand)),
|
||||
DefaultAs: a.DefaultAs,
|
||||
UserOpenId: a.UserOpenId,
|
||||
UserName: a.UserName,
|
||||
|
||||
@@ -130,3 +130,11 @@ func TestRuntimeAppSecret_TokenOnlyUsesPlaceholder(t *testing.T) {
|
||||
t.Fatalf("RuntimeAppSecret(real) = %q, want %q", got, "secret-1")
|
||||
}
|
||||
}
|
||||
|
||||
// The credential-layer ingress normalizes brand casing for all runtime consumers.
|
||||
func TestToCliConfig_NormalizesBrand(t *testing.T) {
|
||||
acct := &Account{AppID: "cli_x", Brand: " LARK "}
|
||||
if got := acct.ToCliConfig().Brand; got != core.BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", got, core.BrandLark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
CliUserOpenID = "LARKSUITE_CLI_USER_OPEN_ID"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
|
||||
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
|
||||
@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
|
||||
91
internal/output/dispatch.go
Normal file
91
internal/output/dispatch.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// DispatchError classifies err exactly like the root command dispatcher and
|
||||
// returns the rendered stderr envelope (if any) together with the process
|
||||
// exit code. It is the single classification path shared by lark-cli's own
|
||||
// root dispatcher and the public extension/envelope facade, so embedders that
|
||||
// drive Execute themselves render errors byte-identically to the official
|
||||
// binary.
|
||||
//
|
||||
// Classification, in order:
|
||||
//
|
||||
// 1. nil → (nil, 0, false).
|
||||
// 2. Typed errs.* carrying a Problem → (envelope, ExitCodeOf(err), true).
|
||||
// If envelope encoding fails the error falls through to branch 4 so
|
||||
// stderr is never blank.
|
||||
// 3. *PartialFailureError / *BareError → (nil, signal code, false): the
|
||||
// result envelope is already on stdout; write nothing to stderr.
|
||||
// 4. Remaining untyped errors: cobra usage text → invalid_argument envelope
|
||||
// with exit 2; anything else leaked past the typed boundary → internal
|
||||
// envelope with exit 5.
|
||||
func DispatchError(err error, identity string) (envelope []byte, exitCode int, hasEnvelope bool) {
|
||||
if err == nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
typedExit := ExitCodeOf(err)
|
||||
if env, ok := renderTypedEnvelope(err, identity); ok {
|
||||
return env, typedExit, true
|
||||
}
|
||||
|
||||
var pfErr *PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return nil, pfErr.Code, false
|
||||
}
|
||||
var bareErr *BareError
|
||||
if errors.As(err, &bareErr) {
|
||||
return nil, bareErr.Code, false
|
||||
}
|
||||
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
env, ok := renderTypedEnvelope(fallback, identity)
|
||||
if !ok {
|
||||
return nil, ExitCodeOf(fallback), false
|
||||
}
|
||||
return env, ExitCodeOf(fallback), true
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
83
internal/output/dispatch_test.go
Normal file
83
internal/output/dispatch_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestDispatchErrorNil(t *testing.T) {
|
||||
env, code, has := DispatchError(nil, "user")
|
||||
if env != nil || code != 0 || has {
|
||||
t.Fatalf("DispatchError(nil) = (%v, %d, %v), want (nil, 0, false)", env, code, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorTyped(t *testing.T) {
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "missing --id")
|
||||
env, code, has := DispatchError(err, "user")
|
||||
if !has || code != ExitCodeOf(err) {
|
||||
t.Fatalf("has=%v code=%d, want true / %d", has, code, ExitCodeOf(err))
|
||||
}
|
||||
var parsed map[string]any
|
||||
if jsonErr := json.Unmarshal(env, &parsed); jsonErr != nil {
|
||||
t.Fatalf("envelope not valid JSON: %v", jsonErr)
|
||||
}
|
||||
if parsed["ok"] != false || parsed["identity"] != "user" {
|
||||
t.Errorf("envelope ok/identity = %v/%v", parsed["ok"], parsed["identity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorPartialFailure(t *testing.T) {
|
||||
env, code, has := DispatchError(PartialFailure(1), "user")
|
||||
if env != nil || code != 1 || has {
|
||||
t.Fatalf("got (%v, %d, %v), want (nil, 1, false)", env, code, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorBare(t *testing.T) {
|
||||
env, code, has := DispatchError(ErrBare(3), "user")
|
||||
if env != nil || code != 3 || has {
|
||||
t.Fatalf("got (%v, %d, %v), want (nil, 3, false)", env, code, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorCobraUsage(t *testing.T) {
|
||||
env, code, has := DispatchError(fmt.Errorf(`required flag(s) "values" not set`), "user")
|
||||
if !has || code != 2 {
|
||||
t.Fatalf("has=%v code=%d, want true / 2", has, code)
|
||||
}
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(env, &parsed)
|
||||
errObj := parsed["error"].(map[string]any)
|
||||
if errObj["subtype"] != "invalid_argument" {
|
||||
t.Errorf("subtype = %v, want invalid_argument", errObj["subtype"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorLeakedUntyped(t *testing.T) {
|
||||
env, code, has := DispatchError(errors.New("boom"), "bot")
|
||||
if !has || code != 5 {
|
||||
t.Fatalf("has=%v code=%d, want true / 5", has, code)
|
||||
}
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(env, &parsed)
|
||||
if parsed["identity"] != "bot" {
|
||||
t.Errorf("identity = %v, want bot", parsed["identity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchErrorEmptyIdentityOmitted(t *testing.T) {
|
||||
env, _, _ := DispatchError(errs.NewValidationError(errs.SubtypeInvalidArgument, "x"), "")
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(env, &parsed)
|
||||
if _, present := parsed["identity"]; present {
|
||||
t.Error("identity field present, want omitted for empty identity")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package output
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`
|
||||
|
||||
@@ -9,6 +9,7 @@ import "io"
|
||||
type SuccessEnvelopeOptions struct {
|
||||
CommandPath string
|
||||
Identity string
|
||||
DryRun bool
|
||||
JqExpr string
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
@@ -41,6 +42,7 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
|
||||
@@ -104,6 +104,47 @@ func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
DryRun: true,
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", env)
|
||||
}
|
||||
if _, ok := env["data"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("data = %#v, want object", env["data"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
DryRun: true,
|
||||
JqExpr: ".dry_run",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "true" {
|
||||
t.Fatalf("jq output = %q, want true", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&mockProvider{
|
||||
|
||||
@@ -34,6 +34,30 @@ func PartialFailure(code int) *PartialFailureError {
|
||||
return &PartialFailureError{Code: code}
|
||||
}
|
||||
|
||||
// renderTypedEnvelope serializes the typed-error envelope for err. It returns
|
||||
// (nil, false) when err carries no Problem or when JSON encoding fails — the
|
||||
// dispatcher then falls through to its signal / usage-error branches.
|
||||
func renderTypedEnvelope(err error, identity string) ([]byte, bool) {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
return nil, false
|
||||
}
|
||||
return buf.Bytes(), true
|
||||
}
|
||||
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
@@ -56,30 +80,11 @@ func PartialFailure(code int) *PartialFailureError {
|
||||
// Returns false only when err carries no Problem (the dispatcher then handles
|
||||
// it via its signal / usage-error branches) or when JSON encoding itself failed.
|
||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
b, ok := renderTypedEnvelope(err, identity)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
// Encoding failed — emit nothing here; the dispatcher's fall-through
|
||||
// branches still surface the error, so stderr is never blank.
|
||||
return false
|
||||
}
|
||||
// Best-effort write. Partial-write does not downgrade the success status:
|
||||
// the dispatcher has already captured ExitCodeOf(err) before calling us,
|
||||
// and a torn stderr is preferable to falling through to the plain
|
||||
// "Error:" path with exit 1.
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
_, _ = w.Write(b)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,15 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/semantic"
|
||||
)
|
||||
|
||||
func TestRunLoadsPolicyAndWaivers(t *testing.T) {
|
||||
freezeNow(t, time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
repo := t.TempDir()
|
||||
writeSemanticConfig(t, repo, `{
|
||||
"schema_version": 1,
|
||||
@@ -65,6 +68,8 @@ func TestRunLoadsPolicyAndWaivers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunLoadsWaiversFromOverrideFile(t *testing.T) {
|
||||
freezeNow(t, time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
repo := t.TempDir()
|
||||
writeSemanticConfig(t, repo, `{
|
||||
"schema_version": 1,
|
||||
@@ -370,6 +375,13 @@ func writeSemanticConfig(t *testing.T, repo, policy, models, waivers string) {
|
||||
}
|
||||
}
|
||||
|
||||
func freezeNow(t *testing.T, fixed time.Time) {
|
||||
t.Helper()
|
||||
original := now
|
||||
now = func() time.Time { return fixed }
|
||||
t.Cleanup(func() { now = original })
|
||||
}
|
||||
|
||||
func readDecision(t *testing.T, path string) semantic.Decision {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -878,16 +878,23 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
|
||||
var firstErr error
|
||||
for start >= 0 {
|
||||
var preview struct {
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
Data struct {
|
||||
API []facts.DryRunRequest `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
|
||||
if err := dec.Decode(&preview); err == nil {
|
||||
if len(preview.API) == 0 {
|
||||
api := preview.API
|
||||
if len(api) == 0 {
|
||||
api = preview.Data.API
|
||||
}
|
||||
if len(api) == 0 {
|
||||
if firstErr == nil {
|
||||
firstErr = errNoDryRunAPI
|
||||
}
|
||||
} else {
|
||||
return preview.API[0], len(preview.API), nil
|
||||
return api[0], len(api), nil
|
||||
}
|
||||
} else if firstErr == nil {
|
||||
firstErr = err
|
||||
|
||||
@@ -33,6 +33,17 @@ func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDryRunJSONReadsSuccessEnvelope(t *testing.T) {
|
||||
raw := `{"ok":true,"dry_run":true,"data":{"api":[{"method":"GET","url":"/open-apis/test"}]}}`
|
||||
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("extractDryRunJSON() error = %v", err)
|
||||
}
|
||||
if got.Method != "GET" || got.URL != "/open-apis/test" || apiCallCount != 1 {
|
||||
t.Fatalf("got request=%#v apiCallCount=%d, want enveloped GET and count 1", got, apiCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDryRunJSONSkipsBannerWithBraces(t *testing.T) {
|
||||
raw := "banner {not json}\n{\"api\":[{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]}\n"
|
||||
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -31,6 +34,43 @@ var (
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// ErrMetaAlreadyLoaded is returned by SetEmbeddedMeta when the embedded
|
||||
// metadata has already been parsed; injection must happen before any
|
||||
// registry consumption. extension/apimeta re-exports it as ErrAlreadyLoaded.
|
||||
var ErrMetaAlreadyLoaded = errors.New("embedded api metadata already parsed")
|
||||
|
||||
var (
|
||||
// embeddedInjectMu serializes SetEmbeddedMeta against the first parse so
|
||||
// check-then-write and mark-then-parse never interleave: an injection
|
||||
// either fully lands before the parse or fails with ErrMetaAlreadyLoaded.
|
||||
embeddedInjectMu sync.Mutex
|
||||
embeddedParsed bool // set inside parseEmbedded's Once body
|
||||
)
|
||||
|
||||
// SetEmbeddedMeta validates data and installs it as this process's embedded
|
||||
// API metadata — the same variable go:embed fills in official builds, so every
|
||||
// downstream consumer (schema, command generation, scope discovery, cache
|
||||
// overlay version gating) behaves exactly as an official build would.
|
||||
//
|
||||
// It is the internal engine of extension/apimeta.SetEmbedded; see that
|
||||
// package for the public contract.
|
||||
func SetEmbeddedMeta(data []byte) error {
|
||||
reg, err := meta.Parse(data) // validate before write; meta.Parse(nil/empty) returns a zero Registry with no error
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid api metadata: %w", err)
|
||||
}
|
||||
if len(reg.Services) == 0 {
|
||||
return errors.New("api metadata contains no services")
|
||||
}
|
||||
embeddedInjectMu.Lock()
|
||||
defer embeddedInjectMu.Unlock()
|
||||
if embeddedParsed {
|
||||
return ErrMetaAlreadyLoaded
|
||||
}
|
||||
embeddedMetaJSON = bytes.Clone(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEmbedded decodes the embedded meta_data.json into the typed model exactly
|
||||
// once. It is the single parse of the embedded bytes: both the overlay-free
|
||||
// envelope path (EmbeddedServicesTyped) and the merged command/scope path
|
||||
@@ -38,6 +78,9 @@ var (
|
||||
// twice and no map round-trip is needed downstream.
|
||||
func parseEmbedded() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedInjectMu.Lock()
|
||||
embeddedParsed = true
|
||||
embeddedInjectMu.Unlock()
|
||||
reg, _ := meta.Parse(embeddedMetaJSON)
|
||||
embeddedVersion = reg.Version
|
||||
embeddedServices = reg.Services
|
||||
@@ -69,6 +112,12 @@ func Init() {
|
||||
InitWithBrand(core.BrandFeishu)
|
||||
}
|
||||
|
||||
// ConfiguredBrand reports the brand the registry was initialized with
|
||||
// (empty before initialization). Diagnostics and startup-order tests use it.
|
||||
func ConfiguredBrand() core.LarkBrand {
|
||||
return configuredBrand
|
||||
}
|
||||
|
||||
// InitWithBrand initializes the registry by loading embedded data and optionally
|
||||
// overlaying cached remote data. The brand determines which remote API host to use.
|
||||
// It is safe to call multiple times (sync.Once).
|
||||
|
||||
126
internal/registry/loader_inject_test.go
Normal file
126
internal/registry/loader_inject_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
const injectValidMetaJSON = `{"version":"9.9.9","services":[{"name":"testsvc","title":"Test Service","resources":{}}]}`
|
||||
|
||||
// resetInjectState resets package state for injection tests and restores the
|
||||
// original embedded bytes afterwards (same save/restore pattern as
|
||||
// catalog_test.go). resetInit() itself clears embeddedParsed.
|
||||
func resetInjectState(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := embeddedMetaJSON
|
||||
resetInit()
|
||||
embeddedServices = nil
|
||||
embeddedServicesByName = nil
|
||||
t.Cleanup(func() {
|
||||
resetInit()
|
||||
embeddedServices = nil
|
||||
embeddedServicesByName = nil
|
||||
embeddedMetaJSON = orig
|
||||
})
|
||||
}
|
||||
|
||||
// R1: parse 前注入合法 meta → 生效,version 基线更新
|
||||
func TestSetEmbeddedMeta_InjectBeforeParse(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta() = %v, want nil", err)
|
||||
}
|
||||
svcs := EmbeddedServicesTyped()
|
||||
if len(svcs) != 1 || svcs[0].Name != "testsvc" {
|
||||
t.Fatalf("EmbeddedServicesTyped() = %+v, want single service testsvc", svcs)
|
||||
}
|
||||
if embeddedVersion != "9.9.9" { // R7: overlay 门禁的比较基线来源正确
|
||||
t.Fatalf("embeddedVersion = %q, want %q", embeddedVersion, "9.9.9")
|
||||
}
|
||||
}
|
||||
|
||||
// R1b: parse 前多次注入 → 后写覆盖(注入者赢,spec §3.2 末行)
|
||||
func TestSetEmbeddedMeta_LastWriteWinsBeforeParse(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
first := `{"version":"1.0.0","services":[{"name":"firstsvc","resources":{}}]}`
|
||||
if err := SetEmbeddedMeta([]byte(first)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta(first) = %v, want nil", err)
|
||||
}
|
||||
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta(second) = %v, want nil", err)
|
||||
}
|
||||
svcs := EmbeddedServicesTyped()
|
||||
if len(svcs) != 1 || svcs[0].Name != "testsvc" {
|
||||
t.Fatalf("EmbeddedServicesTyped() = %+v, want last-injected testsvc", svcs)
|
||||
}
|
||||
}
|
||||
|
||||
// R2: 注入后 SchemaCatalog 走 embedded 快路径
|
||||
func TestSetEmbeddedMeta_SchemaCatalogUsesEmbedded(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
if err := SetEmbeddedMeta([]byte(injectValidMetaJSON)); err != nil {
|
||||
t.Fatalf("SetEmbeddedMeta() = %v, want nil", err)
|
||||
}
|
||||
cat := SchemaCatalog()
|
||||
if cat.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("SchemaCatalog().Source() = %q, want %q", cat.Source(), apicatalog.SourceEmbedded)
|
||||
}
|
||||
if _, ok := cat.Service("testsvc"); !ok {
|
||||
t.Fatalf("SchemaCatalog() missing injected service testsvc")
|
||||
}
|
||||
}
|
||||
|
||||
// R3: 非法 JSON 拒绝且状态不变
|
||||
func TestSetEmbeddedMeta_InvalidJSONRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
before := embeddedMetaJSON
|
||||
err := SetEmbeddedMeta([]byte(`{"broken`))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid api metadata") {
|
||||
t.Fatalf("SetEmbeddedMeta(invalid) = %v, want invalid api metadata error", err)
|
||||
}
|
||||
if string(embeddedMetaJSON) != string(before) {
|
||||
t.Fatalf("embeddedMetaJSON mutated on rejected input")
|
||||
}
|
||||
}
|
||||
|
||||
// R4: 合法 JSON 但 services 为空 → 拒绝且状态不变
|
||||
func TestSetEmbeddedMeta_EmptyServicesRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
before := embeddedMetaJSON
|
||||
for _, in := range []string{`{}`, `{"version":"1.0.0","services":[]}`} {
|
||||
err := SetEmbeddedMeta([]byte(in))
|
||||
if err == nil || !strings.Contains(err.Error(), "api metadata contains no services") {
|
||||
t.Fatalf("SetEmbeddedMeta(%q) = %v, want no-services error", in, err)
|
||||
}
|
||||
}
|
||||
if string(embeddedMetaJSON) != string(before) {
|
||||
t.Fatalf("embeddedMetaJSON mutated on rejected input")
|
||||
}
|
||||
}
|
||||
|
||||
// R5: 首次 parse 之后注入 → ErrMetaAlreadyLoaded
|
||||
func TestSetEmbeddedMeta_AfterParseRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
_ = EmbeddedServicesTyped() // 触发首次 parse
|
||||
err := SetEmbeddedMeta([]byte(injectValidMetaJSON))
|
||||
if !errors.Is(err, ErrMetaAlreadyLoaded) {
|
||||
t.Fatalf("SetEmbeddedMeta(after parse) = %v, want ErrMetaAlreadyLoaded", err)
|
||||
}
|
||||
}
|
||||
|
||||
// R6: nil / 空字节 → 恒走 services 为空路径(meta.Parse 空输入返回零值无错误)
|
||||
func TestSetEmbeddedMeta_NilAndEmptyRejected(t *testing.T) {
|
||||
resetInjectState(t)
|
||||
for _, in := range [][]byte{nil, {}} {
|
||||
err := SetEmbeddedMeta(in)
|
||||
if err == nil || !strings.Contains(err.Error(), "api metadata contains no services") {
|
||||
t.Fatalf("SetEmbeddedMeta(len=%d) = %v, want no-services error", len(in), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
// recommend.allow special-cases scopes absent from scope_priorities.json
|
||||
// (application v7 is not in the platform catalog yet) so interactive
|
||||
// login's "common scopes" tier still offers them. Only the read scope is
|
||||
// admitted: write stays out of the recommended tier by design.
|
||||
if !allowSet["application:app_slash_command:read"] {
|
||||
t.Error("expected application:app_slash_command:read in override allow set")
|
||||
}
|
||||
if allowSet["application:app_slash_command:write"] {
|
||||
t.Error("write scope must NOT be in the recommended tier")
|
||||
}
|
||||
if len(allowSet) != 1 {
|
||||
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,13 +75,7 @@ func remoteMetaURL(version string) string {
|
||||
if testMetaURL != "" {
|
||||
return testMetaURL
|
||||
}
|
||||
var base string
|
||||
switch configuredBrand {
|
||||
case core.BrandLark:
|
||||
base = "https://open.larksuite.com/api/tools/open/api_definition"
|
||||
default:
|
||||
base = "https://open.feishu.cn/api/tools/open/api_definition"
|
||||
}
|
||||
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
|
||||
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
|
||||
if version != "" {
|
||||
q += "&data_version=" + url.QueryEscape(version)
|
||||
|
||||
@@ -32,6 +32,9 @@ func resetInit() {
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
embeddedParseOnce = sync.Once{}
|
||||
embeddedInjectMu.Lock()
|
||||
embeddedParsed = false
|
||||
embeddedInjectMu.Unlock()
|
||||
servicesTypedOnce = sync.Once{}
|
||||
servicesTyped = nil
|
||||
mergedServices = make(map[string]meta.Service)
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"allow": [
|
||||
"application:app_slash_command:read"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"application": {
|
||||
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands for the currently bound app" },
|
||||
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理" }
|
||||
},
|
||||
"apps": {
|
||||
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
|
||||
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -49,7 +50,9 @@ const (
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
// officialSkillsIndexURL overrides the brand-derived skills index URL in
|
||||
// tests; empty in production.
|
||||
officialSkillsIndexURL = ""
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -101,6 +104,9 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
|
||||
// / RestoreAvailableOverride for testing.
|
||||
type Updater struct {
|
||||
// Brand selects the skills index/source endpoints (zero value = feishu).
|
||||
Brand core.LarkBrand
|
||||
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
@@ -129,6 +135,19 @@ type Updater struct {
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// skillsIndexURL returns the brand's well-known skills index URL.
|
||||
func (u *Updater) skillsIndexURL() string {
|
||||
if officialSkillsIndexURL != "" {
|
||||
return officialSkillsIndexURL
|
||||
}
|
||||
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
|
||||
}
|
||||
|
||||
// skillsSource returns the brand's skills source host for `npx skills add`.
|
||||
func (u *Updater) skillsSource() string {
|
||||
return core.ResolveEndpoints(u.Brand).Open
|
||||
}
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
@@ -258,7 +277,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
@@ -297,7 +316,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
r := u.runSkillsListOfficial(u.skillsSource())
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
}
|
||||
@@ -313,7 +332,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
r := u.runSkillsInstall(u.skillsSource(), nameList)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", nameList)
|
||||
}
|
||||
@@ -321,7 +340,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
r := u.runSkillsAdd(u.skillsSource())
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -515,3 +516,23 @@ func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsBrandHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
wantIndex string
|
||||
wantSource string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"},
|
||||
{core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
u := &Updater{Brand: c.brand}
|
||||
if got := u.skillsIndexURL(); got != c.wantIndex {
|
||||
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
|
||||
}
|
||||
if got := u.skillsSource(); got != c.wantSource {
|
||||
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
@@ -30,8 +30,42 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
└── scan_test.go
|
||||
```
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
`domaincontract` is a syntax-level regression guard for the resolver-owned
|
||||
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
|
||||
files it rejects:
|
||||
|
||||
- string literals containing a resolver-owned host FQDN
|
||||
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
|
||||
- direct references to the SDK base-URL globals (`FeishuBaseUrl` / `LarkBaseUrl`)
|
||||
selected off an import of the SDK root package, which pick a host without
|
||||
going through the resolver. Unrelated identifiers sharing the name are not
|
||||
flagged.
|
||||
|
||||
Host literals are permitted only inside the resolver's `ResolveEndpoints`
|
||||
function body (`internal/core/types.go`) and in this rule's own host list
|
||||
(`lint/domaincontract/scan.go`); a helper elsewhere in the resolver file
|
||||
returning a hardcoded host is still rejected. Comments and `_test.go` files
|
||||
are not scanned. Literals are unquoted before matching (escape sequences
|
||||
cannot hide a host) and match case-insensitively, and dot-imports of the SDK
|
||||
root package are rejected outright (they would hide the globals from this
|
||||
parse-level guard). The forbidden-host list is bound to the resolver source by
|
||||
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
|
||||
the guard fails the lint module's tests.
|
||||
|
||||
This is not a general outbound-URL or data-flow analyzer. It does not inspect
|
||||
non-Go assets, hosts assembled from string fragments, SDK constructor option
|
||||
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
|
||||
remain the backstop for those cases.
|
||||
|
||||
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
@@ -42,7 +76,7 @@ go run -C lint . ..
|
||||
`-C lint` switches Go's working directory to `lint/`; the `..` argument
|
||||
is the repo root to scan (relative to `lint/`).
|
||||
|
||||
CI: `.github/workflows/ci.yml` step `Run errs/ lint guards (lintcheck)`.
|
||||
CI: `.github/workflows/ci.yml` step `Run source-contract lint guards (lintcheck)`.
|
||||
|
||||
Exit codes follow `lint/main.go`:
|
||||
|
||||
|
||||
45
lint/domaincontract/enforce_test.go
Normal file
45
lint/domaincontract/enforce_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLintcheckExitCode proves the guard gates CI end to end: a violating
|
||||
// fixture must make the lintcheck binary exit 1, and a clean tree exit 0.
|
||||
func TestLintcheckExitCode(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("compiles the lintcheck binary")
|
||||
}
|
||||
dirty := t.TempDir()
|
||||
writeFile(t, dirty, "internal/x/x.go", "package x\n\nvar h = \"https://open.feishu.cn\"\n")
|
||||
|
||||
run := func(dir string) (string, error) {
|
||||
cmd := exec.Command("go", "run", "..", dir)
|
||||
cmd.Dir = "." // lint/domaincontract — `..` is the lintcheck main package
|
||||
cmd.Env = os.Environ()
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
out, err := run(dirty)
|
||||
if err == nil || !strings.Contains(out, "no-hardcoded-endpoint") {
|
||||
t.Fatalf("violating fixture: err=%v out=%s (want exit 1 with a no-hardcoded-endpoint REJECT)", err, out)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 {
|
||||
t.Fatalf("violating fixture exit = %v, want 1", err)
|
||||
}
|
||||
|
||||
clean := t.TempDir()
|
||||
writeFile(t, clean, "internal/x/x.go", "package x\n\nvar ok = 1\n")
|
||||
if out, err := run(clean); err != nil {
|
||||
t.Fatalf("clean fixture: err=%v out=%s (want exit 0)", err, out)
|
||||
}
|
||||
}
|
||||
190
lint/domaincontract/scan.go
Normal file
190
lint/domaincontract/scan.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package domaincontract guards the Go CLI against direct reuse of the current
|
||||
// resolver-owned host FQDNs outside core.ResolveEndpoints.
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string
|
||||
// literals in the allowlisted resolver source.
|
||||
var forbiddenHosts = []string{
|
||||
"open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn",
|
||||
"open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com",
|
||||
}
|
||||
|
||||
// forbiddenIdents are the SDK root package's base-URL globals; referencing
|
||||
// them picks a host without the resolver. Matched as selectors on an SDK root
|
||||
// import, so unrelated same-name identifiers are not flagged.
|
||||
var forbiddenIdents = map[string]bool{
|
||||
"FeishuBaseUrl": true,
|
||||
"LarkBaseUrl": true,
|
||||
}
|
||||
|
||||
// sdkModulePrefix identifies imports of the Lark OAPI SDK.
|
||||
const sdkModulePrefix = "github.com/larksuite/oapi-sdk-go/"
|
||||
|
||||
// sdkImportAliases returns the file's local names for the SDK root package
|
||||
// (subpackages do not export the base-URL globals).
|
||||
func sdkImportAliases(file *ast.File) map[string]bool {
|
||||
aliases := map[string]bool{}
|
||||
for _, imp := range file.Imports {
|
||||
path, err := strconv.Unquote(imp.Path.Value)
|
||||
if err != nil || !strings.HasPrefix(path, sdkModulePrefix) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.TrimPrefix(path, sdkModulePrefix), "/") {
|
||||
continue // subpackage, not the root
|
||||
}
|
||||
name := "lark" // the SDK root package's package name
|
||||
if imp.Name != nil {
|
||||
name = imp.Name.Name
|
||||
}
|
||||
aliases[name] = true
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
// allowlist holds the only file allowed to carry the literals wholesale:
|
||||
// this rule's own host list. The resolver file is scoped per-function instead
|
||||
// (see resolverPath).
|
||||
var allowlist = map[string]bool{
|
||||
filepath.FromSlash("lint/domaincontract/scan.go"): true,
|
||||
}
|
||||
|
||||
// resolverPath is the resolver source; host literals are permitted only
|
||||
// inside its ResolveEndpoints function body.
|
||||
var resolverPath = filepath.FromSlash("internal/core/types.go")
|
||||
|
||||
func skipDir(name string) bool {
|
||||
switch name {
|
||||
case "vendor", "testdata", "node_modules", ".git", ".claude":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanRepo walks production .go files under root and flags string literals
|
||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
||||
// _test.go files are not scanned.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr == nil && allowlist[rel] {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, perr := parser.ParseFile(fset, path, nil, 0)
|
||||
if perr != nil {
|
||||
return nil // unparseable file: not our concern
|
||||
}
|
||||
display := path
|
||||
if relErr == nil {
|
||||
display = rel
|
||||
}
|
||||
var allowedFrom, allowedTo token.Pos
|
||||
if relErr == nil && rel == resolverPath {
|
||||
for _, d := range file.Decls {
|
||||
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil && fd.Name.Name == "ResolveEndpoints" && fd.Body != nil {
|
||||
allowedFrom, allowedTo = fd.Body.Pos(), fd.Body.End()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
inResolverBody := func(p token.Pos) bool {
|
||||
return allowedFrom != token.NoPos && p >= allowedFrom && p <= allowedTo
|
||||
}
|
||||
// Dot-imports of the SDK root would hide its globals from this
|
||||
// parse-level guard, so the import form itself is rejected.
|
||||
for _, imp := range file.Imports {
|
||||
path, uerr := strconv.Unquote(imp.Path.Value)
|
||||
if uerr != nil || imp.Name == nil || imp.Name.Name != "." {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(path, sdkModulePrefix) &&
|
||||
!strings.Contains(strings.TrimPrefix(path, sdkModulePrefix), "/") {
|
||||
pos := fset.Position(imp.Pos())
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: "no-hardcoded-endpoint",
|
||||
Action: lintapi.ActionReject,
|
||||
File: display,
|
||||
Line: pos.Line,
|
||||
Message: "dot-import of the SDK root package defeats the endpoint guard",
|
||||
Suggestion: "import the SDK with a package name",
|
||||
})
|
||||
}
|
||||
}
|
||||
sdkAliases := sdkImportAliases(file)
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch node := n.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
pkg, ok := node.X.(*ast.Ident)
|
||||
if ok && pkg.Obj == nil && forbiddenIdents[node.Sel.Name] && sdkAliases[pkg.Name] {
|
||||
pos := fset.Position(node.Pos())
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: "no-hardcoded-endpoint",
|
||||
Action: lintapi.ActionReject,
|
||||
File: display,
|
||||
Line: pos.Line,
|
||||
Message: "SDK base-URL global " + pkg.Name + "." + node.Sel.Name + " bypasses the resolver — use core.ResolveEndpoints",
|
||||
Suggestion: "derive the host from core.ResolveEndpoints(brand) instead of the SDK global",
|
||||
})
|
||||
}
|
||||
case *ast.BasicLit:
|
||||
if node.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
if inResolverBody(node.Pos()) {
|
||||
return true
|
||||
}
|
||||
// Unquote and lowercase so escapes or casing cannot hide a host.
|
||||
value := node.Value
|
||||
if v, err := strconv.Unquote(value); err == nil {
|
||||
value = v
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
for _, host := range forbiddenHosts {
|
||||
if strings.Contains(lower, host) {
|
||||
pos := fset.Position(node.Pos())
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: "no-hardcoded-endpoint",
|
||||
Action: lintapi.ActionReject,
|
||||
File: display,
|
||||
Line: pos.Line,
|
||||
Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints",
|
||||
Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host",
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
231
lint/domaincontract/scan_test.go
Normal file
231
lint/domaincontract/scan_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
// requireEnforced pins every violation to the rejecting rule: a regression
|
||||
// that downgrades the guard to an advisory action must fail here.
|
||||
func requireEnforced(t *testing.T, vs []lintapi.Violation) {
|
||||
t.Helper()
|
||||
for _, v := range vs {
|
||||
if v.Rule != "no-hardcoded-endpoint" || v.Action != lintapi.ActionReject {
|
||||
t.Fatalf("violation not CI-enforced: rule=%q action=%q (%s:%d)", v.Rule, v.Action, v.File, v.Line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, root, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRepo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Negative: the resolver may hold the literals inside ResolveEndpoints.
|
||||
writeFile(t, root, "internal/core/types.go", "package core\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n")
|
||||
// Negative: non-resolver hosts + a comment reference must not trip the guard.
|
||||
writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n")
|
||||
// Negative: _test.go files may assert literals.
|
||||
writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n")
|
||||
// Positive: production literal outside the allowlist.
|
||||
writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireEnforced(t, vs)
|
||||
if len(vs) != 1 {
|
||||
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
|
||||
}
|
||||
if filepath.Base(vs[0].File) != "z.go" {
|
||||
t.Errorf("violation in %q, want z.go", vs[0].File)
|
||||
}
|
||||
}
|
||||
|
||||
// SDK base-URL globals are rejected only when selected off an SDK root
|
||||
// import; same-name identifiers elsewhere pass.
|
||||
func TestScanRepoSDKConstants(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Positive: default and renamed imports of the SDK root package.
|
||||
writeFile(t, root, "shortcuts/x/ws.go",
|
||||
"package x\n\nimport \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar d = lark.FeishuBaseUrl\n")
|
||||
writeFile(t, root, "shortcuts/x/ws2.go",
|
||||
"package x\n\nimport sdk \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar e = sdk.LarkBaseUrl\n")
|
||||
// Negative: test file may reference the globals.
|
||||
writeFile(t, root, "shortcuts/x/ws_test.go",
|
||||
"package x\n\nimport lark \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar p = lark.LarkBaseUrl\n")
|
||||
// Negative: same-name local identifier without the SDK import.
|
||||
writeFile(t, root, "shortcuts/y/local.go",
|
||||
"package y\n\nvar FeishuBaseUrl = \"local\"\nvar q = FeishuBaseUrl\n")
|
||||
// Negative: same-name symbol from an unrelated package.
|
||||
writeFile(t, root, "shortcuts/z/other.go",
|
||||
"package z\n\nimport other \"example.com/other\"\n\nvar r = other.FeishuBaseUrl\n")
|
||||
// Negative: SDK subpackage import does not export the globals.
|
||||
writeFile(t, root, "shortcuts/w/sub.go",
|
||||
"package w\n\nimport larkws \"github.com/larksuite/oapi-sdk-go/v3/ws\"\n\nvar s = larkws.FeishuBaseUrl\n")
|
||||
// Negative: a local value shadowing the SDK import alias is not the package.
|
||||
writeFile(t, root, "shortcuts/v/shadow.go",
|
||||
"package v\n\nimport lark \"github.com/larksuite/oapi-sdk-go/v3\"\n\ntype endpoint struct { FeishuBaseUrl string }\nvar _ *lark.Client\nfunc local() string { lark := endpoint{}; return lark.FeishuBaseUrl }\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireEnforced(t, vs)
|
||||
if len(vs) != 2 {
|
||||
t.Fatalf("got %d violations, want 2: %+v", len(vs), vs)
|
||||
}
|
||||
files := map[string]bool{}
|
||||
for _, v := range vs {
|
||||
files[filepath.Base(v.File)] = true
|
||||
}
|
||||
if !files["ws.go"] || !files["ws2.go"] {
|
||||
t.Errorf("violations in %v, want ws.go and ws2.go", files)
|
||||
}
|
||||
}
|
||||
|
||||
// forbiddenHosts must equal the https hosts in the resolver source, both ways;
|
||||
// a resolver domain change without a guard update fails here.
|
||||
func TestForbiddenHostsMatchResolver(t *testing.T) {
|
||||
src := filepath.Join("..", "..", "internal", "core", "types.go")
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, src, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse resolver source: %v", err)
|
||||
}
|
||||
// Walk only the receiverless ResolveEndpoints body — the same scope the
|
||||
// production scanner exempts — so unrelated URLs in the file cannot skew
|
||||
// the parity check.
|
||||
var resolverBody ast.Node
|
||||
for _, d := range file.Decls {
|
||||
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil && fd.Name.Name == "ResolveEndpoints" && fd.Body != nil {
|
||||
resolverBody = fd.Body
|
||||
break
|
||||
}
|
||||
}
|
||||
if resolverBody == nil {
|
||||
t.Fatal("ResolveEndpoints function not found in resolver source")
|
||||
}
|
||||
resolverHosts := map[string]bool{}
|
||||
ast.Inspect(resolverBody, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
v, err := strconv.Unquote(lit.Value)
|
||||
if err != nil || !strings.HasPrefix(v, "https://") {
|
||||
return true
|
||||
}
|
||||
// Parse instead of prefix-stripping so a resolver URL that ever gains a
|
||||
// path component still compares by bare host against forbiddenHosts.
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || u.Host == "" {
|
||||
return true
|
||||
}
|
||||
resolverHosts[u.Host] = true
|
||||
return true
|
||||
})
|
||||
|
||||
guardHosts := map[string]bool{}
|
||||
for _, h := range forbiddenHosts {
|
||||
guardHosts[h] = true
|
||||
}
|
||||
for h := range resolverHosts {
|
||||
if !guardHosts[h] {
|
||||
t.Errorf("resolver host %q is not in the guard's forbidden list", h)
|
||||
}
|
||||
}
|
||||
for h := range guardHosts {
|
||||
if !resolverHosts[h] {
|
||||
t.Errorf("guard forbids %q which the resolver does not define", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dot-import rejection and case-insensitive literal matching.
|
||||
func TestScanRepoDotImportAndCase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Positive: dot-import of the SDK root package.
|
||||
writeFile(t, root, "shortcuts/a/dot.go",
|
||||
"package a\n\nimport . \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar d = FeishuBaseUrl\n")
|
||||
// Positive: uppercase host literal.
|
||||
writeFile(t, root, "shortcuts/b/upper.go",
|
||||
"package b\n\nvar u = \"https://OPEN.FEISHU.CN/api\"\n")
|
||||
// Negative: dot-import of an SDK subpackage is out of the globals' scope.
|
||||
writeFile(t, root, "shortcuts/c/sub.go",
|
||||
"package c\n\nimport . \"github.com/larksuite/oapi-sdk-go/v3/ws\"\n\nvar s = 1\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireEnforced(t, vs)
|
||||
if len(vs) != 2 {
|
||||
t.Fatalf("got %d violations, want 2: %+v", len(vs), vs)
|
||||
}
|
||||
files := map[string]bool{}
|
||||
for _, v := range vs {
|
||||
files[filepath.Base(v.File)] = true
|
||||
}
|
||||
if !files["dot.go"] || !files["upper.go"] {
|
||||
t.Errorf("violations in %v, want dot.go and upper.go", files)
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver file is scoped per-function: a hardcoded host outside the
|
||||
// ResolveEndpoints body is rejected.
|
||||
func TestScanRepoResolverFunctionScope(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "internal/core/types.go",
|
||||
"package core\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n\nfunc bypass() string { return \"https://open.feishu.cn\" }\n\ntype localResolver struct{}\nfunc (localResolver) ResolveEndpoints() string { return \"https://open.feishu.cn\" }\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vs) != 2 {
|
||||
t.Fatalf("got %d violations, want 2 (helper and receiver method): %+v", len(vs), vs)
|
||||
}
|
||||
for _, v := range vs {
|
||||
if filepath.Base(v.File) != "types.go" {
|
||||
t.Errorf("violation in %q, want types.go", v.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escape sequences cannot hide a host: literals are unquoted before matching.
|
||||
func TestScanRepoEscapedLiteral(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "internal/e/e.go",
|
||||
"package e\n\nvar h = \"https://open.feishu\\u002ecn\"\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireEnforced(t, vs)
|
||||
if len(vs) != 1 {
|
||||
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
|
||||
}
|
||||
}
|
||||
11
lint/main.go
11
lint/main.go
@@ -1,10 +1,9 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command lintcheck runs the source-level errs/ contract guards (all four checks).
|
||||
// The fifth contract rule (business path must use typed errors) lives in
|
||||
// .golangci.yml as a forbidigo entry; the four checks here are AST-level
|
||||
// guards that golangci-lint cannot express.
|
||||
// Command lintcheck runs repository source-contract guards that golangci-lint
|
||||
// cannot express directly. It currently covers typed-error contracts and the
|
||||
// resolver-owned endpoint contract.
|
||||
//
|
||||
// lintcheck lives in its own Go module under lint/ so its build-time
|
||||
// dependency on golang.org/x/tools/go/packages does not leak into the
|
||||
@@ -30,6 +29,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/lint/domaincontract"
|
||||
"github.com/larksuite/cli/lint/errscontract"
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
@@ -43,6 +43,9 @@ type scanner struct {
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepo(root)
|
||||
}},
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.69",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
18
shortcuts/application/shortcuts.go
Normal file
18
shortcuts/application/shortcuts.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application provides shortcuts for Open Platform app
|
||||
// self-management (slash commands of the current bound app).
|
||||
package application
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all shortcuts of the application domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
SlashCommandUpdate,
|
||||
SlashCommandDelete,
|
||||
}
|
||||
}
|
||||
105
shortcuts/application/slash_command_common.go
Normal file
105
shortcuts/application/slash_command_common.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
|
||||
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
|
||||
|
||||
// clientCacheHint is printed to stderr after every successful write.
|
||||
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
|
||||
|
||||
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
|
||||
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
|
||||
func parseDescriptionI18n(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]string, len(values))
|
||||
for _, v := range values {
|
||||
idx := strings.Index(v, "=")
|
||||
if idx <= 0 || idx == len(v)-1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
lang := strings.TrimSpace(v[:idx])
|
||||
text := v[idx+1:]
|
||||
if lang == "" || strings.TrimSpace(text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: language and text must be non-empty", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
if _, dup := m[lang]; dup {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"duplicate language %q in --description-i18n", lang).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
m[lang] = text
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateCommandName rejects empty and slash-prefixed command names.
|
||||
func validateCommandName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not start with \"/\" - the slash is implied (use %q)",
|
||||
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeCommandIDPathSegment applies the same normalization and escaping to
|
||||
// command IDs in dry-run output and real requests.
|
||||
func encodeCommandIDPathSegment(id string) string {
|
||||
return validate.EncodePathSegment(strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
// buildSlashCommandBody assembles a create/update request body. Only provided
|
||||
// fields are included: PATCH is field-level partial (absent top-level fields
|
||||
// are preserved server-side; a provided i18n map REPLACES the whole map).
|
||||
// icon sits at the top level, sibling of description (verified live; the
|
||||
// official create sample nesting icon inside description is a doc bug).
|
||||
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if command != "" {
|
||||
body["command"] = command
|
||||
}
|
||||
if description != "" || len(i18n) > 0 {
|
||||
desc := map[string]interface{}{}
|
||||
if description != "" {
|
||||
desc["default_value"] = description
|
||||
}
|
||||
if len(i18n) > 0 {
|
||||
desc["i18n"] = i18n
|
||||
}
|
||||
body["description"] = desc
|
||||
}
|
||||
if iconKey != "" {
|
||||
body["icon"] = map[string]interface{}{"icon_key": iconKey}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// isCommandExists reports whether err is the server-side name-collision error
|
||||
// (code=40000000, message contains "command already exists"; verified live).
|
||||
func isCommandExists(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return p.Code == 40000000 && strings.Contains(p.Message, "command already exists")
|
||||
}
|
||||
197
shortcuts/application/slash_command_common_test.go
Normal file
197
shortcuts/application/slash_command_common_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDescriptionI18n_OK(t *testing.T) {
|
||||
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["zh_cn"] != "你好" {
|
||||
t.Errorf("zh_cn = %q", m["zh_cn"])
|
||||
}
|
||||
// 只按首个 = 分割:值内可含 =
|
||||
if m["en_us"] != "Hello=World" {
|
||||
t.Errorf("en_us = %q", m["en_us"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_Empty(t *testing.T) {
|
||||
m, err := parseDescriptionI18n(nil)
|
||||
if err != nil || m != nil {
|
||||
t.Fatalf("nil input: m=%v err=%v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
|
||||
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
|
||||
_, err := parseDescriptionI18n([]string{bad})
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%q: expected validation problem, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
|
||||
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate language error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation/invalid_argument, got %v", err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--description-i18n" {
|
||||
t.Fatalf("expected param --description-i18n, got %#v", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandName(t *testing.T) {
|
||||
if err := validateCommandName("greet", "--command"); err != nil {
|
||||
t.Fatalf("greet: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", " ", "/greet"} {
|
||||
if err := validateCommandName(bad, "--command"); err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSlashCommandBody(t *testing.T) {
|
||||
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
|
||||
if body["command"] != "greet" {
|
||||
t.Errorf("command = %v", body["command"])
|
||||
}
|
||||
desc := body["description"].(map[string]interface{})
|
||||
if desc["default_value"] != "hi" {
|
||||
t.Errorf("default_value = %v", desc["default_value"])
|
||||
}
|
||||
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
|
||||
t.Errorf("i18n = %v", desc["i18n"])
|
||||
}
|
||||
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
|
||||
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
|
||||
t.Errorf("icon = %v", body["icon"])
|
||||
}
|
||||
// partial:不提供的字段不出现(PATCH 语义依赖)
|
||||
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
|
||||
if _, has := partial["command"]; has {
|
||||
t.Error("empty command must be omitted")
|
||||
}
|
||||
if _, has := partial["description"]; has {
|
||||
t.Error("empty description must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandExists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matching code and message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000000),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same message with different code",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'command'. command already exists.").WithCode(40000031),
|
||||
},
|
||||
{
|
||||
name: "same code with different message",
|
||||
err: errs.NewAPIError(errs.SubtypeUnknown,
|
||||
"Invalid Param 'icon_key'. icon_key is invalid.").WithCode(40000000),
|
||||
},
|
||||
{name: "nil error"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isCommandExists(tt.err); got != tt.want {
|
||||
t.Fatalf("isCommandExists() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
|
||||
// reversal of the OAuth-isolation design: all four slash-command shortcuts
|
||||
// declare identical scopes for the bot and user identities (plain Scopes /
|
||||
// ConditionalScopes, no per-identity overrides), so a user-identity
|
||||
// pre-flight sees the same scope set a bot identity would.
|
||||
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantScope string
|
||||
wantConditional string
|
||||
hasConditional bool
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlashCommandList,
|
||||
wantScope: "application:app_slash_command:read",
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: SlashCommandCreate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: SlashCommandUpdate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: SlashCommandDelete,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, identity := range []string{"user", "bot"} {
|
||||
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
|
||||
if !containsStr(declared, tc.wantScope) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
|
||||
}
|
||||
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
118
shortcuts/application/slash_command_create.go
Normal file
118
shortcuts/application/slash_command_create.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandCreate registers a new slash command on the current bound app.
|
||||
var SlashCommandCreate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-create",
|
||||
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
|
||||
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
|
||||
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
|
||||
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and update it in place"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
|
||||
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description must not be blank").WithParam("--description")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("Create a slash command on the current bound app").
|
||||
POST(slashCommandBasePath).
|
||||
Body(body)
|
||||
if runtime.Bool("force") {
|
||||
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
|
||||
action := "created"
|
||||
if err != nil {
|
||||
if !isCommandExists(err) {
|
||||
return err
|
||||
}
|
||||
if !runtime.Bool("force") {
|
||||
p, _ := errs.ProblemOf(err)
|
||||
rewrapped := errs.NewAPIError(errs.SubtypeAlreadyExists, "slash command %q already exists", name).
|
||||
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
|
||||
WithCause(err)
|
||||
if p.Code != 0 {
|
||||
rewrapped = rewrapped.WithCode(p.Code)
|
||||
}
|
||||
if p.LogID != "" {
|
||||
rewrapped = rewrapped.WithLogID(p.LogID)
|
||||
}
|
||||
return rewrapped
|
||||
}
|
||||
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
|
||||
id, rerr := resolveCommandID(runtime, name)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, patchBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action = "updated"
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = action
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
229
shortcuts/application/slash_command_create_test.go
Normal file
229
shortcuts/application/slash_command_create_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func createOKStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", "id-new"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createConflictStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func patchOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/application/v7/app_slash_commands/" + id,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": sampleItem("greet", id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_OK(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createOKStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi",
|
||||
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
|
||||
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "created" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
if data["command_id"] != "id-new" {
|
||||
t.Fatalf("command_id = %v", data["command_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := [][]string{
|
||||
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
|
||||
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
|
||||
}
|
||||
for i, args := range cases {
|
||||
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("case %d: expected validation error", i)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("case %d: expected validation problem, got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeAlreadyExists || p.Code != 40000000 {
|
||||
t.Fatalf("expected api/already_exists code 40000000, got %#v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
|
||||
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
|
||||
}
|
||||
if errors.Unwrap(apiErr) == nil {
|
||||
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createConflictStub())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v (force must convert to update)", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_TrimsCommandBeforeCreateAndForceResolution(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
conflict := createConflictStub()
|
||||
reg.Register(conflict)
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
|
||||
reg.Register(patchOKStub("id-exist"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", " greet ", "--description", "hi", "--force", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(conflict.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured create body: %v", err)
|
||||
}
|
||||
if body["command"] != "greet" {
|
||||
t.Fatalf("command = %q, want trimmed value %q", body["command"], "greet")
|
||||
}
|
||||
}
|
||||
|
||||
func createIconInvalidStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
|
||||
// blindly treating ANY POST failure as a name collision: only the
|
||||
// "command already exists" (40000000) shape may fall through to the
|
||||
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
|
||||
// the code mistakenly attempted a PATCH, the httpmock registry would fail
|
||||
// the unexpected request and surface a different (registry) error instead
|
||||
// of the original icon_key failure asserted below.
|
||||
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createIconInvalidStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the original icon_key error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype == errs.SubtypeAlreadyExists || p.Code != 40000031 {
|
||||
t.Fatalf("expected original API error code 40000031 without collision reclassification, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
|
||||
t.Fatalf("dry-run must show POST path: %s", out)
|
||||
}
|
||||
// icon 顶层:dry-run body 里 icon 不嵌套在 description 内
|
||||
if !strings.Contains(out, "icon_key") {
|
||||
t.Fatalf("dry-run must include body: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandCreate_ForceHelpHasNoMetavar(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
SlashCommandCreate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
forceFlag := cmd.Flags().Lookup("force")
|
||||
if forceFlag == nil {
|
||||
t.Fatal("missing --force flag")
|
||||
}
|
||||
placeholder, usage := pflag.UnquoteUsage(forceFlag)
|
||||
if placeholder != "" {
|
||||
t.Fatalf("boolean --force must not render a value placeholder, got %q", placeholder)
|
||||
}
|
||||
if !strings.Contains(usage, "update it in place") || strings.Contains(usage, "gh ") {
|
||||
t.Fatalf("unexpected --force help: %q", usage)
|
||||
}
|
||||
if help := cmd.Flags().FlagUsages(); !strings.Contains(help, "--force") || !strings.Contains(help, "update it in place") {
|
||||
t.Fatalf("rendered help missing --force description:\n%s", help)
|
||||
}
|
||||
}
|
||||
85
shortcuts/application/slash_command_delete.go
Normal file
85
shortcuts/application/slash_command_delete.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandDelete removes a slash command (irreversible; command_id is not
|
||||
// reused - recreating the same name yields a NEW id).
|
||||
var SlashCommandDelete = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-delete",
|
||||
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-delete --command greet --yes --as bot",
|
||||
"deleted commands may linger in clients for ~5 minutes (client cache)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
return validateCommandName(name, "--command")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.DELETE(slashCommandBasePath + "/" + target)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"action": "deleted", "command_id": id}
|
||||
if name != "" {
|
||||
out["command"] = name
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "deleted command_id %s\n", id)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
135
shortcuts/application/slash_command_delete_test.go
Normal file
135
shortcuts/application/slash_command_delete_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func deleteOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: slashCommandBasePath + "/" + id,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
if errs.CategoryOf(err) != errs.CategoryConfirmation {
|
||||
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
// 上游 DELETE 返回空对象;CLI 必须补 action/command_id(写操作返回资源 ID)
|
||||
if data["action"] != "deleted" || data["command_id"] != "id1" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
|
||||
reg.Register(deleteOKStub("id7"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["command"] != "greet" || data["command_id"] != "id7" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "DELETE" || strings.Contains(got.API[1].Desc, "resolve command_id") {
|
||||
t.Fatalf("second call must be the delete without the resolve description: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
for _, args := range [][]string{
|
||||
{"+slash-command-delete", "--yes", "--as", "bot"},
|
||||
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
|
||||
} {
|
||||
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%v: expected validation error", args)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%v: expected validation problem, got %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", " id/with space?x ", "--yes", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
58
shortcuts/application/slash_command_list.go
Normal file
58
shortcuts/application/slash_command_list.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandList lists all slash commands of the current bound app.
|
||||
var SlashCommandList = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-list",
|
||||
Description: "List all slash commands (/ commands) registered on the currently bound Open Platform app; source of command_id for update/delete",
|
||||
Risk: "read",
|
||||
Scopes: []string{"application:app_slash_command:read"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-list --as bot",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
|
||||
"the upstream API returns all commands at once (max 100 per app, no pagination)",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List all slash commands of the current bound app (read-only)").
|
||||
GET(slashCommandBasePath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out := map[string]interface{}{"items": items, "count": len(items)}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if d, ok := m["description"].(map[string]interface{}); ok {
|
||||
desc, _ = d["default_value"].(string)
|
||||
}
|
||||
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
115
shortcuts/application/slash_command_list_test.go
Normal file
115
shortcuts/application/slash_command_list_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func appTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
|
||||
// Mirrors shortcuts/contact tests.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func listStub(items []interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"items": items},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleItem(name, id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"command": name, "command_id": id,
|
||||
"create_time": "1783318553", "update_time": "1783318553",
|
||||
"description": map[string]interface{}{"default_value": "desc of " + name},
|
||||
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
if data["count"] != float64(2) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
first := items[0].(map[string]interface{})
|
||||
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
|
||||
if _, ok := first[k]; !ok {
|
||||
t.Errorf("missing item key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("empty list must be [] not %v", data["items"])
|
||||
}
|
||||
if data["count"] != float64(0) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
|
||||
t.Fatalf("dry-run must show GET path, got %s", out)
|
||||
}
|
||||
}
|
||||
54
shortcuts/application/slash_command_resolve.go
Normal file
54
shortcuts/application/slash_command_resolve.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// matchCommandID finds the command_id of the item whose "command" equals
|
||||
// name (exact match - the server enforces name uniqueness, so first hit is the
|
||||
// only hit).
|
||||
func matchCommandID(items []interface{}, name string) string {
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["command"] == name {
|
||||
id, _ := m["command_id"].(string)
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandNotFoundError reports a resolution miss against the live list as an
|
||||
// API-category not-found error (the name is a valid argument shape; the
|
||||
// resource simply does not exist server-side - this is not a validation
|
||||
// failure of caller input).
|
||||
func commandNotFoundError(name string) error {
|
||||
return errs.NewAPIError(errs.SubtypeNotFound,
|
||||
"slash command %q not found in the current bound app", name).
|
||||
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
|
||||
}
|
||||
|
||||
// resolveCommandID resolves a command name to its command_id via the live
|
||||
// list endpoint (in-memory only; never touches local files). Requires the
|
||||
// read scope on the current identity.
|
||||
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, error) {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
id := matchCommandID(items, name)
|
||||
if id == "" {
|
||||
return "", commandNotFoundError(name)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
shortcuts/application/slash_command_resolve_test.go
Normal file
41
shortcuts/application/slash_command_resolve_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestMatchCommandID(t *testing.T) {
|
||||
items := []interface{}{
|
||||
sampleItem("greet", "id1"),
|
||||
sampleItem("weather", "id2"),
|
||||
}
|
||||
id := matchCommandID(items, "weather")
|
||||
if id != "id2" {
|
||||
t.Fatalf("got id=%q", id)
|
||||
}
|
||||
id = matchCommandID(items, "nope")
|
||||
if id != "" {
|
||||
t.Fatalf("miss should return empty, got id=%q", id)
|
||||
}
|
||||
// 精确匹配:大小写与空白不做宽容
|
||||
id = matchCommandID(items, "Greet")
|
||||
if id != "" {
|
||||
t.Fatalf("match must be exact, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotFoundErrorShape(t *testing.T) {
|
||||
err := commandNotFoundError("nope")
|
||||
if err == nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
124
shortcuts/application/slash_command_update.go
Normal file
124
shortcuts/application/slash_command_update.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
|
||||
// least one editable field; --description-i18n requires --description (PATCH
|
||||
// replaces the whole description object - sending i18n alone would drop
|
||||
// default_value, so both values must be provided together).
|
||||
func validateUpdateTarget(runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if (id == "") == (name == "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide exactly one of --command-id or --command").WithParam("--command-id")
|
||||
}
|
||||
if name != "" {
|
||||
if err := validateCommandName(name, "--command"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
|
||||
hasI18n := len(runtime.StrArray("description-i18n")) > 0
|
||||
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
|
||||
if !hasDesc && !hasI18n && !hasIcon {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
|
||||
}
|
||||
if hasI18n && !hasDesc {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
|
||||
}
|
||||
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
|
||||
var SlashCommandUpdate = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-update",
|
||||
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
|
||||
Risk: "write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
|
||||
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
|
||||
{Name: "description", Desc: "new default description (description.default_value)"},
|
||||
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
|
||||
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
|
||||
"PATCH is field-level partial: fields you do not pass are preserved server-side",
|
||||
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateUpdateTarget(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
// The CLI validates first; keep this guard for direct DryRun callers.
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
d := common.NewDryRunAPI()
|
||||
target := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if target == "" {
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
|
||||
target = "<resolved_command_id>"
|
||||
} else {
|
||||
target = encodeCommandIDPathSegment(target)
|
||||
}
|
||||
return d.PATCH(slashCommandBasePath + "/" + target).
|
||||
Desc("Update a slash command by command_id").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
if id == "" {
|
||||
resolved, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
|
||||
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["action"] = "updated"
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
150
shortcuts/application/slash_command_update_test.go
Normal file
150
shortcuts/application/slash_command_update_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestSlashCommandUpdate_ByID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["action"] != "updated" {
|
||||
t.Fatalf("action = %v", data["action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
|
||||
reg.Register(patchOKStub("id9"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("expected api/not_found, got %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
|
||||
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
|
||||
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
|
||||
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected validation error", c.name)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("%s: expected validation problem, got %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(patchOKStub("id%2Fwith%20space%3Fx"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command", " greet ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
Method string `json:"method"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
if strings.Contains(got.Description, "resolve command_id") {
|
||||
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
|
||||
}
|
||||
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
|
||||
t.Fatalf("first call must describe name resolution: %#v", got.API)
|
||||
}
|
||||
if got.API[1].Method != "PATCH" || !strings.Contains(got.API[1].Desc, "Update a slash command") {
|
||||
t.Fatalf("second call must describe update: %#v", got.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
|
||||
"--command-id", " id/with space?x ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envlp struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
got := envlp.Data
|
||||
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
|
||||
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
|
||||
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)
|
||||
}
|
||||
}
|
||||
@@ -23,19 +23,21 @@ func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
if env.Data.API[0].Method != "POST" || env.Data.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.Data.API[0].Method, env.Data.API[0].URL)
|
||||
}
|
||||
body := env.API[0].Body
|
||||
body := env.Data.API[0].Body
|
||||
if _, ok := body["start_timestamp_ns"]; !ok {
|
||||
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
|
||||
}
|
||||
@@ -92,14 +94,16 @@ func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
filter := env.API[0].Body["filter"].(map[string]interface{})
|
||||
filter := env.Data.API[0].Body["filter"].(map[string]interface{})
|
||||
deviceTypes := filter["device_types"].([]interface{})
|
||||
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
|
||||
t.Fatalf("device_types = %#v", deviceTypes)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -61,6 +62,7 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
agent := envvars.AgentName()
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": rctx.Str("app-type"),
|
||||
@@ -71,5 +73,8 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
if agent != "" {
|
||||
body["source_agent"] = agent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -273,3 +273,93 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
|
||||
t.Fatalf("dry-run should not contain message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "doubao")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["source_agent"] != "doubao" {
|
||||
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is empty: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if _, present := sent["source_agent"]; present {
|
||||
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +101,16 @@ func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
a := env.Data.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
@@ -136,13 +138,15 @@ func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
if env.Data.API[0].Body["enabled"] != false || env.Data.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.Data.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
@@ -178,14 +182,16 @@ func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
a := env.Data.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
|
||||
@@ -37,13 +37,7 @@ func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbChangelogURL {
|
||||
|
||||
@@ -71,13 +71,7 @@ func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
|
||||
@@ -97,14 +97,7 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbDataImportURL {
|
||||
@@ -131,12 +124,11 @@ func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if len(env.API) != 1 {
|
||||
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
|
||||
}
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
@@ -174,11 +166,7 @@ func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Params["table"] != "customers" {
|
||||
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
|
||||
|
||||
@@ -30,13 +30,7 @@ func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
@@ -91,11 +85,7 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
@@ -180,13 +170,7 @@ func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
@@ -331,14 +315,11 @@ func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if len(env.API) != 1 {
|
||||
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
|
||||
}
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
|
||||
@@ -165,14 +165,7 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -254,11 +247,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
@@ -79,11 +79,7 @@ func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
|
||||
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
@@ -165,13 +165,7 @@ func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -196,11 +190,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
@@ -149,11 +149,7 @@ func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -228,11 +224,7 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
}
|
||||
@@ -353,13 +345,7 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var dryRun dryRunAPIEnvelope
|
||||
got := stdout.String()
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
|
||||
@@ -44,8 +44,9 @@ func appsExternalToolError(err error, format string, args ...any) *errs.Internal
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
|
||||
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
|
||||
// appsSubprocessEnvelopeError classifies a malformed or unexpected response
|
||||
// structure as internal/invalid_response. Used for subprocess envelopes
|
||||
// (+git-credential-init / +env-pull) and server responses (e.g. pre_release).
|
||||
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
@@ -48,13 +48,7 @@ func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
|
||||
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != fileDeleteURL {
|
||||
|
||||
@@ -41,12 +41,7 @@ func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
|
||||
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
|
||||
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)
|
||||
|
||||
@@ -46,13 +46,7 @@ func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
|
||||
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
|
||||
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)
|
||||
|
||||
@@ -95,13 +95,7 @@ func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
@@ -139,11 +133,7 @@ func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
|
||||
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
|
||||
if _, ok := env.API[0].Params[banned]; ok {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user